1package soy.iko.opencode.ui.file
3import androidx.activity.compose.BackHandler
4import androidx.compose.foundation.layout.Box
5import androidx.compose.foundation.layout.Row
6import androidx.compose.foundation.layout.fillMaxHeight
7import androidx.compose.foundation.layout.fillMaxSize
8import androidx.compose.foundation.layout.widthIn
9import androidx.compose.material.icons.Icons
10import androidx.compose.material.icons.filled.Description
11import androidx.compose.material.icons.filled.Refresh
12import androidx.compose.material3.VerticalDivider
13import androidx.compose.runtime.Composable
14import androidx.compose.runtime.LaunchedEffect
15import androidx.compose.runtime.getValue
16import androidx.compose.runtime.key
17import androidx.compose.runtime.mutableStateOf
18import androidx.compose.runtime.remember
19import androidx.compose.runtime.rememberCoroutineScope
20import androidx.compose.runtime.saveable.rememberSaveable
21import androidx.compose.runtime.setValue
22import androidx.compose.ui.Modifier
23import androidx.compose.ui.res.stringResource
24import androidx.compose.ui.unit.dp
25import androidx.lifecycle.compose.collectAsStateWithLifecycle
26import androidx.lifecycle.viewmodel.compose.viewModel
27import kotlinx.coroutines.launch
28import soy.iko.opencode.R
29import soy.iko.opencode.data.network.NetworkConfig
30import soy.iko.opencode.di.AppContainer
31import soy.iko.opencode.ui.components.EmptyState
32import soy.iko.opencode.ui.vmFactory
33import soy.iko.opencode.util.runCatchingCancellable
36 * Master–detail layout for the file browser on wide screens (≥ 840dp): the directory listing
37 * on the left, the selected file's viewer on the right. Mirrors [TwoPaneSessionChat]: each
38 * side hosts an existing screen (which carries its own Scaffold + app bar), so the panes are
39 * self-contained. Selecting a file updates the detail pane rather than pushing a destination;
40 * the viewer's onBack clears the selection.
43fun TwoPaneFileBrowser(
44 container: AppContainer,
46 modifier: Modifier = Modifier,
48 var selected by rememberSaveable { mutableStateOf<String?>(null) }
49 // A deep-linked file open (opencode://file/{path}) targets the detail pane here.
50 val pendingOpenFile by container.pendingOpenFile.collectAsStateWithLifecycle()
51 LaunchedEffect(pendingOpenFile) {
52 pendingOpenFile?.let {
54 container.consumePendingOpenFile()
58 // Stale-selection cleanup: if the selected file's view fails to load (the file was
59 // deleted server-side or otherwise disappeared), clear the selection so the detail
60 // pane doesn't keep showing stale content. Previously this made an extra API call
61 // (listDirectory) per selection change to proactively check existence; that round-trip
62 // was wasteful and could lag the UI on slow connections. Instead we rely on the
63 // FileViewScreen's own error state: when its load fails, it calls onBack, which clears
64 // the selection. This is the same pattern the session two-pane uses for deleted sessions.
65 // No extra network call is needed here.
67 Row(modifier = modifier.fillMaxSize()) {
70 .weight(NetworkConfig.twoPaneLeftWeight)
71 .widthIn(max = 460.dp),
74 container = container,
75 // Selecting a file opens it in the detail pane (not the back stack).
76 onOpenFile = { path, _ -> selected = path },
81 VerticalDivider(modifier = Modifier.fillMaxHeight())
83 Box(modifier = Modifier.weight(NetworkConfig.twoPaneRightWeight).fillMaxSize()) {
86 // Share the FileBrowserScreen's VM (same ViewModelStoreOwner) so the empty
87 // detail's "Refresh" action re-fetches the same tree the master pane shows,
88 // rather than leaving the pane inert with no recovery affordance.
89 val browserVm: FileBrowserViewModel = viewModel(
90 factory = vmFactory { FileBrowserViewModel(container) }
92 val scope = rememberCoroutineScope()
94 icon = Icons.Filled.Description,
95 title = stringResource(R.string.empty_file_detail_title),
96 description = stringResource(R.string.empty_file_detail_desc),
97 actionIcon = Icons.Filled.Refresh,
98 actionLabel = stringResource(R.string.refresh),
99 onAction = { scope.launch { runCatchingCancellable { browserVm.refresh() } } },
100 modifier = Modifier.fillMaxSize(),
103 BackHandler { selected = null }
104 // key() on the path so switching files in two-pane mode gives FileViewScreen a
105 // fresh composition (its find/wrap state and scroll are per-file, like the chat
106 // pane's per-session keying).
109 container = container,
111 onBack = { selected = null },
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