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.semantics.Role
43import androidx.compose.ui.semantics.contentDescription
44import androidx.compose.ui.semantics.role
45import androidx.compose.ui.semantics.semantics
46import androidx.compose.ui.text.font.FontFamily
47import androidx.compose.ui.text.input.ImeAction
48import androidx.compose.ui.text.style.TextOverflow
49import androidx.compose.ui.unit.dp
50import androidx.compose.ui.res.stringResource
51import androidx.lifecycle.compose.collectAsStateWithLifecycle
52import androidx.lifecycle.viewmodel.compose.viewModel
53import soy.iko.opencode.data.model.FileStatusEntry
54import soy.iko.opencode.di.AppContainer
55import soy.iko.opencode.R
56import soy.iko.opencode.ui.vmFactory
58@OptIn(ExperimentalMaterial3Api::class)
61 container: AppContainer,
62 onOpenFile: (String) -> Unit,
65 val vm: FileBrowserViewModel = viewModel(factory = vmFactory { FileBrowserViewModel(container) })
66 val state by vm.state.collectAsStateWithLifecycle()
67 val transientError by vm.transientError.collectAsStateWithLifecycle()
68 val snackbar = remember { SnackbarHostState() }
70 LaunchedEffect(transientError) {
71 val msg = transientError ?: return@LaunchedEffect
72 snackbar.showSnackbar(msg)
73 vm.clearTransientError()
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),
100 placeholder = { Text(stringResource(R.string.search_files)) },
101 leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
103 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
106 Box(modifier = Modifier.fillMaxSize()) {
108 state.loading || state.searching -> CircularProgressIndicator(Modifier.align(Alignment.Center))
109 state.error != null && !state.isSearching -> Text(
111 modifier = Modifier.align(Alignment.Center).padding(24.dp),
112 color = MaterialTheme.colorScheme.error,
114 state.isSearching -> SearchResults(state.results, onOpenFile)
115 else -> DirectoryListing(
117 onOpenDir = vm::open,
119 onOpenFile = onOpenFile,
128private fun Breadcrumbs(path: String, onNavigate: (String) -> Unit, modifier: Modifier = Modifier) {
129 val segments = if (path.isBlank()) emptyList() else path.trim('/').split('/').filter { it.isNotEmpty() }
132 .horizontalScroll(rememberScrollState())
133 .padding(horizontal = 16.dp, vertical = 8.dp),
134 verticalAlignment = Alignment.CenterVertically,
136 IconButton(onClick = { onNavigate("") }, modifier = Modifier.size(20.dp)) {
137 Icon(Icons.Filled.Home, contentDescription = stringResource(R.string.root), modifier = Modifier.size(18.dp))
140 segments.forEachIndexed { index, segment ->
141 acc = if (acc.isEmpty()) segment else "$acc/$segment"
143 Icons.Filled.ChevronRight,
144 contentDescription = null,
145 modifier = Modifier.size(16.dp),
146 tint = MaterialTheme.colorScheme.onSurfaceVariant,
151 style = MaterialTheme.typography.bodyMedium,
152 color = if (index == segments.lastIndex) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.primary,
153 modifier = Modifier.clickable(role = Role.Button) { onNavigate(target) }.padding(vertical = 2.dp),
160private fun SearchResults(results: List<String>, onOpenFile: (String) -> Unit) {
161 if (results.isEmpty()) {
162 Text(stringResource(R.string.no_matches), modifier = Modifier.padding(24.dp))
165 LazyColumn(modifier = Modifier.fillMaxSize()) {
166 items(results, key = { it }) { path ->
167 FileRow(icon = false, label = path, onClick = { onOpenFile(path) })
174private fun DirectoryListing(
175 state: FileBrowserState,
176 onOpenDir: (String) -> Unit,
178 onOpenFile: (String) -> Unit,
180 if (state.entries.isEmpty() && state.path.isBlank()) {
182 stringResource(R.string.empty_folder),
183 modifier = Modifier.fillMaxWidth().padding(24.dp),
184 color = MaterialTheme.colorScheme.onSurfaceVariant,
188 LazyColumn(modifier = Modifier.fillMaxSize()) {
189 if (state.path.isNotBlank()) {
191 FileRow(icon = true, label = "..", onClick = onUp)
195 items(state.entries, key = { it.path }) { node ->
197 icon = node.isDirectory,
199 onClick = { if (node.isDirectory) onOpenDir(node.path) else onOpenFile(node.path) },
200 status = state.statusMap[node.path],
212 status: FileStatusEntry? = null,
214 val desc = if (label == "..") stringResource(R.string.parent_dir)
215 else if (icon) stringResource(R.string.folder, label)
216 else stringResource(R.string.file_label, label)
220 .clickable(role = Role.Button, onClick = onClick)
221 .semantics(mergeDescendants = true) { contentDescription = desc }
222 .padding(horizontal = 16.dp, vertical = 14.dp),
223 verticalAlignment = Alignment.CenterVertically,
226 if (icon) Icons.Filled.Folder else Icons.Filled.Description,
227 contentDescription = null,
228 modifier = Modifier.size(20.dp),
229 tint = if (icon) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
233 style = MaterialTheme.typography.bodyLarge,
235 overflow = TextOverflow.Ellipsis,
236 modifier = Modifier.weight(1f),
238 if (status != null) StatusBadge(status)
243private fun StatusBadge(status: FileStatusEntry) {
244 val (letter, color, desc) = when (status.status) {
245 "added" -> Triple("A", Color(0xFF4CAF50), stringResource(R.string.git_added))
246 "modified" -> Triple("M", Color(0xFFFFA000), stringResource(R.string.git_modified))
247 "deleted" -> Triple("D", MaterialTheme.colorScheme.error, stringResource(R.string.git_deleted))
248 else -> Triple("·", MaterialTheme.colorScheme.onSurfaceVariant, "")
251 verticalAlignment = Alignment.CenterVertically,
252 modifier = Modifier.semantics(mergeDescendants = true) {
253 if (desc.isNotEmpty()) contentDescription = desc
258 style = MaterialTheme.typography.labelMedium,
259 fontFamily = FontFamily.Monospace,
262 if (status.added > 0 || status.removed > 0) {
264 " +${status.added} −${status.removed}",
265 style = MaterialTheme.typography.labelSmall,
266 fontFamily = FontFamily.Monospace,
267 color = MaterialTheme.colorScheme.onSurfaceVariant,
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