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.fillMaxSize
9import androidx.compose.foundation.layout.fillMaxWidth
10import androidx.compose.foundation.layout.padding
11import androidx.compose.foundation.layout.size
12import androidx.compose.foundation.lazy.LazyColumn
13import androidx.compose.foundation.lazy.items
14import androidx.compose.foundation.rememberScrollState
15import androidx.compose.foundation.text.KeyboardOptions
16import androidx.compose.material.icons.Icons
17import androidx.compose.material.icons.automirrored.filled.ArrowBack
18import androidx.compose.material.icons.filled.ChevronRight
19import androidx.compose.material.icons.filled.Description
20import androidx.compose.material.icons.filled.Folder
21import androidx.compose.material.icons.filled.Home
22import androidx.compose.material.icons.filled.Search
23import androidx.compose.material3.CircularProgressIndicator
24import androidx.compose.material3.ExperimentalMaterial3Api
25import androidx.compose.material3.HorizontalDivider
26import androidx.compose.material3.Icon
27import androidx.compose.material3.IconButton
28import androidx.compose.material3.MaterialTheme
29import androidx.compose.material3.OutlinedTextField
30import androidx.compose.material3.Scaffold
31import androidx.compose.material3.SnackbarHost
32import androidx.compose.material3.SnackbarHostState
33import androidx.compose.material3.Text
34import androidx.compose.material3.TopAppBar
35import androidx.compose.runtime.Composable
36import androidx.compose.runtime.LaunchedEffect
37import androidx.compose.runtime.getValue
38import androidx.compose.runtime.remember
39import androidx.compose.ui.Alignment
40import androidx.compose.ui.Modifier
41import androidx.compose.ui.graphics.Color
42import androidx.compose.ui.platform.testTag
43import androidx.compose.ui.semantics.Role
44import androidx.compose.ui.semantics.contentDescription
45import androidx.compose.ui.semantics.role
46import androidx.compose.ui.semantics.semantics
47import androidx.compose.ui.text.font.FontFamily
48import androidx.compose.ui.text.input.ImeAction
49import androidx.compose.ui.text.style.TextOverflow
50import androidx.compose.ui.unit.dp
51import androidx.compose.ui.res.stringResource
52import androidx.lifecycle.compose.collectAsStateWithLifecycle
53import androidx.lifecycle.viewmodel.compose.viewModel
54import soy.iko.opencode.data.model.FileStatusEntry
55import soy.iko.opencode.di.AppContainer
56import soy.iko.opencode.R
57import soy.iko.opencode.ui.vmFactory
59@OptIn(ExperimentalMaterial3Api::class)
62 container: AppContainer,
63 onOpenFile: (String) -> Unit,
66 val vm: FileBrowserViewModel = viewModel(factory = vmFactory { FileBrowserViewModel(container) })
67 val state by vm.state.collectAsStateWithLifecycle()
68 val transientError by vm.transientError.collectAsStateWithLifecycle()
69 val snackbar = remember { SnackbarHostState() }
71 LaunchedEffect(transientError) {
72 val msg = transientError ?: return@LaunchedEffect
74 snackbar.showSnackbar(msg)
76 vm.clearTransientError()
83 title = { Text(stringResource(R.string.files), maxLines = 1, overflow = TextOverflow.Ellipsis) },
85 IconButton(onClick = onBack) {
86 Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
91 snackbarHost = { SnackbarHost(snackbar) },
93 Column(modifier = Modifier.fillMaxSize().padding(padding)) {
94 // Tappable breadcrumb trail so deep paths are navigable.
97 onNavigate = vm::open,
98 modifier = Modifier.fillMaxWidth(),
102 onValueChange = vm::setQuery,
103 modifier = Modifier.fillMaxWidth().padding(12.dp).testTag("file_search"),
104 placeholder = { Text(stringResource(R.string.search_files)) },
105 label = { Text(stringResource(R.string.search_files)) },
106 leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
108 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
111 Box(modifier = Modifier.fillMaxSize()) {
113 state.loading || state.searching -> {
114 val loadingLabel = stringResource(R.string.loading)
115 CircularProgressIndicator(
116 Modifier.align(Alignment.Center).semantics { contentDescription = loadingLabel },
119 state.error != null -> Column(
120 modifier = Modifier.align(Alignment.Center).padding(24.dp),
121 horizontalAlignment = Alignment.CenterHorizontally,
125 color = MaterialTheme.colorScheme.error,
127 androidx.compose.foundation.layout.Spacer(Modifier.size(12.dp))
128 androidx.compose.material3.TextButton(onClick = { vm.open(state.path) }) {
129 Text(stringResource(R.string.retry))
132 state.isSearching -> SearchResults(state.results, onOpenFile)
133 else -> DirectoryListing(
135 onOpenDir = vm::open,
137 onOpenFile = onOpenFile,
146private fun Breadcrumbs(path: String, onNavigate: (String) -> Unit, modifier: Modifier = Modifier) {
147 val segments = if (path.isBlank()) emptyList() else path.trim('/').split('/').filter { it.isNotEmpty() }
150 .horizontalScroll(rememberScrollState())
151 .padding(horizontal = 16.dp, vertical = 8.dp),
152 verticalAlignment = Alignment.CenterVertically,
154 IconButton(onClick = { onNavigate("") }) {
155 Icon(Icons.Filled.Home, contentDescription = stringResource(R.string.root), modifier = Modifier.size(24.dp))
158 segments.forEachIndexed { index, segment ->
159 acc = if (acc.isEmpty()) segment else "$acc/$segment"
161 Icons.Filled.ChevronRight,
162 contentDescription = null,
163 modifier = Modifier.size(16.dp),
164 tint = MaterialTheme.colorScheme.onSurfaceVariant,
169 style = MaterialTheme.typography.bodyMedium,
170 color = if (index == segments.lastIndex) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.primary,
172 .clickable(role = Role.Button) { onNavigate(target) }
173 .padding(horizontal = 6.dp, vertical = 14.dp),
180private fun SearchResults(results: List<String>, onOpenFile: (String) -> Unit) {
181 if (results.isEmpty()) {
182 Text(stringResource(R.string.no_matches), modifier = Modifier.padding(24.dp))
185 LazyColumn(modifier = Modifier.fillMaxSize()) {
186 items(results, key = { it }) { path ->
187 FileRow(icon = false, label = path, onClick = { onOpenFile(path) })
194private fun DirectoryListing(
195 state: FileBrowserState,
196 onOpenDir: (String) -> Unit,
198 onOpenFile: (String) -> Unit,
200 if (state.entries.isEmpty() && state.path.isBlank()) {
202 stringResource(R.string.empty_folder),
203 modifier = Modifier.fillMaxWidth().padding(24.dp),
204 color = MaterialTheme.colorScheme.onSurfaceVariant,
208 LazyColumn(modifier = Modifier.fillMaxSize()) {
209 if (state.path.isNotBlank()) {
211 FileRow(icon = true, label = "..", onClick = onUp)
215 items(state.entries, key = { it.path + "_" + it.name }) { node ->
217 icon = node.isDirectory,
219 onClick = { if (node.isDirectory) onOpenDir(node.path) else onOpenFile(node.path) },
220 status = state.statusMap[node.path],
232 status: FileStatusEntry? = null,
234 val desc = if (label == "..") stringResource(R.string.parent_dir)
235 else if (icon) stringResource(R.string.folder, label)
236 else stringResource(R.string.file_label, label)
240 .clickable(role = Role.Button, onClick = onClick)
241 .semantics(mergeDescendants = true) { contentDescription = desc }
242 .padding(horizontal = 16.dp, vertical = 14.dp),
243 verticalAlignment = Alignment.CenterVertically,
246 if (icon) Icons.Filled.Folder else Icons.Filled.Description,
247 contentDescription = null,
248 modifier = Modifier.size(20.dp),
249 tint = if (icon) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
253 style = MaterialTheme.typography.bodyLarge,
255 overflow = TextOverflow.Ellipsis,
256 modifier = Modifier.weight(1f).padding(start = 8.dp),
258 if (status != null) StatusBadge(status)
263private fun StatusBadge(status: FileStatusEntry) {
264 val (letter, color, desc) = when (status.status) {
265 "added" -> Triple("A", MaterialTheme.colorScheme.primary, stringResource(R.string.git_added))
266 "modified" -> Triple("M", MaterialTheme.colorScheme.tertiary, stringResource(R.string.git_modified))
267 "deleted" -> Triple("D", MaterialTheme.colorScheme.error, stringResource(R.string.git_deleted))
268 else -> Triple("·", MaterialTheme.colorScheme.onSurfaceVariant, "")
271 verticalAlignment = Alignment.CenterVertically,
272 modifier = Modifier.semantics(mergeDescendants = true) {
273 if (desc.isNotEmpty()) contentDescription = desc
278 style = MaterialTheme.typography.labelMedium,
279 fontFamily = FontFamily.Monospace,
282 if (status.added > 0 || status.removed > 0) {
284 "+${status.added} −${status.removed}",
285 style = MaterialTheme.typography.labelSmall,
286 fontFamily = FontFamily.Monospace,
287 color = MaterialTheme.colorScheme.onSurfaceVariant,
288 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