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 -> Text(
121 modifier = Modifier.align(Alignment.Center).padding(24.dp),
122 color = MaterialTheme.colorScheme.error,
124 state.isSearching -> SearchResults(state.results, onOpenFile)
125 else -> DirectoryListing(
127 onOpenDir = vm::open,
129 onOpenFile = onOpenFile,
138private fun Breadcrumbs(path: String, onNavigate: (String) -> Unit, modifier: Modifier = Modifier) {
139 val segments = if (path.isBlank()) emptyList() else path.trim('/').split('/').filter { it.isNotEmpty() }
142 .horizontalScroll(rememberScrollState())
143 .padding(horizontal = 16.dp, vertical = 8.dp),
144 verticalAlignment = Alignment.CenterVertically,
146 IconButton(onClick = { onNavigate("") }) {
147 Icon(Icons.Filled.Home, contentDescription = stringResource(R.string.root), modifier = Modifier.size(24.dp))
150 segments.forEachIndexed { index, segment ->
151 acc = if (acc.isEmpty()) segment else "$acc/$segment"
153 Icons.Filled.ChevronRight,
154 contentDescription = null,
155 modifier = Modifier.size(16.dp),
156 tint = MaterialTheme.colorScheme.onSurfaceVariant,
161 style = MaterialTheme.typography.bodyMedium,
162 color = if (index == segments.lastIndex) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.primary,
164 .clickable(role = Role.Button) { onNavigate(target) }
165 .padding(horizontal = 6.dp, vertical = 14.dp),
172private fun SearchResults(results: List<String>, onOpenFile: (String) -> Unit) {
173 if (results.isEmpty()) {
174 Text(stringResource(R.string.no_matches), modifier = Modifier.padding(24.dp))
177 LazyColumn(modifier = Modifier.fillMaxSize()) {
178 items(results, key = { it }) { path ->
179 FileRow(icon = false, label = path, onClick = { onOpenFile(path) })
186private fun DirectoryListing(
187 state: FileBrowserState,
188 onOpenDir: (String) -> Unit,
190 onOpenFile: (String) -> Unit,
192 if (state.entries.isEmpty() && state.path.isBlank()) {
194 stringResource(R.string.empty_folder),
195 modifier = Modifier.fillMaxWidth().padding(24.dp),
196 color = MaterialTheme.colorScheme.onSurfaceVariant,
200 LazyColumn(modifier = Modifier.fillMaxSize()) {
201 if (state.path.isNotBlank()) {
203 FileRow(icon = true, label = "..", onClick = onUp)
207 items(state.entries, key = { it.path + "_" + it.name }) { node ->
209 icon = node.isDirectory,
211 onClick = { if (node.isDirectory) onOpenDir(node.path) else onOpenFile(node.path) },
212 status = state.statusMap[node.path],
224 status: FileStatusEntry? = null,
226 val desc = if (label == "..") stringResource(R.string.parent_dir)
227 else if (icon) stringResource(R.string.folder, label)
228 else stringResource(R.string.file_label, label)
232 .clickable(role = Role.Button, onClick = onClick)
233 .semantics(mergeDescendants = true) { contentDescription = desc }
234 .padding(horizontal = 16.dp, vertical = 14.dp),
235 verticalAlignment = Alignment.CenterVertically,
238 if (icon) Icons.Filled.Folder else Icons.Filled.Description,
239 contentDescription = null,
240 modifier = Modifier.size(20.dp),
241 tint = if (icon) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
245 style = MaterialTheme.typography.bodyLarge,
247 overflow = TextOverflow.Ellipsis,
248 modifier = Modifier.weight(1f).padding(start = 8.dp),
250 if (status != null) StatusBadge(status)
255private fun StatusBadge(status: FileStatusEntry) {
256 val (letter, color, desc) = when (status.status) {
257 "added" -> Triple("A", MaterialTheme.colorScheme.primary, stringResource(R.string.git_added))
258 "modified" -> Triple("M", MaterialTheme.colorScheme.tertiary, stringResource(R.string.git_modified))
259 "deleted" -> Triple("D", MaterialTheme.colorScheme.error, stringResource(R.string.git_deleted))
260 else -> Triple("·", MaterialTheme.colorScheme.onSurfaceVariant, "")
263 verticalAlignment = Alignment.CenterVertically,
264 modifier = Modifier.semantics(mergeDescendants = true) {
265 if (desc.isNotEmpty()) contentDescription = desc
270 style = MaterialTheme.typography.labelMedium,
271 fontFamily = FontFamily.Monospace,
274 if (status.added > 0 || status.removed > 0) {
276 "+${status.added} −${status.removed}",
277 style = MaterialTheme.typography.labelSmall,
278 fontFamily = FontFamily.Monospace,
279 color = MaterialTheme.colorScheme.onSurfaceVariant,
280 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