1package soy.iko.opencode.ui
3import androidx.compose.animation.core.tween
4import androidx.compose.animation.fadeIn
5import androidx.compose.animation.fadeOut
6import androidx.compose.animation.slideInHorizontally
7import androidx.compose.animation.slideOutHorizontally
8import androidx.compose.foundation.layout.BoxWithConstraints
9import androidx.compose.runtime.Composable
10import androidx.compose.runtime.LaunchedEffect
11import androidx.compose.runtime.getValue
12import androidx.compose.runtime.mutableStateOf
13import androidx.compose.runtime.remember
14import androidx.compose.runtime.rememberCoroutineScope
15import androidx.compose.runtime.setValue
16import androidx.compose.ui.unit.dp
17import androidx.navigation.NavType
18import androidx.navigation.compose.NavHost
19import androidx.navigation.compose.composable
20import androidx.navigation.compose.rememberNavController
21import androidx.navigation.navArgument
22import androidx.lifecycle.compose.collectAsStateWithLifecycle
23import kotlinx.coroutines.launch
24import soy.iko.opencode.di.AppContainer
25import soy.iko.opencode.data.network.NetworkConfig
26import soy.iko.opencode.ui.chat.ChatScreen
27import soy.iko.opencode.ui.file.FileBrowserScreen
28import soy.iko.opencode.ui.file.FileViewScreen
29import soy.iko.opencode.ui.server.ServerEditScreen
30import soy.iko.opencode.ui.server.ServerListScreen
31import soy.iko.opencode.ui.session.SessionListScreen
32import soy.iko.opencode.ui.session.TwoPaneSessionChat
33import soy.iko.opencode.ui.settings.DiagnosticsScreen
34import soy.iko.opencode.ui.settings.SettingsScreen
35import soy.iko.opencode.util.runCatchingCancellable
38fun OpencodeApp(container: AppContainer) {
39 val navController = rememberNavController()
40 val scope = rememberCoroutineScope()
41 val pendingShare by container.pendingShare.collectAsStateWithLifecycle()
42 val pendingOpenSession by container.pendingOpenSession.collectAsStateWithLifecycle()
43 val connection by container.activeConnection.collectAsStateWithLifecycle()
45 // When text is shared into the app, surface the session list so the user can pick
46 // (or create) a conversation to drop it into. The chosen session's draft is set
47 // in [onOpenSession] below. Keyed on `connection` too so a share that arrives
48 // before auto-reconnect completes is retried once the connection is established.
49 // Tracks the last connection id seen so a server switch (a non-null -> non-null
50 // change) doesn't re-bounce the user to the session list for a share they've
51 // already decided to abandon; only the null -> non-null initial connect retries.
52 var lastConnectionId by remember { mutableStateOf<String?>(null) }
53 LaunchedEffect(pendingShare, connection) {
54 if (pendingShare == null) return@LaunchedEffect
55 if (connection == null) { lastConnectionId = null; return@LaunchedEffect }
56 val currentId = connection?.profile?.id
57 // Only (re)navigate on the initial null -> non-null connect. A subsequent
58 // server switch while a share is pending (rare) leaves the user where they
59 // are instead of yanking them back to the session list.
60 if (lastConnectionId != null && lastConnectionId == currentId) return@LaunchedEffect
61 lastConnectionId = currentId
62 if (!navController.popBackStack(Routes.SESSIONS, inclusive = false)) {
63 navController.navigate(Routes.SESSIONS) { launchSingleTop = true }
67 // Adaptive: on wide screens (tablets / unfolded foldables) show the session list and
68 // the open conversation side by side instead of a single-pane back stack.
70 val isTwoPane = maxWidth >= NetworkConfig.twoPaneWidthThresholdDp.dp && connection != null
72 // Open a session requested by a notification tap or deep link, once connected.
73 LaunchedEffect(pendingOpenSession, connection, isTwoPane) {
74 val id = pendingOpenSession ?: return@LaunchedEffect
75 if (connection == null) return@LaunchedEffect
76 // In two-pane mode the detail pane (TwoPaneSessionChat, hosted on the SESSIONS
77 // route) consumes the request and opens it in the detail pane. But it only
78 // exists while the NavHost is on SESSIONS, so if the user is currently on
79 // Files/Settings/FileView bring SESSIONS back to front first — otherwise the
80 // pending request is never consumed and the session never opens. Don't consume
81 // here; let TwoPaneSessionChat's effect do it once it's composed.
83 if (!navController.popBackStack(Routes.SESSIONS, inclusive = false)) {
84 navController.navigate(Routes.SESSIONS) { launchSingleTop = true }
88 container.consumePendingOpenSession()
89 // Ensure SESSIONS sits on the back stack below the chat destination so backing
90 // out of the chat lands on the session list (not the server list). Bring SESSIONS
91 // to front (or push it if a cold-start deep link left only SERVERS on the stack),
92 // then push chat on top — yielding SERVERS -> SESSIONS -> chat.
93 if (!navController.popBackStack(Routes.SESSIONS, inclusive = false)) {
94 navController.navigate(Routes.SESSIONS) { launchSingleTop = true }
96 navController.navigate(Routes.chat(id)) { launchSingleTop = true }
100 navController = navController,
101 startDestination = Routes.SERVERS,
102 // Slide horizontally for forward/back navigation to feel native; fade for
103 // the root so the first frame doesn't slide in from off-screen.
104 enterTransition = { slideInHorizontally(animationSpec = tween(220)) { it } + fadeIn(tween(180)) },
105 exitTransition = { fadeOut(tween(180)) },
106 popEnterTransition = { fadeIn(tween(180)) },
107 popExitTransition = { slideOutHorizontally(animationSpec = tween(220)) { it } + fadeOut(tween(180)) },
110 composable(Routes.SERVERS) {
112 container = container,
114 // Only leave the server list if we're still on it. A pending deep-link /
115 // notification open sets activeConnection (which drives the open-session
116 // effect: SERVERS -> SESSIONS -> chat) *before* autoConnectDone fires this
117 // callback, so a blind navigate(SESSIONS) here would slam a second SESSIONS
118 // on top of the chat it just opened, burying it. Order-independent: if the
119 // deep link already navigated away from SERVERS, this is a no-op.
120 if (navController.currentDestination?.route == Routes.SERVERS) {
121 navController.navigate(Routes.SESSIONS) { launchSingleTop = true }
124 onAddProfile = { navController.navigate(Routes.serverEdit()) },
125 onEditProfile = { id -> navController.navigate(Routes.serverEdit(id)) },
126 onDuplicateProfile = { id -> navController.navigate(Routes.serverEditDuplicate(id)) },
131 route = "${Routes.SERVER_EDIT}?id={id}&dup={dup}",
133 navArgument("id") { type = NavType.StringType; nullable = true; defaultValue = null },
134 navArgument("dup") { type = NavType.StringType; nullable = true; defaultValue = null },
138 container = container,
139 profileId = entry.arguments?.getString("id"),
140 sourceId = entry.arguments?.getString("dup"),
141 onDone = { navController.popBackStack() },
145 composable(Routes.SESSIONS) {
148 container = container,
149 onOpenFiles = { navController.navigate(Routes.FILES) },
150 onOpenFile = { path -> navController.navigate(Routes.fileView(path)) },
151 onOpenSettings = { navController.navigate(Routes.SETTINGS) },
153 scope.launch { runCatchingCancellable { container.disconnect() } }
154 navController.popBackStack(Routes.SERVERS, inclusive = false)
156 onAddServer = { navController.navigate(Routes.serverEdit()) },
160 container = container,
161 onOpenSession = { id ->
162 // Set the draft synchronously in-memory before navigating so the
163 // ChatScreen sees it on first composition. The async persistence
164 // to disk happens in the background via draftStore.set.
165 container.consumePendingShare()?.let { shareText ->
166 container.draftStore.setImmediate(id, shareText)
167 scope.launch { runCatchingCancellable { container.draftStore.set(id, shareText) } }
169 navController.navigate(Routes.chat(id))
172 scope.launch { runCatchingCancellable { container.disconnect() } }
173 navController.popBackStack(Routes.SERVERS, inclusive = false)
175 onOpenFiles = { navController.navigate(Routes.FILES) },
176 onOpenSettings = { navController.navigate(Routes.SETTINGS) },
177 onAddServer = { navController.navigate(Routes.serverEdit()) },
183 route = "${Routes.CHAT}/{sessionId}",
184 arguments = listOf(navArgument("sessionId") { type = NavType.StringType }),
187 container = container,
188 sessionId = entry.arguments?.getString("sessionId").orEmpty(),
189 onBack = { navController.popBackStack() },
190 onOpenFile = { path -> navController.navigate(Routes.fileView(path)) },
194 composable(Routes.FILES) {
196 container = container,
197 onOpenFile = { path -> navController.navigate(Routes.fileView(path)) },
198 onBack = { navController.popBackStack() },
203 route = "${Routes.FILE_VIEW}?path={path}",
204 arguments = listOf(navArgument("path") { type = NavType.StringType; defaultValue = "" }),
207 container = container,
208 path = entry.arguments?.getString("path").orEmpty(),
209 onBack = { navController.popBackStack() },
213 composable(Routes.SETTINGS) {
215 container = container,
216 onBack = { navController.popBackStack() },
217 // Navigate to the server list as a new entry on top of Sessions so the
218 // user can back out to their conversations. Previously this popped back
219 // to the root SERVERS destination, which removed Sessions from the stack
220 // and left the user unable to return to their conversations without
221 // reconnecting. Pop only Settings, then push SERVERS; back from the
222 // server list lands on Sessions (the screen the user was on before
223 // Settings), and connecting pushes a fresh SESSIONS as usual.
225 navController.navigate(Routes.SERVERS) {
226 popUpTo(Routes.SETTINGS) { inclusive = true }
227 launchSingleTop = true
230 onOpenDiagnostics = { navController.navigate(Routes.DIAGNOSTICS) },
234 composable(Routes.DIAGNOSTICS) {
235 DiagnosticsScreen(onBack = { navController.popBackStack() })
keysesc to close
- j k
- move down, up
- enter
- open
- esc
- back, or up a level
- 1 2 3 4
- readme, files, commits, refs
- /
- filter
- y
- copy the clone command
- c
- compare the chosen commit with another (or shift-click both)
- ?
- show or hide this list