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 snackbar = remember { SnackbarHostState() }
70 LaunchedEffect(Unit) {
71 vm.transientErrors.collect { msg ->
72 snackbar.showSnackbar(msg)
79 title = { Text(stringResource(R.string.files), maxLines = 1, overflow = TextOverflow.Ellipsis) },
81 IconButton(onClick = onBack) {
82 Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
87 snackbarHost = { SnackbarHost(snackbar) },
89 Column(modifier = Modifier.fillMaxSize().padding(padding)) {
90 // Tappable breadcrumb trail so deep paths are navigable.
93 onNavigate = vm::open,
94 modifier = Modifier.fillMaxWidth(),
98 onValueChange = vm::setQuery,
99 modifier = Modifier.fillMaxWidth().padding(12.dp).testTag("file_search"),
100 placeholder = { Text(stringResource(R.string.search_files)) },
101 label = { Text(stringResource(R.string.search_files)) },
102 leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
104 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
107 Box(modifier = Modifier.fillMaxSize()) {
109 state.loading || state.searching -> {
110 val loadingLabel = stringResource(R.string.loading)
111 CircularProgressIndicator(
112 Modifier.align(Alignment.Center).semantics { contentDescription = loadingLabel },
115 state.error != null -> Column(
116 modifier = Modifier.align(Alignment.Center).padding(24.dp),
117 horizontalAlignment = Alignment.CenterHorizontally,
121 color = MaterialTheme.colorScheme.error,
123 androidx.compose.foundation.layout.Spacer(Modifier.size(12.dp))
124 androidx.compose.material3.TextButton(onClick = { vm.open(state.path) }) {
125 Text(stringResource(R.string.retry))
128 state.isSearching -> SearchResults(state.results, onOpenFile)
129 else -> DirectoryListing(
131 onOpenDir = vm::open,
133 onOpenFile = onOpenFile,
142private fun Breadcrumbs(path: String, onNavigate: (String) -> Unit, modifier: Modifier = Modifier) {
143 val segments = if (path.isBlank()) emptyList() else path.trim('/').split('/').filter { it.isNotEmpty() }
146 .horizontalScroll(rememberScrollState())
147 .padding(horizontal = 16.dp, vertical = 8.dp),
148 verticalAlignment = Alignment.CenterVertically,
150 IconButton(onClick = { onNavigate("") }) {
151 Icon(Icons.Filled.Home, contentDescription = stringResource(R.string.root), modifier = Modifier.size(24.dp))
154 segments.forEachIndexed { index, segment ->
155 acc = if (acc.isEmpty()) segment else "$acc/$segment"
157 Icons.Filled.ChevronRight,
158 contentDescription = null,
159 modifier = Modifier.size(16.dp),
160 tint = MaterialTheme.colorScheme.onSurfaceVariant,
165 style = MaterialTheme.typography.bodyMedium,
166 color = if (index == segments.lastIndex) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.primary,
168 .clickable(role = Role.Button) { onNavigate(target) }
169 .padding(horizontal = 6.dp, vertical = 14.dp),
176private fun SearchResults(results: List<String>, onOpenFile: (String) -> Unit) {
177 if (results.isEmpty()) {
178 Text(stringResource(R.string.no_matches), modifier = Modifier.padding(24.dp))
181 LazyColumn(modifier = Modifier.fillMaxSize()) {
182 items(results, key = { it }) { path ->
183 FileRow(icon = false, label = path, onClick = { onOpenFile(path) })
190private fun DirectoryListing(
191 state: FileBrowserState,
192 onOpenDir: (String) -> Unit,
194 onOpenFile: (String) -> Unit,
196 if (state.entries.isEmpty() && state.path.isBlank()) {
198 stringResource(R.string.empty_folder),
199 modifier = Modifier.fillMaxWidth().padding(24.dp),
200 color = MaterialTheme.colorScheme.onSurfaceVariant,
204 LazyColumn(modifier = Modifier.fillMaxSize()) {
205 if (state.path.isNotBlank()) {
207 FileRow(icon = true, label = "..", onClick = onUp)
211 items(state.entries, key = { it.path + "_" + it.name }) { node ->
213 icon = node.isDirectory,
215 onClick = { if (node.isDirectory) onOpenDir(node.path) else onOpenFile(node.path) },
216 status = state.statusMap[node.path],
228 status: FileStatusEntry? = null,
230 val desc = if (label == "..") stringResource(R.string.parent_dir)
231 else if (icon) stringResource(R.string.folder, label)
232 else stringResource(R.string.file_label, label)
236 .clickable(role = Role.Button, onClick = onClick)
237 .semantics(mergeDescendants = true) { contentDescription = desc }
238 .padding(horizontal = 16.dp, vertical = 14.dp),
239 verticalAlignment = Alignment.CenterVertically,
242 if (icon) Icons.Filled.Folder else Icons.Filled.Description,
243 contentDescription = null,
244 modifier = Modifier.size(20.dp),
245 tint = if (icon) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
249 style = MaterialTheme.typography.bodyLarge,
251 overflow = TextOverflow.Ellipsis,
252 modifier = Modifier.weight(1f).padding(start = 8.dp),
254 if (status != null) StatusBadge(status)
259private fun StatusBadge(status: FileStatusEntry) {
260 val (letter, color, desc) = when (status.status) {
261 "added" -> Triple("A", MaterialTheme.colorScheme.primary, stringResource(R.string.git_added))
262 "modified" -> Triple("M", MaterialTheme.colorScheme.tertiary, stringResource(R.string.git_modified))
263 "deleted" -> Triple("D", MaterialTheme.colorScheme.error, stringResource(R.string.git_deleted))
264 else -> Triple("·", MaterialTheme.colorScheme.onSurfaceVariant, "")
267 verticalAlignment = Alignment.CenterVertically,
268 modifier = Modifier.semantics(mergeDescendants = true) {
269 if (desc.isNotEmpty()) contentDescription = desc
274 style = MaterialTheme.typography.labelMedium,
275 fontFamily = FontFamily.Monospace,
278 if (status.added > 0 || status.removed > 0) {
280 "+${status.added} −${status.removed}",
281 style = MaterialTheme.typography.labelSmall,
282 fontFamily = FontFamily.Monospace,
283 color = MaterialTheme.colorScheme.onSurfaceVariant,
284 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