git.iko.soy/opencode-android
on 5d56632 help sign in
544 lines 24 kB history raw
1package soy.iko.opencode.ui.file
3import androidx.compose.foundation.clickable
4import androidx.compose.foundation.horizontalScroll
5import androidx.compose.foundation.layout.Arrangement
6import androidx.compose.foundation.layout.Box
7import androidx.compose.foundation.layout.Column
8import androidx.compose.foundation.layout.Row
9import androidx.compose.foundation.layout.Spacer
10import androidx.compose.foundation.layout.defaultMinSize
11import androidx.compose.foundation.layout.fillMaxSize
12import androidx.compose.foundation.layout.fillMaxWidth
13import androidx.compose.foundation.layout.padding
14import androidx.compose.foundation.layout.size
15import androidx.compose.foundation.lazy.LazyColumn
16import androidx.compose.foundation.lazy.items
17import androidx.compose.foundation.lazy.itemsIndexed
18import androidx.compose.foundation.rememberScrollState
19import androidx.compose.foundation.text.KeyboardActions
20import androidx.compose.foundation.text.KeyboardOptions
21import androidx.compose.material.icons.Icons
22import androidx.compose.material.icons.automirrored.filled.ArrowBack
23import androidx.compose.material.icons.filled.ChevronRight
24import androidx.compose.material.icons.filled.Close
25import androidx.compose.material.icons.filled.Description
26import androidx.compose.material.icons.filled.Folder
27import androidx.compose.material.icons.filled.Home
28import androidx.compose.material.icons.filled.Search
29import androidx.compose.material3.CircularProgressIndicator
30import androidx.compose.material3.ExperimentalMaterial3Api
31import androidx.compose.material3.FilterChip
32import androidx.compose.material3.HorizontalDivider
33import androidx.compose.material3.Icon
34import androidx.compose.material3.IconButton
35import androidx.compose.material3.MaterialTheme
36import androidx.compose.material3.OutlinedTextField
37import androidx.compose.material3.Scaffold
38import androidx.compose.material3.SnackbarHost
39import androidx.compose.material3.SnackbarHostState
40import androidx.compose.material3.Text
41import androidx.compose.material3.TopAppBar
42import androidx.compose.runtime.Composable
43import androidx.compose.runtime.LaunchedEffect
44import androidx.compose.runtime.getValue
45import androidx.compose.runtime.remember
46import androidx.compose.ui.Alignment
47import androidx.compose.ui.Modifier
48import androidx.compose.ui.graphics.Color
49import androidx.compose.ui.platform.LocalSoftwareKeyboardController
50import androidx.compose.ui.platform.testTag
51import androidx.compose.ui.semantics.Role
52import androidx.compose.ui.semantics.contentDescription
53import androidx.compose.ui.semantics.role
54import androidx.compose.ui.semantics.semantics
55import androidx.compose.ui.text.SpanStyle
56import androidx.compose.ui.text.buildAnnotatedString
57import androidx.compose.ui.text.font.FontFamily
58import androidx.compose.ui.text.font.FontWeight
59import androidx.compose.ui.text.input.ImeAction
60import androidx.compose.ui.text.style.TextOverflow
61import androidx.compose.ui.text.withStyle
62import androidx.compose.ui.unit.dp
63import androidx.compose.ui.res.stringResource
64import androidx.lifecycle.compose.collectAsStateWithLifecycle
65import androidx.lifecycle.viewmodel.compose.viewModel
66import soy.iko.opencode.data.model.FileStatusEntry
67import soy.iko.opencode.data.model.FindMatch
68import soy.iko.opencode.data.model.SymbolResult
69import soy.iko.opencode.data.model.symbolKindLabel
70import soy.iko.opencode.di.AppContainer
71import soy.iko.opencode.R
72import soy.iko.opencode.ui.vmFactory
74@OptIn(ExperimentalMaterial3Api::class)
75@Composable
76fun FileBrowserScreen(
77 container: AppContainer,
78 onOpenFile: (path: String, line: Int?) -> Unit,
79 onBack: () -> Unit,
80) {
81 val vm: FileBrowserViewModel = viewModel(factory = vmFactory { FileBrowserViewModel(container) })
82 val state by vm.state.collectAsStateWithLifecycle()
83 val refreshing by vm.refreshing.collectAsStateWithLifecycle()
84 val snackbar = remember { SnackbarHostState() }
86 LaunchedEffect(Unit) {
87 vm.transientErrors.collect { msg ->
88 snackbar.showSnackbar(msg)
89 }
90 }
92 Scaffold(
93 topBar = {
94 TopAppBar(
95 title = { Text(stringResource(R.string.files), maxLines = 1, overflow = TextOverflow.Ellipsis) },
96 navigationIcon = {
97 IconButton(onClick = onBack) {
98 Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
99 }
100 },
101 )
102 },
103 snackbarHost = { SnackbarHost(snackbar) },
104 ) { padding ->
105 Column(modifier = Modifier.fillMaxSize().padding(padding)) {
106 // Tappable breadcrumb trail so deep paths are navigable.
107 Breadcrumbs(
108 path = state.path,
109 onNavigate = vm::open,
110 modifier = Modifier.fillMaxWidth(),
111 )
112 val keyboardController = LocalSoftwareKeyboardController.current
113 OutlinedTextField(
114 value = state.query,
115 onValueChange = vm::setQuery,
116 modifier = Modifier.fillMaxWidth().padding(start = 12.dp, end = 12.dp, top = 12.dp).testTag("file_search"),
117 label = {
118 Text(
119 stringResource(
120 when (state.mode) {
121 SearchMode.FILES -> R.string.search_files
122 SearchMode.TEXT -> R.string.search_in_files
123 SearchMode.SYMBOL -> R.string.search_symbols_label
124 },
125 ),
126 )
127 },
128 leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
129 trailingIcon = if (state.query.isNotEmpty()) {
130 {
131 IconButton(onClick = { vm.setQuery("") }) {
132 Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.clear_search))
133 }
134 }
135 } else null,
136 singleLine = true,
137 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
138 keyboardActions = KeyboardActions(onSearch = { keyboardController?.hide() }),
139 )
140 // Mode selector: file names / contents / symbols.
141 Row(
142 modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).padding(horizontal = 12.dp),
143 horizontalArrangement = Arrangement.spacedBy(8.dp),
144 ) {
145 SearchModeChip(stringResource(R.string.search_mode_files), state.mode == SearchMode.FILES) { vm.setMode(SearchMode.FILES) }
146 SearchModeChip(stringResource(R.string.search_mode_text), state.mode == SearchMode.TEXT) { vm.setMode(SearchMode.TEXT) }
147 SearchModeChip(stringResource(R.string.search_mode_symbols), state.mode == SearchMode.SYMBOL) { vm.setMode(SearchMode.SYMBOL) }
148 }
149 Spacer(Modifier.size(8.dp))
151 androidx.compose.material3.pulltorefresh.PullToRefreshBox(
152 isRefreshing = refreshing,
153 onRefresh = { vm.refresh() },
154 modifier = Modifier.fillMaxSize(),
155 ) {
156 Box(modifier = Modifier.fillMaxSize()) {
157 when {
158 state.loading || state.searching -> {
159 val loadingLabel = stringResource(R.string.loading)
160 CircularProgressIndicator(
161 Modifier.align(Alignment.Center).semantics { contentDescription = loadingLabel },
162 )
163 }
164 state.error != null -> Column(
165 modifier = Modifier.align(Alignment.Center).padding(24.dp),
166 horizontalAlignment = Alignment.CenterHorizontally,
167 ) {
168 Text(
169 state.error ?: "",
170 color = MaterialTheme.colorScheme.error,
171 )
172 androidx.compose.foundation.layout.Spacer(Modifier.size(12.dp))
173 // Retry the operation that actually failed: re-run the search
174 // when one was active (open() would reset the query and drop the
175 // user back into the directory listing), otherwise reload the dir.
176 androidx.compose.material3.TextButton(
177 onClick = { if (state.isSearching) vm.setQuery(state.query) else vm.open(state.path) },
178 ) {
179 Text(stringResource(R.string.retry))
180 }
181 }
182 state.mode == SearchMode.TEXT ->
183 TextResults(state.textResults) { path, line -> onOpenFile(path, line) }
184 state.mode == SearchMode.SYMBOL ->
185 SymbolResults(state.symbolResults) { path, line -> onOpenFile(path, line) }
186 state.isSearching -> SearchResults(state.results) { onOpenFile(it, null) }
187 else -> DirectoryListing(
188 state = state,
189 onOpenDir = vm::open,
190 onUp = vm::up,
191 onOpenFile = { onOpenFile(it, null) },
192 )
193 }
194 }
195 }
196 }
197 }
200@Composable
201private fun Breadcrumbs(path: String, onNavigate: (String) -> Unit, modifier: Modifier = Modifier) {
202 val segments = if (path.isBlank()) emptyList() else path.trim('/').split('/').filter { it.isNotEmpty() }
203 Row(
204 modifier = modifier
205 .horizontalScroll(rememberScrollState())
206 .padding(horizontal = 16.dp, vertical = 8.dp),
207 verticalAlignment = Alignment.CenterVertically,
208 ) {
209 IconButton(onClick = { onNavigate("") }) {
210 Icon(Icons.Filled.Home, contentDescription = stringResource(R.string.root), modifier = Modifier.size(24.dp))
211 }
212 var acc = ""
213 segments.forEachIndexed { index, segment ->
214 acc = if (acc.isEmpty()) segment else "$acc/$segment"
215 Icon(
216 Icons.Filled.ChevronRight,
217 contentDescription = null,
218 modifier = Modifier.size(16.dp),
219 tint = MaterialTheme.colorScheme.onSurfaceVariant,
220 )
221 val target = acc
222 Text(
223 segment,
224 style = MaterialTheme.typography.bodyMedium,
225 color = if (index == segments.lastIndex) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.primary,
226 modifier = Modifier
227 .clickable(role = Role.Button) { onNavigate(target) }
228 .defaultMinSize(minHeight = 48.dp)
229 .padding(horizontal = 6.dp, vertical = 14.dp),
230 )
231 }
232 }
235@Composable
236private fun SearchResults(results: List<String>, onOpenFile: (String) -> Unit) {
237 if (results.isEmpty()) {
238 EmptyFileState(
239 icon = Icons.Filled.Search,
240 message = stringResource(R.string.no_matches),
241 modifier = Modifier.fillMaxWidth().padding(24.dp),
242 )
243 return
244 }
245 LazyColumn(modifier = Modifier.fillMaxSize()) {
246 // Key on path + index (not the bare path): a duplicate path from findFiles would
247 // otherwise crash LazyColumn with "Key already used". Mirrors TextResults/SymbolResults.
248 itemsIndexed(results, key = { i, p -> "$p:$i" }) { _, path ->
249 // Split the path into directory (muted) + filename (emphasized) so results
250 // are scannable instead of a wall of identical-looking full paths.
251 val dir = path.substringBeforeLast('/', missingDelimiterValue = "").trimEnd('/')
252 val name = path.substringAfterLast('/')
253 FileRow(
254 icon = false,
255 label = name,
256 sublabel = dir.takeIf { it.isNotEmpty() },
257 onClick = { onOpenFile(path) },
258 modifier = Modifier.animateItem(),
259 )
260 HorizontalDivider()
261 }
262 }
265@OptIn(ExperimentalMaterial3Api::class)
266@Composable
267private fun SearchModeChip(label: String, selected: Boolean, onClick: () -> Unit) {
268 FilterChip(selected = selected, onClick = onClick, label = { Text(label) })
271/** Content (ripgrep) search results: file + line number, with the matched line highlighted. */
272@Composable
273private fun TextResults(results: List<FindMatch>, onOpen: (String, Int?) -> Unit) {
274 if (results.isEmpty()) {
275 EmptyFileState(
276 icon = Icons.Filled.Search,
277 message = stringResource(R.string.no_matches),
278 modifier = Modifier.fillMaxWidth().padding(24.dp),
279 )
280 return
281 }
282 LazyColumn(modifier = Modifier.fillMaxSize()) {
283 itemsIndexed(results, key = { i, m -> "${m.filePath}:${m.lineNumber}:$i" }) { _, match ->
284 val name = match.filePath.substringAfterLast('/')
285 val dir = match.filePath.substringBeforeLast('/', missingDelimiterValue = "").trimEnd('/')
286 val highlighted = remember(match) { highlightMatchLine(match) }
287 Column(
288 modifier = Modifier
289 .fillMaxWidth()
290 .clickable(role = Role.Button) { onOpen(match.filePath, match.lineNumber) }
291 .padding(horizontal = 16.dp, vertical = 10.dp),
292 ) {
293 Row(verticalAlignment = Alignment.CenterVertically) {
294 Text(
295 name,
296 style = MaterialTheme.typography.bodyMedium,
297 maxLines = 1,
298 overflow = TextOverflow.Ellipsis,
299 modifier = Modifier.weight(1f, fill = false),
300 )
301 Text(
302 ":${match.lineNumber}",
303 style = MaterialTheme.typography.labelSmall,
304 color = MaterialTheme.colorScheme.onSurfaceVariant,
305 fontFamily = FontFamily.Monospace,
306 modifier = Modifier.padding(start = 4.dp),
307 )
308 }
309 if (dir.isNotEmpty()) {
310 Text(
311 dir,
312 style = MaterialTheme.typography.labelSmall,
313 color = MaterialTheme.colorScheme.onSurfaceVariant,
314 maxLines = 1,
315 overflow = TextOverflow.Ellipsis,
316 )
317 }
318 Text(
319 highlighted,
320 style = MaterialTheme.typography.bodySmall,
321 fontFamily = FontFamily.Monospace,
322 maxLines = 2,
323 overflow = TextOverflow.Ellipsis,
324 modifier = Modifier.padding(top = 2.dp),
325 )
326 }
327 HorizontalDivider()
328 }
329 }
332/** Workspace symbol results: symbol name + kind, and the file:line it's defined at. */
333@Composable
334private fun SymbolResults(results: List<SymbolResult>, onOpen: (String, Int?) -> Unit) {
335 if (results.isEmpty()) {
336 EmptyFileState(
337 icon = Icons.Filled.Search,
338 message = stringResource(R.string.no_matches),
339 modifier = Modifier.fillMaxWidth().padding(24.dp),
340 )
341 return
342 }
343 LazyColumn(modifier = Modifier.fillMaxSize()) {
344 itemsIndexed(results, key = { i, s -> "${s.filePath}:${s.name}:$i" }) { _, symbol ->
345 val fileName = symbol.filePath.substringAfterLast('/')
346 Column(
347 modifier = Modifier
348 .fillMaxWidth()
349 .clickable(role = Role.Button) { onOpen(symbol.filePath, symbol.displayLine) }
350 .padding(horizontal = 16.dp, vertical = 10.dp),
351 ) {
352 Row(verticalAlignment = Alignment.CenterVertically) {
353 Text(
354 symbol.name,
355 style = MaterialTheme.typography.bodyMedium,
356 maxLines = 1,
357 overflow = TextOverflow.Ellipsis,
358 modifier = Modifier.weight(1f, fill = false),
359 )
360 Text(
361 symbolKindLabel(symbol.kind),
362 style = MaterialTheme.typography.labelSmall,
363 color = MaterialTheme.colorScheme.primary,
364 modifier = Modifier.padding(start = 8.dp),
365 )
366 }
367 Text(
368 "$fileName:${symbol.displayLine}",
369 style = MaterialTheme.typography.labelSmall,
370 color = MaterialTheme.colorScheme.onSurfaceVariant,
371 fontFamily = FontFamily.Monospace,
372 maxLines = 1,
373 overflow = TextOverflow.Ellipsis,
374 )
375 }
376 HorizontalDivider()
377 }
378 }
381/** Build the matched line with each ripgrep submatch bolded. Offsets are byte columns
382 * within the line; clamped to the (trimmed) line length so a multibyte line can't crash. */
383private fun highlightMatchLine(match: FindMatch): androidx.compose.ui.text.AnnotatedString {
384 val line = match.lineText
385 return buildAnnotatedString {
386 var cursor = 0
387 for (sub in match.submatches.sortedBy { it.start }) {
388 val start = sub.start.coerceIn(0, line.length)
389 val end = sub.end.coerceIn(start, line.length)
390 if (start > cursor) append(line.substring(cursor, start))
391 withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { append(line.substring(start, end)) }
392 cursor = end
393 }
394 if (cursor < line.length) append(line.substring(cursor))
395 }
398@Composable
399private fun DirectoryListing(
400 state: FileBrowserState,
401 onOpenDir: (String) -> Unit,
402 onUp: () -> Unit,
403 onOpenFile: (String) -> Unit,
404) {
405 if (state.entries.isEmpty()) {
406 EmptyFileState(
407 icon = Icons.Filled.Folder,
408 message = stringResource(R.string.empty_folder),
409 modifier = Modifier.fillMaxWidth().padding(24.dp),
410 )
411 return
412 }
413 LazyColumn(modifier = Modifier.fillMaxSize()) {
414 if (state.path.isNotBlank()) {
415 item(key = "__up") {
416 FileRow(icon = true, label = "..", onClick = onUp)
417 HorizontalDivider()
418 }
419 }
420 items(state.entries, key = { it.path + "_" + it.name }) { node ->
421 FileRow(
422 icon = node.isDirectory,
423 label = node.name,
424 onClick = { if (node.isDirectory) onOpenDir(node.path) else onOpenFile(node.path) },
425 status = state.statusMap[node.path],
426 modifier = Modifier.animateItem(),
427 )
428 HorizontalDivider()
429 }
430 }
433/** Shared empty-state for the file browser: icon + message, matching the icon+text
434 * pattern used by the session and server lists (the prior versions were bare Text
435 * lines that read as status messages rather than intentional empty states). */
436@Composable
437private fun EmptyFileState(icon: androidx.compose.ui.graphics.vector.ImageVector, message: String, modifier: Modifier = Modifier) {
438 Column(
439 modifier = modifier,
440 horizontalAlignment = Alignment.CenterHorizontally,
441 ) {
442 Icon(
443 icon,
444 contentDescription = null,
445 modifier = Modifier.size(48.dp),
446 tint = MaterialTheme.colorScheme.onSurfaceVariant,
447 )
448 Spacer(Modifier.size(12.dp))
449 Text(
450 message,
451 style = MaterialTheme.typography.bodyMedium,
452 color = MaterialTheme.colorScheme.onSurfaceVariant,
453 )
454 }
457@Composable
458private fun FileRow(
459 icon: Boolean,
460 label: String,
461 onClick: () -> Unit,
462 status: FileStatusEntry? = null,
463 sublabel: String? = null,
464 modifier: Modifier = Modifier,
465) {
466 val fileDesc = if (label == "..") stringResource(R.string.parent_dir)
467 else if (icon) stringResource(R.string.folder, label)
468 else stringResource(R.string.file_label, label)
469 // Build a combined description so TalkBack announces both the file name and its git
470 // status. The parent sets an explicit contentDescription (mergeDescendants), which
471 // would otherwise suppress the StatusBadge's own description.
472 val statusDesc = status?.let {
473 when (it.status) {
474 "added" -> stringResource(R.string.git_added)
475 "modified" -> stringResource(R.string.git_modified)
476 "deleted" -> stringResource(R.string.git_deleted)
477 else -> ""
478 }
479 }.orEmpty()
480 val fullDesc = if (statusDesc.isNotEmpty()) "$fileDesc, $statusDesc" else fileDesc
481 Row(
482 modifier = modifier
483 .fillMaxWidth()
484 .defaultMinSize(minHeight = 48.dp)
485 .clickable(role = Role.Button, onClick = onClick)
486 .semantics(mergeDescendants = true) { contentDescription = fullDesc }
487 .padding(horizontal = 16.dp, vertical = 14.dp),
488 verticalAlignment = Alignment.CenterVertically,
489 ) {
490 Icon(
491 if (icon) Icons.Filled.Folder else Icons.Filled.Description,
492 contentDescription = null,
493 modifier = Modifier.size(20.dp),
494 tint = if (icon) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
495 )
496 Column(modifier = Modifier.weight(1f).padding(start = 8.dp)) {
497 Text(
498 label,
499 style = MaterialTheme.typography.bodyLarge,
500 maxLines = 1,
501 overflow = TextOverflow.Ellipsis,
502 )
503 if (sublabel != null) {
504 Text(
505 sublabel,
506 style = MaterialTheme.typography.labelSmall,
507 color = MaterialTheme.colorScheme.onSurfaceVariant,
508 maxLines = 1,
509 overflow = TextOverflow.Ellipsis,
510 )
511 }
512 }
513 if (status != null) StatusBadge(status)
514 }
517@Composable
518private fun StatusBadge(status: FileStatusEntry) {
519 val (letter, color) = when (status.status) {
520 "added" -> "A" to MaterialTheme.colorScheme.primary
521 "modified" -> "M" to MaterialTheme.colorScheme.tertiary
522 "deleted" -> "D" to MaterialTheme.colorScheme.error
523 else -> "·" to MaterialTheme.colorScheme.onSurfaceVariant
524 }
525 Row(
526 verticalAlignment = Alignment.CenterVertically,
527 ) {
528 Text(
529 letter,
530 style = MaterialTheme.typography.labelMedium,
531 fontFamily = FontFamily.Monospace,
532 color = color,
533 )
534 if (status.added > 0 || status.removed > 0) {
535 Text(
536 "+${status.added} −${status.removed}",
537 style = MaterialTheme.typography.labelSmall,
538 fontFamily = FontFamily.Monospace,
539 color = MaterialTheme.colorScheme.onSurfaceVariant,
540 modifier = Modifier.padding(start = 4.dp),
541 )
542 }
543 }