git.iko.soy/opencode-android
on c0621ff help sign in
542 lines 23 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 items(results, key = { it }) { path ->
247 // Split the path into directory (muted) + filename (emphasized) so results
248 // are scannable instead of a wall of identical-looking full paths.
249 val dir = path.substringBeforeLast('/', missingDelimiterValue = "").trimEnd('/')
250 val name = path.substringAfterLast('/')
251 FileRow(
252 icon = false,
253 label = name,
254 sublabel = dir.takeIf { it.isNotEmpty() },
255 onClick = { onOpenFile(path) },
256 modifier = Modifier.animateItem(),
257 )
258 HorizontalDivider()
259 }
260 }
263@OptIn(ExperimentalMaterial3Api::class)
264@Composable
265private fun SearchModeChip(label: String, selected: Boolean, onClick: () -> Unit) {
266 FilterChip(selected = selected, onClick = onClick, label = { Text(label) })
269/** Content (ripgrep) search results: file + line number, with the matched line highlighted. */
270@Composable
271private fun TextResults(results: List<FindMatch>, onOpen: (String, Int?) -> Unit) {
272 if (results.isEmpty()) {
273 EmptyFileState(
274 icon = Icons.Filled.Search,
275 message = stringResource(R.string.no_matches),
276 modifier = Modifier.fillMaxWidth().padding(24.dp),
277 )
278 return
279 }
280 LazyColumn(modifier = Modifier.fillMaxSize()) {
281 itemsIndexed(results, key = { i, m -> "${m.filePath}:${m.lineNumber}:$i" }) { _, match ->
282 val name = match.filePath.substringAfterLast('/')
283 val dir = match.filePath.substringBeforeLast('/', missingDelimiterValue = "").trimEnd('/')
284 val highlighted = remember(match) { highlightMatchLine(match) }
285 Column(
286 modifier = Modifier
287 .fillMaxWidth()
288 .clickable(role = Role.Button) { onOpen(match.filePath, match.lineNumber) }
289 .padding(horizontal = 16.dp, vertical = 10.dp),
290 ) {
291 Row(verticalAlignment = Alignment.CenterVertically) {
292 Text(
293 name,
294 style = MaterialTheme.typography.bodyMedium,
295 maxLines = 1,
296 overflow = TextOverflow.Ellipsis,
297 modifier = Modifier.weight(1f, fill = false),
298 )
299 Text(
300 ":${match.lineNumber}",
301 style = MaterialTheme.typography.labelSmall,
302 color = MaterialTheme.colorScheme.onSurfaceVariant,
303 fontFamily = FontFamily.Monospace,
304 modifier = Modifier.padding(start = 4.dp),
305 )
306 }
307 if (dir.isNotEmpty()) {
308 Text(
309 dir,
310 style = MaterialTheme.typography.labelSmall,
311 color = MaterialTheme.colorScheme.onSurfaceVariant,
312 maxLines = 1,
313 overflow = TextOverflow.Ellipsis,
314 )
315 }
316 Text(
317 highlighted,
318 style = MaterialTheme.typography.bodySmall,
319 fontFamily = FontFamily.Monospace,
320 maxLines = 2,
321 overflow = TextOverflow.Ellipsis,
322 modifier = Modifier.padding(top = 2.dp),
323 )
324 }
325 HorizontalDivider()
326 }
327 }
330/** Workspace symbol results: symbol name + kind, and the file:line it's defined at. */
331@Composable
332private fun SymbolResults(results: List<SymbolResult>, onOpen: (String, Int?) -> Unit) {
333 if (results.isEmpty()) {
334 EmptyFileState(
335 icon = Icons.Filled.Search,
336 message = stringResource(R.string.no_matches),
337 modifier = Modifier.fillMaxWidth().padding(24.dp),
338 )
339 return
340 }
341 LazyColumn(modifier = Modifier.fillMaxSize()) {
342 itemsIndexed(results, key = { i, s -> "${s.filePath}:${s.name}:$i" }) { _, symbol ->
343 val fileName = symbol.filePath.substringAfterLast('/')
344 Column(
345 modifier = Modifier
346 .fillMaxWidth()
347 .clickable(role = Role.Button) { onOpen(symbol.filePath, symbol.displayLine) }
348 .padding(horizontal = 16.dp, vertical = 10.dp),
349 ) {
350 Row(verticalAlignment = Alignment.CenterVertically) {
351 Text(
352 symbol.name,
353 style = MaterialTheme.typography.bodyMedium,
354 maxLines = 1,
355 overflow = TextOverflow.Ellipsis,
356 modifier = Modifier.weight(1f, fill = false),
357 )
358 Text(
359 symbolKindLabel(symbol.kind),
360 style = MaterialTheme.typography.labelSmall,
361 color = MaterialTheme.colorScheme.primary,
362 modifier = Modifier.padding(start = 8.dp),
363 )
364 }
365 Text(
366 "$fileName:${symbol.displayLine}",
367 style = MaterialTheme.typography.labelSmall,
368 color = MaterialTheme.colorScheme.onSurfaceVariant,
369 fontFamily = FontFamily.Monospace,
370 maxLines = 1,
371 overflow = TextOverflow.Ellipsis,
372 )
373 }
374 HorizontalDivider()
375 }
376 }
379/** Build the matched line with each ripgrep submatch bolded. Offsets are byte columns
380 * within the line; clamped to the (trimmed) line length so a multibyte line can't crash. */
381private fun highlightMatchLine(match: FindMatch): androidx.compose.ui.text.AnnotatedString {
382 val line = match.lineText
383 return buildAnnotatedString {
384 var cursor = 0
385 for (sub in match.submatches.sortedBy { it.start }) {
386 val start = sub.start.coerceIn(0, line.length)
387 val end = sub.end.coerceIn(start, line.length)
388 if (start > cursor) append(line.substring(cursor, start))
389 withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { append(line.substring(start, end)) }
390 cursor = end
391 }
392 if (cursor < line.length) append(line.substring(cursor))
393 }
396@Composable
397private fun DirectoryListing(
398 state: FileBrowserState,
399 onOpenDir: (String) -> Unit,
400 onUp: () -> Unit,
401 onOpenFile: (String) -> Unit,
402) {
403 if (state.entries.isEmpty()) {
404 EmptyFileState(
405 icon = Icons.Filled.Folder,
406 message = stringResource(R.string.empty_folder),
407 modifier = Modifier.fillMaxWidth().padding(24.dp),
408 )
409 return
410 }
411 LazyColumn(modifier = Modifier.fillMaxSize()) {
412 if (state.path.isNotBlank()) {
413 item(key = "__up") {
414 FileRow(icon = true, label = "..", onClick = onUp)
415 HorizontalDivider()
416 }
417 }
418 items(state.entries, key = { it.path + "_" + it.name }) { node ->
419 FileRow(
420 icon = node.isDirectory,
421 label = node.name,
422 onClick = { if (node.isDirectory) onOpenDir(node.path) else onOpenFile(node.path) },
423 status = state.statusMap[node.path],
424 modifier = Modifier.animateItem(),
425 )
426 HorizontalDivider()
427 }
428 }
431/** Shared empty-state for the file browser: icon + message, matching the icon+text
432 * pattern used by the session and server lists (the prior versions were bare Text
433 * lines that read as status messages rather than intentional empty states). */
434@Composable
435private fun EmptyFileState(icon: androidx.compose.ui.graphics.vector.ImageVector, message: String, modifier: Modifier = Modifier) {
436 Column(
437 modifier = modifier,
438 horizontalAlignment = Alignment.CenterHorizontally,
439 ) {
440 Icon(
441 icon,
442 contentDescription = null,
443 modifier = Modifier.size(48.dp),
444 tint = MaterialTheme.colorScheme.onSurfaceVariant,
445 )
446 Spacer(Modifier.size(12.dp))
447 Text(
448 message,
449 style = MaterialTheme.typography.bodyMedium,
450 color = MaterialTheme.colorScheme.onSurfaceVariant,
451 )
452 }
455@Composable
456private fun FileRow(
457 icon: Boolean,
458 label: String,
459 onClick: () -> Unit,
460 status: FileStatusEntry? = null,
461 sublabel: String? = null,
462 modifier: Modifier = Modifier,
463) {
464 val fileDesc = if (label == "..") stringResource(R.string.parent_dir)
465 else if (icon) stringResource(R.string.folder, label)
466 else stringResource(R.string.file_label, label)
467 // Build a combined description so TalkBack announces both the file name and its git
468 // status. The parent sets an explicit contentDescription (mergeDescendants), which
469 // would otherwise suppress the StatusBadge's own description.
470 val statusDesc = status?.let {
471 when (it.status) {
472 "added" -> stringResource(R.string.git_added)
473 "modified" -> stringResource(R.string.git_modified)
474 "deleted" -> stringResource(R.string.git_deleted)
475 else -> ""
476 }
477 }.orEmpty()
478 val fullDesc = if (statusDesc.isNotEmpty()) "$fileDesc, $statusDesc" else fileDesc
479 Row(
480 modifier = modifier
481 .fillMaxWidth()
482 .defaultMinSize(minHeight = 48.dp)
483 .clickable(role = Role.Button, onClick = onClick)
484 .semantics(mergeDescendants = true) { contentDescription = fullDesc }
485 .padding(horizontal = 16.dp, vertical = 14.dp),
486 verticalAlignment = Alignment.CenterVertically,
487 ) {
488 Icon(
489 if (icon) Icons.Filled.Folder else Icons.Filled.Description,
490 contentDescription = null,
491 modifier = Modifier.size(20.dp),
492 tint = if (icon) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
493 )
494 Column(modifier = Modifier.weight(1f).padding(start = 8.dp)) {
495 Text(
496 label,
497 style = MaterialTheme.typography.bodyLarge,
498 maxLines = 1,
499 overflow = TextOverflow.Ellipsis,
500 )
501 if (sublabel != null) {
502 Text(
503 sublabel,
504 style = MaterialTheme.typography.labelSmall,
505 color = MaterialTheme.colorScheme.onSurfaceVariant,
506 maxLines = 1,
507 overflow = TextOverflow.Ellipsis,
508 )
509 }
510 }
511 if (status != null) StatusBadge(status)
512 }
515@Composable
516private fun StatusBadge(status: FileStatusEntry) {
517 val (letter, color) = when (status.status) {
518 "added" -> "A" to MaterialTheme.colorScheme.primary
519 "modified" -> "M" to MaterialTheme.colorScheme.tertiary
520 "deleted" -> "D" to MaterialTheme.colorScheme.error
521 else -> "·" to MaterialTheme.colorScheme.onSurfaceVariant
522 }
523 Row(
524 verticalAlignment = Alignment.CenterVertically,
525 ) {
526 Text(
527 letter,
528 style = MaterialTheme.typography.labelMedium,
529 fontFamily = FontFamily.Monospace,
530 color = color,
531 )
532 if (status.added > 0 || status.removed > 0) {
533 Text(
534 "+${status.added} −${status.removed}",
535 style = MaterialTheme.typography.labelSmall,
536 fontFamily = FontFamily.Monospace,
537 color = MaterialTheme.colorScheme.onSurfaceVariant,
538 modifier = Modifier.padding(start = 4.dp),
539 )
540 }
541 }