git.iko.soy/opencode-android
on a0ce9ce help sign in
315 lines 14 kB history raw
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.defaultMinSize
7import androidx.compose.foundation.layout.fillMaxWidth
8import androidx.compose.foundation.layout.heightIn
9import androidx.compose.foundation.layout.padding
10import androidx.compose.foundation.layout.size
11import androidx.compose.foundation.rememberScrollState
12import androidx.compose.foundation.selection.selectable
13import androidx.compose.foundation.text.KeyboardActions
14import androidx.compose.foundation.text.KeyboardOptions
15import androidx.compose.foundation.verticalScroll
16import androidx.compose.material3.AlertDialog
17import androidx.compose.material3.CircularProgressIndicator
18import androidx.compose.material3.MaterialTheme
19import androidx.compose.material3.OutlinedTextField
20import androidx.compose.material3.RadioButton
21import androidx.compose.material3.Text
22import androidx.compose.material3.TextButton
23import androidx.compose.runtime.Composable
24import androidx.compose.runtime.LaunchedEffect
25import androidx.compose.runtime.getValue
26import androidx.compose.runtime.mutableStateOf
27import androidx.compose.runtime.remember
28import androidx.compose.runtime.saveable.Saver
29import androidx.compose.runtime.saveable.rememberSaveable
30import androidx.compose.runtime.setValue
31import androidx.compose.ui.Alignment
32import androidx.compose.ui.Modifier
33import androidx.compose.ui.focus.FocusRequester
34import androidx.compose.ui.focus.focusRequester
35import androidx.compose.ui.semantics.Role
36import androidx.compose.ui.text.font.FontFamily
37import androidx.compose.ui.text.input.ImeAction
38import androidx.compose.ui.text.style.TextOverflow
39import androidx.compose.ui.unit.dp
40import androidx.compose.ui.res.stringResource
41import soy.iko.opencode.R
42import soy.iko.opencode.data.network.NetworkConfig
44/** The user's directory choice in the new-session picker. */
45private sealed interface DirChoice {
46 /** Create with no directory override — the server uses its launch cwd. */
47 object ServerDefault : DirChoice
49 /** A known worktree / recent-session directory. */
50 data class Known(val path: String) : DirChoice
52 /** A path the user types by hand. */
53 object Custom : DirChoice
56// Persist the radio selection across configuration changes / process death as a short
57// string tag ("d" = server default, "c" = custom, "k:<path>" = a known directory).
58private val dirChoiceSaver = Saver<DirChoice, String>(
59 save = {
60 when (it) {
61 is DirChoice.ServerDefault -> "d"
62 is DirChoice.Custom -> "c"
63 is DirChoice.Known -> "k:${it.path}"
64 }
65 },
66 restore = {
67 when {
68 it == "c" -> DirChoice.Custom
69 it.startsWith("k:") -> DirChoice.Known(it.removePrefix("k:"))
70 else -> DirChoice.ServerDefault
71 }
72 },
75/**
76 * Dialog shown when creating a new session: lets the user pick the working directory the
77 * agent will run in. Offers the server's default (cwd), the directories the server knows
78 * about ([options].projects) plus any directories of existing [sessionDirectories], and a
79 * free-text path. Sends the resolved path (or null for the server default) to [onCreate].
80 *
81 * [lastChosenDirectory] preselects the directory used for the previous new session.
82 */
83@Composable
84fun NewSessionDialog(
85 options: DirectoryOptionsState,
86 sessionDirectories: List<String>,
87 lastChosenDirectory: String?,
88 creating: Boolean,
89 onCreate: (directory: String?, title: String?) -> Unit,
90 onDismiss: () -> Unit,
91) {
92 // Directories to list as selectable rows: known projects + directories of existing
93 // sessions + the last chosen one, minus the server default (which has its own row).
94 val knownDirs = remember(options.projects, sessionDirectories, lastChosenDirectory, options.serverDefault) {
95 (options.projects + sessionDirectories + listOfNotNull(lastChosenDirectory))
96 .filter { it.isNotBlank() && it != options.serverDefault }
97 .distinct()
98 }
100 // Preselect the last chosen directory (or the most-recent session's directory); both are
101 // included in knownDirs above, so a non-null, non-default preselect always maps to a row.
102 var choice by rememberSaveable(stateSaver = dirChoiceSaver) {
103 val preselect = lastChosenDirectory ?: sessionDirectories.firstOrNull { it.isNotBlank() }
104 mutableStateOf(
105 if (preselect == null || preselect == options.serverDefault) DirChoice.ServerDefault
106 else DirChoice.Known(preselect),
107 )
108 }
109 var customText by rememberSaveable { mutableStateOf("") }
110 // Optional title so a new session doesn't land in the list as "Untitled session" requiring a
111 // rename. Trimmed to a sane max; an all-whitespace value is treated as no title.
112 var titleText by rememberSaveable { mutableStateOf("") }
113 val titleLimit = NetworkConfig.maxSessionTitleChars
115 // The initializer above may have run while options were still loading (serverDefault
116 // null), pinning choice to DirChoice.Known(preselect). Once the real serverDefault
117 // arrives, knownDirs filters that path out (it has its own row), so no DirectoryRow
118 // matches a Known(serverDefault) choice and every radio shows unselected. Collapse it
119 // into ServerDefault so the selected radio always tracks a visible row.
120 LaunchedEffect(options.serverDefault) {
121 val current = choice
122 if (current is DirChoice.Known && current.path == options.serverDefault) {
123 choice = DirChoice.ServerDefault
124 }
125 }
127 val resolved: String? = when (val c = choice) {
128 is DirChoice.ServerDefault -> null
129 is DirChoice.Known -> c.path
130 is DirChoice.Custom -> customText.trim().takeIf { it.isNotBlank() }
131 }
132 val resolvedTitle: String? = titleText.trim().takeIf { it.isNotBlank() }
133 // Custom requires a non-blank path; the other choices are always valid.
134 val canCreate = !creating && (choice !is DirChoice.Custom || customText.isNotBlank())
136 AlertDialog(
137 onDismissRequest = { if (!creating) onDismiss() },
138 title = { Text(stringResource(R.string.new_session)) },
139 text = {
140 Column(
141 modifier = Modifier
142 .fillMaxWidth()
143 .heightIn(max = 380.dp)
144 .verticalScroll(rememberScrollState()),
145 ) {
146 OutlinedTextField(
147 value = titleText,
148 onValueChange = { titleText = it.take(titleLimit) },
149 modifier = Modifier.fillMaxWidth().padding(bottom = 12.dp),
150 label = { Text(stringResource(R.string.session_title_hint)) },
151 placeholder = { Text(stringResource(R.string.new_session)) },
152 singleLine = true,
153 enabled = !creating,
154 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
155 supportingText = { TitleCharCounter(titleText.length, titleLimit) },
156 )
157 Text(
158 stringResource(R.string.directory_label),
159 style = MaterialTheme.typography.labelLarge,
160 color = MaterialTheme.colorScheme.onSurfaceVariant,
161 modifier = Modifier.padding(bottom = 4.dp),
162 )
164 DirectoryRow(
165 label = stringResource(R.string.directory_server_default),
166 sublabel = options.serverDefault,
167 selected = choice is DirChoice.ServerDefault,
168 enabled = !creating,
169 onSelect = { choice = DirChoice.ServerDefault },
170 )
172 knownDirs.forEach { path ->
173 DirectoryRow(
174 label = path.trimEnd('/').substringAfterLast('/').ifBlank { path },
175 sublabel = path,
176 selected = (choice as? DirChoice.Known)?.path == path,
177 enabled = !creating,
178 onSelect = { choice = DirChoice.Known(path) },
179 )
180 }
182 DirectoryRow(
183 label = stringResource(R.string.directory_custom),
184 sublabel = null,
185 selected = choice is DirChoice.Custom,
186 enabled = !creating,
187 onSelect = { choice = DirChoice.Custom },
188 )
189 if (choice is DirChoice.Custom) {
190 CustomPathField(
191 text = customText,
192 onTextChange = { customText = it },
193 enabled = !creating,
194 canCreate = canCreate,
195 onCreate = { onCreate(resolved, resolvedTitle) },
196 )
197 }
199 if (options.loading && !options.loaded) {
200 Row(
201 modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
202 verticalAlignment = Alignment.CenterVertically,
203 ) {
204 CircularProgressIndicator(Modifier.size(16.dp), strokeWidth = 2.dp)
205 Text(
206 stringResource(R.string.directory_loading),
207 style = MaterialTheme.typography.bodySmall,
208 color = MaterialTheme.colorScheme.onSurfaceVariant,
209 modifier = Modifier.padding(start = 8.dp),
210 )
211 }
212 }
213 }
214 },
215 confirmButton = {
216 TextButton(onClick = { onCreate(resolved, resolvedTitle) }, enabled = canCreate) {
217 if (creating) {
218 CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp)
219 } else {
220 Text(stringResource(R.string.create))
221 }
222 }
223 },
224 dismissButton = {
225 TextButton(onClick = onDismiss, enabled = !creating) { Text(stringResource(R.string.cancel)) }
226 },
227 )
230/** Character counter for the session-title field, shown only once the title nears the cap so a
231 * normal short title isn't cluttered. Extracted from [NewSessionDialog] to keep its complexity
232 * under the detekt threshold. */
233@Composable
234private fun TitleCharCounter(length: Int, limit: Int) {
235 if (length >= (limit * 0.8f).toInt()) {
236 Text("$length / $limit")
237 }
240/** The custom-path text field shown when the Custom directory choice is selected. Autofocuses
241 * on appearance and marks itself error when blank so the user sees why Create is disabled.
242 * Extracted from [NewSessionDialog] to keep its cyclomatic complexity under the threshold. */
243@Composable
244private fun CustomPathField(
245 text: String,
246 onTextChange: (String) -> Unit,
247 enabled: Boolean,
248 canCreate: Boolean,
249 onCreate: () -> Unit,
250) {
251 // Autofocus the path field the moment the Custom row is chosen so the keyboard is ready
252 // without a second tap.
253 val customFocus = remember { FocusRequester() }
254 LaunchedEffect(Unit) { customFocus.requestFocus() }
255 // Mark the field error when Custom is selected but the path is still blank, so the user
256 // sees *why* Create is disabled instead of inferring it from the disabled button alone.
257 val customBlank = text.isBlank()
258 OutlinedTextField(
259 value = text,
260 onValueChange = onTextChange,
261 modifier = Modifier
262 .fillMaxWidth()
263 .focusRequester(customFocus)
264 .padding(start = 36.dp, top = 4.dp, bottom = 4.dp),
265 placeholder = { Text(stringResource(R.string.directory_custom_hint)) },
266 singleLine = true,
267 enabled = enabled,
268 isError = customBlank,
269 supportingText = if (customBlank) {
270 { Text(stringResource(R.string.custom_path_required), color = MaterialTheme.colorScheme.error) }
271 } else null,
272 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
273 keyboardActions = KeyboardActions(onDone = { if (canCreate) onCreate() }),
274 )
277@Composable
278private fun DirectoryRow(
279 label: String,
280 sublabel: String?,
281 selected: Boolean,
282 enabled: Boolean,
283 onSelect: () -> Unit,
284) {
285 Row(
286 modifier = Modifier
287 .fillMaxWidth()
288 .defaultMinSize(minHeight = 48.dp)
289 .selectable(selected = selected, enabled = enabled, role = Role.RadioButton, onClick = onSelect)
290 .padding(vertical = 4.dp),
291 verticalAlignment = Alignment.CenterVertically,
292 ) {
293 RadioButton(selected = selected, onClick = null, enabled = enabled)
294 Column(modifier = Modifier.padding(start = 12.dp)) {
295 Text(
296 label,
297 style = MaterialTheme.typography.bodyLarge,
298 maxLines = 1,
299 overflow = TextOverflow.Ellipsis,
300 )
301 if (!sublabel.isNullOrBlank() && sublabel != label) {
302 // Full path can be long; let it scroll horizontally rather than truncate so
303 // the user can verify exactly which directory they're selecting.
304 Text(
305 sublabel,
306 style = MaterialTheme.typography.bodySmall,
307 color = MaterialTheme.colorScheme.onSurfaceVariant,
308 fontFamily = FontFamily.Monospace,
309 maxLines = 1,
310 modifier = Modifier.horizontalScroll(rememberScrollState()),
311 )
312 }
313 }
314 }