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 // In two-pane mode the detail pane consumes the request instead of navigating.
74 // Ensure SESSIONS is on the back stack below the chat destination so backing
75 // out of the chat lands on the session list (not the server list). The
76 // ServerListScreen's auto-connect also navigates to SESSIONS; without this
77 // popUpTo guard the two navigations race and can leave the stack as
78 // SERVERS -> chat (missing SESSIONS) or chat buried under SESSIONS.
79 LaunchedEffect(pendingOpenSession, connection, isTwoPane) {
80 val id = pendingOpenSession ?: return@LaunchedEffect
81 if (connection == null || isTwoPane) return@LaunchedEffect
82 container.consumePendingOpenSession()
83 navController.navigate(Routes.chat(id)) {
84 launchSingleTop = true
85 popUpTo(Routes.SERVERS) { inclusive = false }
90 navController = navController,
91 startDestination = Routes.SERVERS,
92 // Slide horizontally for forward/back navigation to feel native; fade for
93 // the root so the first frame doesn't slide in from off-screen.
94 enterTransition = { slideInHorizontally(animationSpec = tween(220)) { it } + fadeIn(tween(180)) },
95 exitTransition = { fadeOut(tween(180)) },
96 popEnterTransition = { fadeIn(tween(180)) },
97 popExitTransition = { slideOutHorizontally(animationSpec = tween(220)) { it } + fadeOut(tween(180)) },
100 composable(Routes.SERVERS) {
102 container = container,
103 onConnected = { navController.navigate(Routes.SESSIONS) },
104 onAddProfile = { navController.navigate(Routes.serverEdit()) },
105 onEditProfile = { id -> navController.navigate(Routes.serverEdit(id)) },
106 onDuplicateProfile = { id -> navController.navigate(Routes.serverEditDuplicate(id)) },
111 route = "${Routes.SERVER_EDIT}?id={id}&dup={dup}",
113 navArgument("id") { type = NavType.StringType; nullable = true; defaultValue = null },
114 navArgument("dup") { type = NavType.StringType; nullable = true; defaultValue = null },
118 container = container,
119 profileId = entry.arguments?.getString("id"),
120 sourceId = entry.arguments?.getString("dup"),
121 onDone = { navController.popBackStack() },
125 composable(Routes.SESSIONS) {
128 container = container,
129 onOpenFiles = { navController.navigate(Routes.FILES) },
130 onOpenFile = { path -> navController.navigate(Routes.fileView(path)) },
131 onOpenSettings = { navController.navigate(Routes.SETTINGS) },
133 scope.launch { runCatchingCancellable { container.disconnect() } }
134 navController.popBackStack(Routes.SERVERS, inclusive = false)
136 onAddServer = { navController.navigate(Routes.serverEdit()) },
140 container = container,
141 onOpenSession = { id ->
142 // Set the draft synchronously in-memory before navigating so the
143 // ChatScreen sees it on first composition. The async persistence
144 // to disk happens in the background via draftStore.set.
145 container.consumePendingShare()?.let { shareText ->
146 container.draftStore.setImmediate(id, shareText)
147 scope.launch { runCatchingCancellable { container.draftStore.set(id, shareText) } }
149 navController.navigate(Routes.chat(id))
152 scope.launch { runCatchingCancellable { container.disconnect() } }
153 navController.popBackStack(Routes.SERVERS, inclusive = false)
155 onOpenFiles = { navController.navigate(Routes.FILES) },
156 onOpenSettings = { navController.navigate(Routes.SETTINGS) },
157 onAddServer = { navController.navigate(Routes.serverEdit()) },
163 route = "${Routes.CHAT}/{sessionId}",
164 arguments = listOf(navArgument("sessionId") { type = NavType.StringType }),
167 container = container,
168 sessionId = entry.arguments?.getString("sessionId").orEmpty(),
169 onBack = { navController.popBackStack() },
170 onOpenFile = { path -> navController.navigate(Routes.fileView(path)) },
174 composable(Routes.FILES) {
176 container = container,
177 onOpenFile = { path -> navController.navigate(Routes.fileView(path)) },
178 onBack = { navController.popBackStack() },
183 route = "${Routes.FILE_VIEW}?path={path}",
184 arguments = listOf(navArgument("path") { type = NavType.StringType; defaultValue = "" }),
187 container = container,
188 path = entry.arguments?.getString("path").orEmpty(),
189 onBack = { navController.popBackStack() },
193 composable(Routes.SETTINGS) {
195 container = container,
196 onBack = { navController.popBackStack() },
197 // Navigate to the server list as a new entry on top of Sessions so the
198 // user can back out to their conversations. Previously this popped back
199 // to the root SERVERS destination, which removed Sessions from the stack
200 // and left the user unable to return to their conversations without
201 // reconnecting. Pop only Settings, then push SERVERS; back from the
202 // server list lands on Sessions (the screen the user was on before
203 // Settings), and connecting pushes a fresh SESSIONS as usual.
205 navController.navigate(Routes.SERVERS) {
206 popUpTo(Routes.SETTINGS) { inclusive = true }
207 launchSingleTop = true
210 onOpenDiagnostics = { navController.navigate(Routes.DIAGNOSTICS) },
214 composable(Routes.DIAGNOSTICS) {
215 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