1package soy.iko.opencode.ui.session
3import androidx.compose.foundation.horizontalScroll
4import androidx.compose.foundation.layout.Column
5import androidx.compose.foundation.layout.Row
6import androidx.compose.foundation.layout.fillMaxWidth
7import androidx.compose.foundation.layout.heightIn
8import androidx.compose.foundation.layout.padding
9import androidx.compose.foundation.layout.size
10import androidx.compose.foundation.rememberScrollState
11import androidx.compose.foundation.selection.selectable
12import androidx.compose.foundation.text.KeyboardActions
13import androidx.compose.foundation.text.KeyboardOptions
14import androidx.compose.foundation.verticalScroll
15import androidx.compose.material3.AlertDialog
16import androidx.compose.material3.CircularProgressIndicator
17import androidx.compose.material3.MaterialTheme
18import androidx.compose.material3.OutlinedTextField
19import androidx.compose.material3.RadioButton
20import androidx.compose.material3.Text
21import androidx.compose.material3.TextButton
22import androidx.compose.runtime.Composable
23import androidx.compose.runtime.LaunchedEffect
24import androidx.compose.runtime.getValue
25import androidx.compose.runtime.mutableStateOf
26import androidx.compose.runtime.remember
27import androidx.compose.runtime.saveable.Saver
28import androidx.compose.runtime.saveable.rememberSaveable
29import androidx.compose.runtime.setValue
30import androidx.compose.ui.Alignment
31import androidx.compose.ui.Modifier
32import androidx.compose.ui.focus.FocusRequester
33import androidx.compose.ui.focus.focusRequester
34import androidx.compose.ui.semantics.Role
35import androidx.compose.ui.text.font.FontFamily
36import androidx.compose.ui.text.input.ImeAction
37import androidx.compose.ui.text.style.TextOverflow
38import androidx.compose.ui.unit.dp
39import androidx.compose.ui.res.stringResource
40import soy.iko.opencode.R
42/** The user's directory choice in the new-session picker. */
43private sealed interface DirChoice {
44 /** Create with no directory override — the server uses its launch cwd. */
45 object ServerDefault : DirChoice
47 /** A known worktree / recent-session directory. */
48 data class Known(val path: String) : DirChoice
50 /** A path the user types by hand. */
51 object Custom : DirChoice
54// Persist the radio selection across configuration changes / process death as a short
55// string tag ("d" = server default, "c" = custom, "k:<path>" = a known directory).
56private val dirChoiceSaver = Saver<DirChoice, String>(
59 is DirChoice.ServerDefault -> "d"
60 is DirChoice.Custom -> "c"
61 is DirChoice.Known -> "k:${it.path}"
66 it == "c" -> DirChoice.Custom
67 it.startsWith("k:") -> DirChoice.Known(it.removePrefix("k:"))
68 else -> DirChoice.ServerDefault
74 * Dialog shown when creating a new session: lets the user pick the working directory the
75 * agent will run in. Offers the server's default (cwd), the directories the server knows
76 * about ([options].projects) plus any directories of existing [sessionDirectories], and a
77 * free-text path. Sends the resolved path (or null for the server default) to [onCreate].
79 * [lastChosenDirectory] preselects the directory used for the previous new session.
83 options: DirectoryOptionsState,
84 sessionDirectories: List<String>,
85 lastChosenDirectory: String?,
87 onCreate: (String?) -> Unit,
88 onDismiss: () -> Unit,
90 // Directories to list as selectable rows: known projects + directories of existing
91 // sessions + the last chosen one, minus the server default (which has its own row).
92 val knownDirs = remember(options.projects, sessionDirectories, lastChosenDirectory, options.serverDefault) {
93 (options.projects + sessionDirectories + listOfNotNull(lastChosenDirectory))
94 .filter { it.isNotBlank() && it != options.serverDefault }
98 // Preselect the last chosen directory (or the most-recent session's directory); both are
99 // included in knownDirs above, so a non-null, non-default preselect always maps to a row.
100 var choice by rememberSaveable(stateSaver = dirChoiceSaver) {
101 val preselect = lastChosenDirectory ?: sessionDirectories.firstOrNull { it.isNotBlank() }
103 if (preselect == null || preselect == options.serverDefault) DirChoice.ServerDefault
104 else DirChoice.Known(preselect),
107 var customText by rememberSaveable { mutableStateOf("") }
109 // The initializer above may have run while options were still loading (serverDefault
110 // null), pinning choice to DirChoice.Known(preselect). Once the real serverDefault
111 // arrives, knownDirs filters that path out (it has its own row), so no DirectoryRow
112 // matches a Known(serverDefault) choice and every radio shows unselected. Collapse it
113 // into ServerDefault so the selected radio always tracks a visible row.
114 LaunchedEffect(options.serverDefault) {
116 if (current is DirChoice.Known && current.path == options.serverDefault) {
117 choice = DirChoice.ServerDefault
121 val resolved: String? = when (val c = choice) {
122 is DirChoice.ServerDefault -> null
123 is DirChoice.Known -> c.path
124 is DirChoice.Custom -> customText.trim().takeIf { it.isNotBlank() }
126 // Custom requires a non-blank path; the other choices are always valid.
127 val canCreate = !creating && (choice !is DirChoice.Custom || customText.isNotBlank())
130 onDismissRequest = { if (!creating) onDismiss() },
131 title = { Text(stringResource(R.string.new_session)) },
136 .heightIn(max = 380.dp)
137 .verticalScroll(rememberScrollState()),
140 stringResource(R.string.directory_label),
141 style = MaterialTheme.typography.labelLarge,
142 color = MaterialTheme.colorScheme.onSurfaceVariant,
143 modifier = Modifier.padding(bottom = 4.dp),
147 label = stringResource(R.string.directory_server_default),
148 sublabel = options.serverDefault,
149 selected = choice is DirChoice.ServerDefault,
151 onSelect = { choice = DirChoice.ServerDefault },
154 knownDirs.forEach { path ->
156 label = path.trimEnd('/').substringAfterLast('/').ifBlank { path },
158 selected = (choice as? DirChoice.Known)?.path == path,
160 onSelect = { choice = DirChoice.Known(path) },
165 label = stringResource(R.string.directory_custom),
167 selected = choice is DirChoice.Custom,
169 onSelect = { choice = DirChoice.Custom },
171 if (choice is DirChoice.Custom) {
172 // Autofocus the path field the moment the Custom row is chosen so the
173 // keyboard is ready without a second tap.
174 val customFocus = remember { FocusRequester() }
175 LaunchedEffect(Unit) { customFocus.requestFocus() }
178 onValueChange = { customText = it },
181 .focusRequester(customFocus)
182 .padding(start = 36.dp, top = 4.dp, bottom = 4.dp),
183 placeholder = { Text(stringResource(R.string.directory_custom_hint)) },
186 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
187 keyboardActions = KeyboardActions(onDone = { if (canCreate) onCreate(resolved) }),
191 if (options.loading && !options.loaded) {
193 modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
194 verticalAlignment = Alignment.CenterVertically,
196 CircularProgressIndicator(Modifier.size(16.dp), strokeWidth = 2.dp)
198 stringResource(R.string.directory_loading),
199 style = MaterialTheme.typography.bodySmall,
200 color = MaterialTheme.colorScheme.onSurfaceVariant,
201 modifier = Modifier.padding(start = 8.dp),
208 TextButton(onClick = { onCreate(resolved) }, enabled = canCreate) {
210 CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp)
212 Text(stringResource(R.string.create))
217 TextButton(onClick = onDismiss, enabled = !creating) { Text(stringResource(R.string.cancel)) }
223private fun DirectoryRow(
228 onSelect: () -> Unit,
233 .selectable(selected = selected, enabled = enabled, role = Role.RadioButton, onClick = onSelect)
234 .padding(vertical = 4.dp),
235 verticalAlignment = Alignment.CenterVertically,
237 RadioButton(selected = selected, onClick = null, enabled = enabled)
238 Column(modifier = Modifier.padding(start = 12.dp)) {
241 style = MaterialTheme.typography.bodyLarge,
243 overflow = TextOverflow.Ellipsis,
245 if (!sublabel.isNullOrBlank() && sublabel != label) {
246 // Full path can be long; let it scroll horizontally rather than truncate so
247 // the user can verify exactly which directory they're selecting.
250 style = MaterialTheme.typography.bodySmall,
251 color = MaterialTheme.colorScheme.onSurfaceVariant,
252 fontFamily = FontFamily.Monospace,
254 modifier = Modifier.horizontalScroll(rememberScrollState()),
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