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 // Retry the operation that actually failed: re-run the search
136 // when one was active (open() would reset the query and drop the
137 // user back into the directory listing), otherwise reload the dir.
138 androidx.compose.material3.TextButton(
139 onClick = { if (state.isSearching) vm.setQuery(state.query) else vm.open(state.path) },
141 Text(stringResource(R.string.retry))
144 state.isSearching -> SearchResults(state.results, onOpenFile)
145 else -> DirectoryListing(
147 onOpenDir = vm::open,
149 onOpenFile = onOpenFile,
159private fun Breadcrumbs(path: String, onNavigate: (String) -> Unit, modifier: Modifier = Modifier) {
160 val segments = if (path.isBlank()) emptyList() else path.trim('/').split('/').filter { it.isNotEmpty() }
163 .horizontalScroll(rememberScrollState())
164 .padding(horizontal = 16.dp, vertical = 8.dp),
165 verticalAlignment = Alignment.CenterVertically,
167 IconButton(onClick = { onNavigate("") }) {
168 Icon(Icons.Filled.Home, contentDescription = stringResource(R.string.root), modifier = Modifier.size(24.dp))
171 segments.forEachIndexed { index, segment ->
172 acc = if (acc.isEmpty()) segment else "$acc/$segment"
174 Icons.Filled.ChevronRight,
175 contentDescription = null,
176 modifier = Modifier.size(16.dp),
177 tint = MaterialTheme.colorScheme.onSurfaceVariant,
182 style = MaterialTheme.typography.bodyMedium,
183 color = if (index == segments.lastIndex) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.primary,
185 .clickable(role = Role.Button) { onNavigate(target) }
186 .defaultMinSize(minHeight = 48.dp)
187 .padding(horizontal = 6.dp, vertical = 14.dp),
194private fun SearchResults(results: List<String>, onOpenFile: (String) -> Unit) {
195 if (results.isEmpty()) {
197 icon = Icons.Filled.Search,
198 message = stringResource(R.string.no_matches),
199 modifier = Modifier.fillMaxWidth().padding(24.dp),
203 LazyColumn(modifier = Modifier.fillMaxSize()) {
204 items(results, key = { it }) { path ->
205 // Split the path into directory (muted) + filename (emphasized) so results
206 // are scannable instead of a wall of identical-looking full paths.
207 val dir = path.substringBeforeLast('/', missingDelimiterValue = "").trimEnd('/')
208 val name = path.substringAfterLast('/')
212 sublabel = dir.takeIf { it.isNotEmpty() },
213 onClick = { onOpenFile(path) },
214 modifier = Modifier.animateItem(),
222private fun DirectoryListing(
223 state: FileBrowserState,
224 onOpenDir: (String) -> Unit,
226 onOpenFile: (String) -> Unit,
228 if (state.entries.isEmpty()) {
230 icon = Icons.Filled.Folder,
231 message = stringResource(R.string.empty_folder),
232 modifier = Modifier.fillMaxWidth().padding(24.dp),
236 LazyColumn(modifier = Modifier.fillMaxSize()) {
237 if (state.path.isNotBlank()) {
239 FileRow(icon = true, label = "..", onClick = onUp)
243 items(state.entries, key = { it.path + "_" + it.name }) { node ->
245 icon = node.isDirectory,
247 onClick = { if (node.isDirectory) onOpenDir(node.path) else onOpenFile(node.path) },
248 status = state.statusMap[node.path],
249 modifier = Modifier.animateItem(),
256/** Shared empty-state for the file browser: icon + message, matching the icon+text
257 * pattern used by the session and server lists (the prior versions were bare Text
258 * lines that read as status messages rather than intentional empty states). */
260private fun EmptyFileState(icon: androidx.compose.ui.graphics.vector.ImageVector, message: String, modifier: Modifier = Modifier) {
263 horizontalAlignment = Alignment.CenterHorizontally,
267 contentDescription = null,
268 modifier = Modifier.size(48.dp),
269 tint = MaterialTheme.colorScheme.onSurfaceVariant,
271 Spacer(Modifier.size(12.dp))
274 style = MaterialTheme.typography.bodyMedium,
275 color = MaterialTheme.colorScheme.onSurfaceVariant,
285 status: FileStatusEntry? = null,
286 sublabel: String? = null,
287 modifier: Modifier = Modifier,
289 val fileDesc = if (label == "..") stringResource(R.string.parent_dir)
290 else if (icon) stringResource(R.string.folder, label)
291 else stringResource(R.string.file_label, label)
292 // Build a combined description so TalkBack announces both the file name and its git
293 // status. The parent sets an explicit contentDescription (mergeDescendants), which
294 // would otherwise suppress the StatusBadge's own description.
295 val statusDesc = status?.let {
297 "added" -> stringResource(R.string.git_added)
298 "modified" -> stringResource(R.string.git_modified)
299 "deleted" -> stringResource(R.string.git_deleted)
303 val fullDesc = if (statusDesc.isNotEmpty()) "$fileDesc, $statusDesc" else fileDesc
307 .defaultMinSize(minHeight = 48.dp)
308 .clickable(role = Role.Button, onClick = onClick)
309 .semantics(mergeDescendants = true) { contentDescription = fullDesc }
310 .padding(horizontal = 16.dp, vertical = 14.dp),
311 verticalAlignment = Alignment.CenterVertically,
314 if (icon) Icons.Filled.Folder else Icons.Filled.Description,
315 contentDescription = null,
316 modifier = Modifier.size(20.dp),
317 tint = if (icon) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
319 Column(modifier = Modifier.weight(1f).padding(start = 8.dp)) {
322 style = MaterialTheme.typography.bodyLarge,
324 overflow = TextOverflow.Ellipsis,
326 if (sublabel != null) {
329 style = MaterialTheme.typography.labelSmall,
330 color = MaterialTheme.colorScheme.onSurfaceVariant,
332 overflow = TextOverflow.Ellipsis,
336 if (status != null) StatusBadge(status)
341private fun StatusBadge(status: FileStatusEntry) {
342 val (letter, color) = when (status.status) {
343 "added" -> "A" to MaterialTheme.colorScheme.primary
344 "modified" -> "M" to MaterialTheme.colorScheme.tertiary
345 "deleted" -> "D" to MaterialTheme.colorScheme.error
346 else -> "·" to MaterialTheme.colorScheme.onSurfaceVariant
349 verticalAlignment = Alignment.CenterVertically,
353 style = MaterialTheme.typography.labelMedium,
354 fontFamily = FontFamily.Monospace,
357 if (status.added > 0 || status.removed > 0) {
359 "+${status.added} −${status.removed}",
360 style = MaterialTheme.typography.labelSmall,
361 fontFamily = FontFamily.Monospace,
362 color = MaterialTheme.colorScheme.onSurfaceVariant,
363 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