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.rememberCoroutineScope
13import androidx.compose.ui.unit.dp
14import androidx.navigation.NavType
15import androidx.navigation.compose.NavHost
16import androidx.navigation.compose.composable
17import androidx.navigation.compose.rememberNavController
18import androidx.navigation.navArgument
19import androidx.lifecycle.compose.collectAsStateWithLifecycle
20import kotlinx.coroutines.launch
21import soy.iko.opencode.di.AppContainer
22import soy.iko.opencode.data.network.NetworkConfig
23import soy.iko.opencode.ui.chat.ChatScreen
24import soy.iko.opencode.ui.file.FileBrowserScreen
25import soy.iko.opencode.ui.file.FileViewScreen
26import soy.iko.opencode.ui.server.ServerEditScreen
27import soy.iko.opencode.ui.server.ServerListScreen
28import soy.iko.opencode.ui.session.SessionListScreen
29import soy.iko.opencode.ui.session.TwoPaneSessionChat
30import soy.iko.opencode.ui.settings.DiagnosticsScreen
31import soy.iko.opencode.ui.settings.SettingsScreen
32import soy.iko.opencode.util.runCatchingCancellable
35fun OpencodeApp(container: AppContainer) {
36 val navController = rememberNavController()
37 val scope = rememberCoroutineScope()
38 val pendingShare by container.pendingShare.collectAsStateWithLifecycle()
39 val pendingOpenSession by container.pendingOpenSession.collectAsStateWithLifecycle()
40 val connection by container.activeConnection.collectAsStateWithLifecycle()
42 // When text is shared into the app, surface the session list so the user can pick
43 // (or create) a conversation to drop it into. The chosen session's draft is set
44 // in [onOpenSession] below. Keyed on `connection` too so a share that arrives
45 // before auto-reconnect completes is retried once the connection is established.
46 LaunchedEffect(pendingShare, connection) {
47 if (pendingShare == null) return@LaunchedEffect
48 if (connection == null) return@LaunchedEffect
49 if (!navController.popBackStack(Routes.SESSIONS, inclusive = false)) {
50 navController.navigate(Routes.SESSIONS) { launchSingleTop = true }
54 // Adaptive: on wide screens (tablets / unfolded foldables) show the session list and
55 // the open conversation side by side instead of a single-pane back stack.
57 val isTwoPane = maxWidth >= NetworkConfig.twoPaneWidthThresholdDp.dp && connection != null
59 // Open a session requested by a notification tap or deep link, once connected.
60 // In two-pane mode the detail pane consumes the request instead of navigating.
61 LaunchedEffect(pendingOpenSession, connection, isTwoPane) {
62 val id = pendingOpenSession ?: return@LaunchedEffect
63 if (connection == null || isTwoPane) return@LaunchedEffect
64 container.consumePendingOpenSession()
65 navController.navigate(Routes.chat(id)) { launchSingleTop = true }
69 navController = navController,
70 startDestination = Routes.SERVERS,
71 // Slide horizontally for forward/back navigation to feel native; fade for
72 // the root so the first frame doesn't slide in from off-screen.
73 enterTransition = { slideInHorizontally(animationSpec = tween(220)) { it } + fadeIn(tween(180)) },
74 exitTransition = { fadeOut(tween(180)) },
75 popEnterTransition = { fadeIn(tween(180)) },
76 popExitTransition = { slideOutHorizontally(animationSpec = tween(220)) { it } + fadeOut(tween(180)) },
79 composable(Routes.SERVERS) {
81 container = container,
82 onConnected = { navController.navigate(Routes.SESSIONS) },
83 onAddProfile = { navController.navigate(Routes.serverEdit()) },
84 onEditProfile = { id -> navController.navigate(Routes.serverEdit(id)) },
89 route = "${Routes.SERVER_EDIT}?id={id}",
90 arguments = listOf(navArgument("id") { type = NavType.StringType; nullable = true; defaultValue = null }),
93 container = container,
94 profileId = entry.arguments?.getString("id"),
95 onDone = { navController.popBackStack() },
99 composable(Routes.SESSIONS) {
102 container = container,
103 onOpenFiles = { navController.navigate(Routes.FILES) },
104 onOpenSettings = { navController.navigate(Routes.SETTINGS) },
106 scope.launch { runCatchingCancellable { container.disconnect() } }
107 navController.popBackStack(Routes.SERVERS, inclusive = false)
112 container = container,
113 onOpenSession = { id ->
114 // Set the draft synchronously in-memory before navigating so the
115 // ChatScreen sees it on first composition. The async persistence
116 // to disk happens in the background via draftStore.set.
117 container.consumePendingShare()?.let { shareText ->
118 container.draftStore.setImmediate(id, shareText)
119 scope.launch { runCatchingCancellable { container.draftStore.set(id, shareText) } }
121 navController.navigate(Routes.chat(id))
124 scope.launch { runCatchingCancellable { container.disconnect() } }
125 navController.popBackStack(Routes.SERVERS, inclusive = false)
127 onOpenFiles = { navController.navigate(Routes.FILES) },
128 onOpenSettings = { navController.navigate(Routes.SETTINGS) },
134 route = "${Routes.CHAT}/{sessionId}",
135 arguments = listOf(navArgument("sessionId") { type = NavType.StringType }),
138 container = container,
139 sessionId = entry.arguments?.getString("sessionId").orEmpty(),
140 onBack = { navController.popBackStack() },
144 composable(Routes.FILES) {
146 container = container,
147 onOpenFile = { path -> navController.navigate(Routes.fileView(path)) },
148 onBack = { navController.popBackStack() },
153 route = "${Routes.FILE_VIEW}?path={path}",
154 arguments = listOf(navArgument("path") { type = NavType.StringType; defaultValue = "" }),
157 container = container,
158 path = entry.arguments?.getString("path").orEmpty(),
159 onBack = { navController.popBackStack() },
163 composable(Routes.SETTINGS) {
165 container = container,
166 onBack = { navController.popBackStack() },
167 // Navigate to the server list as a new entry on top of Sessions so the
168 // user can back out to their conversations. Previously this popped back
169 // to the root SERVERS destination, which removed Sessions from the stack
170 // and left the user unable to return to their conversations without
171 // reconnecting. Pop only Settings, then push SERVERS; back from the
172 // server list lands on Sessions (the screen the user was on before
173 // Settings), and connecting pushes a fresh SESSIONS as usual.
175 navController.navigate(Routes.SERVERS) {
176 popUpTo(Routes.SETTINGS) { inclusive = true }
177 launchSingleTop = true
180 onOpenDiagnostics = { navController.navigate(Routes.DIAGNOSTICS) },
184 composable(Routes.DIAGNOSTICS) {
185 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