1package soy.iko.opencode.ui
3import androidx.compose.foundation.layout.BoxWithConstraints
4import androidx.compose.runtime.Composable
5import androidx.compose.runtime.LaunchedEffect
6import androidx.compose.runtime.getValue
7import androidx.compose.runtime.rememberCoroutineScope
8import androidx.compose.ui.unit.dp
9import androidx.navigation.NavType
10import androidx.navigation.compose.NavHost
11import androidx.navigation.compose.composable
12import androidx.navigation.compose.rememberNavController
13import androidx.navigation.navArgument
14import androidx.lifecycle.compose.collectAsStateWithLifecycle
15import kotlinx.coroutines.launch
16import soy.iko.opencode.di.AppContainer
17import soy.iko.opencode.ui.chat.ChatScreen
18import soy.iko.opencode.ui.file.FileBrowserScreen
19import soy.iko.opencode.ui.file.FileViewScreen
20import soy.iko.opencode.ui.server.ServerEditScreen
21import soy.iko.opencode.ui.server.ServerListScreen
22import soy.iko.opencode.ui.session.SessionListScreen
23import soy.iko.opencode.ui.session.TwoPaneSessionChat
24import soy.iko.opencode.ui.settings.DiagnosticsScreen
25import soy.iko.opencode.ui.settings.SettingsScreen
26import soy.iko.opencode.util.runCatchingCancellable
29fun OpencodeApp(container: AppContainer) {
30 val navController = rememberNavController()
31 val scope = rememberCoroutineScope()
32 val pendingShare by container.pendingShare.collectAsStateWithLifecycle()
33 val pendingOpenSession by container.pendingOpenSession.collectAsStateWithLifecycle()
34 val connection by container.activeConnection.collectAsStateWithLifecycle()
36 // When text is shared into the app, surface the session list so the user can pick
37 // (or create) a conversation to drop it into. The chosen session's draft is set
38 // in [onOpenSession] below. Keyed on `connection` too so a share that arrives
39 // before auto-reconnect completes is retried once the connection is established.
40 LaunchedEffect(pendingShare, connection) {
41 if (pendingShare == null) return@LaunchedEffect
42 if (connection == null) return@LaunchedEffect
43 if (!navController.popBackStack(Routes.SESSIONS, inclusive = false)) {
44 navController.navigate(Routes.SESSIONS) { launchSingleTop = true }
48 // Adaptive: on wide screens (tablets / unfolded foldables) show the session list and
49 // the open conversation side by side instead of a single-pane back stack.
51 val isTwoPane = maxWidth >= 840.dp && connection != null
53 // Open a session requested by a notification tap or deep link, once connected.
54 // In two-pane mode the detail pane consumes the request instead of navigating.
55 LaunchedEffect(pendingOpenSession, connection, isTwoPane) {
56 val id = pendingOpenSession ?: return@LaunchedEffect
57 if (connection == null || isTwoPane) return@LaunchedEffect
58 container.consumePendingOpenSession()
59 navController.navigate(Routes.chat(id)) { launchSingleTop = true }
62 NavHost(navController = navController, startDestination = Routes.SERVERS) {
64 composable(Routes.SERVERS) {
66 container = container,
67 onConnected = { navController.navigate(Routes.SESSIONS) },
68 onAddProfile = { navController.navigate(Routes.serverEdit()) },
69 onEditProfile = { id -> navController.navigate(Routes.serverEdit(id)) },
74 route = "${Routes.SERVER_EDIT}?id={id}",
75 arguments = listOf(navArgument("id") { type = NavType.StringType; nullable = true; defaultValue = null }),
78 container = container,
79 profileId = entry.arguments?.getString("id"),
80 onDone = { navController.popBackStack() },
84 composable(Routes.SESSIONS) {
87 container = container,
88 onOpenFiles = { navController.navigate(Routes.FILES) },
89 onOpenSettings = { navController.navigate(Routes.SETTINGS) },
91 scope.launch { runCatchingCancellable { container.disconnect() } }
92 navController.popBackStack(Routes.SERVERS, inclusive = false)
97 container = container,
98 onOpenSession = { id ->
99 // Set the draft synchronously in-memory before navigating so the
100 // ChatScreen sees it on first composition. The async persistence
101 // to disk happens in the background via draftStore.set.
102 container.consumePendingShare()?.let { shareText ->
103 container.draftStore.setImmediate(id, shareText)
104 scope.launch { runCatchingCancellable { container.draftStore.set(id, shareText) } }
106 navController.navigate(Routes.chat(id))
109 scope.launch { runCatchingCancellable { container.disconnect() } }
110 navController.popBackStack(Routes.SERVERS, inclusive = false)
112 onOpenFiles = { navController.navigate(Routes.FILES) },
113 onOpenSettings = { navController.navigate(Routes.SETTINGS) },
119 route = "${Routes.CHAT}/{sessionId}",
120 arguments = listOf(navArgument("sessionId") { type = NavType.StringType }),
123 container = container,
124 sessionId = entry.arguments?.getString("sessionId").orEmpty(),
125 onBack = { navController.popBackStack() },
129 composable(Routes.FILES) {
131 container = container,
132 onOpenFile = { path -> navController.navigate(Routes.fileView(path)) },
133 onBack = { navController.popBackStack() },
138 route = "${Routes.FILE_VIEW}?path={path}",
139 arguments = listOf(navArgument("path") { type = NavType.StringType; defaultValue = "" }),
142 container = container,
143 path = entry.arguments?.getString("path").orEmpty(),
144 onBack = { navController.popBackStack() },
148 composable(Routes.SETTINGS) {
150 container = container,
151 onBack = { navController.popBackStack() },
153 navController.popBackStack(Routes.SERVERS, inclusive = false)
155 onOpenDiagnostics = { navController.navigate(Routes.DIAGNOSTICS) },
159 composable(Routes.DIAGNOSTICS) {
160 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