1package soy.iko.opencode.ui.file
3import androidx.compose.foundation.clickable
4import androidx.compose.foundation.horizontalScroll
5import androidx.compose.foundation.layout.Box
6import androidx.compose.foundation.layout.Column
7import androidx.compose.foundation.layout.Row
8import androidx.compose.foundation.layout.Spacer
9import androidx.compose.foundation.layout.defaultMinSize
10import androidx.compose.foundation.layout.fillMaxSize
11import androidx.compose.foundation.layout.fillMaxWidth
12import androidx.compose.foundation.layout.padding
13import androidx.compose.foundation.layout.size
14import androidx.compose.foundation.lazy.LazyColumn
15import androidx.compose.foundation.lazy.items
16import androidx.compose.foundation.rememberScrollState
17import androidx.compose.foundation.text.KeyboardActions
18import androidx.compose.foundation.text.KeyboardOptions
19import androidx.compose.material.icons.Icons
20import androidx.compose.material.icons.automirrored.filled.ArrowBack
21import androidx.compose.material.icons.filled.ChevronRight
22import androidx.compose.material.icons.filled.Description
23import androidx.compose.material.icons.filled.Folder
24import androidx.compose.material.icons.filled.Home
25import androidx.compose.material.icons.filled.Search
26import androidx.compose.material3.CircularProgressIndicator
27import androidx.compose.material3.ExperimentalMaterial3Api
28import androidx.compose.material3.HorizontalDivider
29import androidx.compose.material3.Icon
30import androidx.compose.material3.IconButton
31import androidx.compose.material3.MaterialTheme
32import androidx.compose.material3.OutlinedTextField
33import androidx.compose.material3.Scaffold
34import androidx.compose.material3.SnackbarHost
35import androidx.compose.material3.SnackbarHostState
36import androidx.compose.material3.Text
37import androidx.compose.material3.TopAppBar
38import androidx.compose.runtime.Composable
39import androidx.compose.runtime.LaunchedEffect
40import androidx.compose.runtime.getValue
41import androidx.compose.runtime.remember
42import androidx.compose.ui.Alignment
43import androidx.compose.ui.Modifier
44import androidx.compose.ui.graphics.Color
45import androidx.compose.ui.platform.LocalSoftwareKeyboardController
46import androidx.compose.ui.platform.testTag
47import androidx.compose.ui.semantics.Role
48import androidx.compose.ui.semantics.contentDescription
49import androidx.compose.ui.semantics.role
50import androidx.compose.ui.semantics.semantics
51import androidx.compose.ui.text.font.FontFamily
52import androidx.compose.ui.text.input.ImeAction
53import androidx.compose.ui.text.style.TextOverflow
54import androidx.compose.ui.unit.dp
55import androidx.compose.ui.res.stringResource
56import androidx.lifecycle.compose.collectAsStateWithLifecycle
57import androidx.lifecycle.viewmodel.compose.viewModel
58import soy.iko.opencode.data.model.FileStatusEntry
59import soy.iko.opencode.di.AppContainer
60import soy.iko.opencode.R
61import soy.iko.opencode.ui.vmFactory
63@OptIn(ExperimentalMaterial3Api::class)
66 container: AppContainer,
67 onOpenFile: (String) -> Unit,
70 val vm: FileBrowserViewModel = viewModel(factory = vmFactory { FileBrowserViewModel(container) })
71 val state by vm.state.collectAsStateWithLifecycle()
72 val refreshing by vm.refreshing.collectAsStateWithLifecycle()
73 val snackbar = remember { SnackbarHostState() }
75 LaunchedEffect(Unit) {
76 vm.transientErrors.collect { msg ->
77 snackbar.showSnackbar(msg)
84 title = { Text(stringResource(R.string.files), maxLines = 1, overflow = TextOverflow.Ellipsis) },
86 IconButton(onClick = onBack) {
87 Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
92 snackbarHost = { SnackbarHost(snackbar) },
94 Column(modifier = Modifier.fillMaxSize().padding(padding)) {
95 // Tappable breadcrumb trail so deep paths are navigable.
98 onNavigate = vm::open,
99 modifier = Modifier.fillMaxWidth(),
101 val keyboardController = LocalSoftwareKeyboardController.current
104 onValueChange = vm::setQuery,
105 modifier = Modifier.fillMaxWidth().padding(12.dp).testTag("file_search"),
106 label = { Text(stringResource(R.string.search_files)) },
107 leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
109 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
110 keyboardActions = KeyboardActions(onSearch = { keyboardController?.hide() }),
113 androidx.compose.material3.pulltorefresh.PullToRefreshBox(
114 isRefreshing = refreshing,
115 onRefresh = { vm.refresh() },
116 modifier = Modifier.fillMaxSize(),
118 Box(modifier = Modifier.fillMaxSize()) {
120 state.loading || state.searching -> {
121 val loadingLabel = stringResource(R.string.loading)
122 CircularProgressIndicator(
123 Modifier.align(Alignment.Center).semantics { contentDescription = loadingLabel },
126 state.error != null -> Column(
127 modifier = Modifier.align(Alignment.Center).padding(24.dp),
128 horizontalAlignment = Alignment.CenterHorizontally,
132 color = MaterialTheme.colorScheme.error,
134 androidx.compose.foundation.layout.Spacer(Modifier.size(12.dp))
135 androidx.compose.material3.TextButton(onClick = { vm.open(state.path) }) {
136 Text(stringResource(R.string.retry))
139 state.isSearching -> SearchResults(state.results, onOpenFile)
140 else -> DirectoryListing(
142 onOpenDir = vm::open,
144 onOpenFile = onOpenFile,
154private fun Breadcrumbs(path: String, onNavigate: (String) -> Unit, modifier: Modifier = Modifier) {
155 val segments = if (path.isBlank()) emptyList() else path.trim('/').split('/').filter { it.isNotEmpty() }
158 .horizontalScroll(rememberScrollState())
159 .padding(horizontal = 16.dp, vertical = 8.dp),
160 verticalAlignment = Alignment.CenterVertically,
162 IconButton(onClick = { onNavigate("") }) {
163 Icon(Icons.Filled.Home, contentDescription = stringResource(R.string.root), modifier = Modifier.size(24.dp))
166 segments.forEachIndexed { index, segment ->
167 acc = if (acc.isEmpty()) segment else "$acc/$segment"
169 Icons.Filled.ChevronRight,
170 contentDescription = null,
171 modifier = Modifier.size(16.dp),
172 tint = MaterialTheme.colorScheme.onSurfaceVariant,
177 style = MaterialTheme.typography.bodyMedium,
178 color = if (index == segments.lastIndex) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.primary,
180 .clickable(role = Role.Button) { onNavigate(target) }
181 .defaultMinSize(minHeight = 48.dp)
182 .padding(horizontal = 6.dp, vertical = 14.dp),
189private fun SearchResults(results: List<String>, onOpenFile: (String) -> Unit) {
190 if (results.isEmpty()) {
192 icon = Icons.Filled.Search,
193 message = stringResource(R.string.no_matches),
194 modifier = Modifier.fillMaxWidth().padding(24.dp),
198 LazyColumn(modifier = Modifier.fillMaxSize()) {
199 items(results, key = { it }) { path ->
200 // Split the path into directory (muted) + filename (emphasized) so results
201 // are scannable instead of a wall of identical-looking full paths.
202 val dir = path.substringBeforeLast('/', missingDelimiterValue = "").trimEnd('/')
203 val name = path.substringAfterLast('/')
207 sublabel = dir.takeIf { it.isNotEmpty() },
208 onClick = { onOpenFile(path) },
209 modifier = Modifier.animateItem(),
217private fun DirectoryListing(
218 state: FileBrowserState,
219 onOpenDir: (String) -> Unit,
221 onOpenFile: (String) -> Unit,
223 if (state.entries.isEmpty()) {
225 icon = Icons.Filled.Folder,
226 message = stringResource(R.string.empty_folder),
227 modifier = Modifier.fillMaxWidth().padding(24.dp),
231 LazyColumn(modifier = Modifier.fillMaxSize()) {
232 if (state.path.isNotBlank()) {
234 FileRow(icon = true, label = "..", onClick = onUp)
238 items(state.entries, key = { it.path + "_" + it.name }) { node ->
240 icon = node.isDirectory,
242 onClick = { if (node.isDirectory) onOpenDir(node.path) else onOpenFile(node.path) },
243 status = state.statusMap[node.path],
244 modifier = Modifier.animateItem(),
251/** Shared empty-state for the file browser: icon + message, matching the icon+text
252 * pattern used by the session and server lists (the prior versions were bare Text
253 * lines that read as status messages rather than intentional empty states). */
255private fun EmptyFileState(icon: androidx.compose.ui.graphics.vector.ImageVector, message: String, modifier: Modifier = Modifier) {
258 horizontalAlignment = Alignment.CenterHorizontally,
262 contentDescription = null,
263 modifier = Modifier.size(48.dp),
264 tint = MaterialTheme.colorScheme.onSurfaceVariant,
266 Spacer(Modifier.size(12.dp))
269 style = MaterialTheme.typography.bodyMedium,
270 color = MaterialTheme.colorScheme.onSurfaceVariant,
280 status: FileStatusEntry? = null,
281 sublabel: String? = null,
282 modifier: Modifier = Modifier,
284 val fileDesc = if (label == "..") stringResource(R.string.parent_dir)
285 else if (icon) stringResource(R.string.folder, label)
286 else stringResource(R.string.file_label, label)
287 // Build a combined description so TalkBack announces both the file name and its git
288 // status. The parent sets an explicit contentDescription (mergeDescendants), which
289 // would otherwise suppress the StatusBadge's own description.
290 val statusDesc = status?.let {
292 "added" -> stringResource(R.string.git_added)
293 "modified" -> stringResource(R.string.git_modified)
294 "deleted" -> stringResource(R.string.git_deleted)
298 val fullDesc = if (statusDesc.isNotEmpty()) "$fileDesc, $statusDesc" else fileDesc
302 .defaultMinSize(minHeight = 48.dp)
303 .clickable(role = Role.Button, onClick = onClick)
304 .semantics(mergeDescendants = true) { contentDescription = fullDesc }
305 .padding(horizontal = 16.dp, vertical = 14.dp),
306 verticalAlignment = Alignment.CenterVertically,
309 if (icon) Icons.Filled.Folder else Icons.Filled.Description,
310 contentDescription = null,
311 modifier = Modifier.size(20.dp),
312 tint = if (icon) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
314 Column(modifier = Modifier.weight(1f).padding(start = 8.dp)) {
317 style = MaterialTheme.typography.bodyLarge,
319 overflow = TextOverflow.Ellipsis,
321 if (sublabel != null) {
324 style = MaterialTheme.typography.labelSmall,
325 color = MaterialTheme.colorScheme.onSurfaceVariant,
327 overflow = TextOverflow.Ellipsis,
331 if (status != null) StatusBadge(status)
336private fun StatusBadge(status: FileStatusEntry) {
337 val (letter, color) = when (status.status) {
338 "added" -> "A" to MaterialTheme.colorScheme.primary
339 "modified" -> "M" to MaterialTheme.colorScheme.tertiary
340 "deleted" -> "D" to MaterialTheme.colorScheme.error
341 else -> "·" to MaterialTheme.colorScheme.onSurfaceVariant
344 verticalAlignment = Alignment.CenterVertically,
348 style = MaterialTheme.typography.labelMedium,
349 fontFamily = FontFamily.Monospace,
352 if (status.added > 0 || status.removed > 0) {
354 "+${status.added} −${status.removed}",
355 style = MaterialTheme.typography.labelSmall,
356 fontFamily = FontFamily.Monospace,
357 color = MaterialTheme.colorScheme.onSurfaceVariant,
358 modifier = Modifier.padding(start = 4.dp),
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