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("") }) {
137 Icon(Icons.Filled.Home, contentDescription = stringResource(R.string.root), modifier = Modifier.size(24.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,
154 .clickable(role = Role.Button) { onNavigate(target) }
155 .padding(horizontal = 6.dp, vertical = 14.dp),
162private fun SearchResults(results: List<String>, onOpenFile: (String) -> Unit) {
163 if (results.isEmpty()) {
164 Text(stringResource(R.string.no_matches), modifier = Modifier.padding(24.dp))
167 LazyColumn(modifier = Modifier.fillMaxSize()) {
168 items(results, key = { it }) { path ->
169 FileRow(icon = false, label = path, onClick = { onOpenFile(path) })
176private fun DirectoryListing(
177 state: FileBrowserState,
178 onOpenDir: (String) -> Unit,
180 onOpenFile: (String) -> Unit,
182 if (state.entries.isEmpty() && state.path.isBlank()) {
184 stringResource(R.string.empty_folder),
185 modifier = Modifier.fillMaxWidth().padding(24.dp),
186 color = MaterialTheme.colorScheme.onSurfaceVariant,
190 LazyColumn(modifier = Modifier.fillMaxSize()) {
191 if (state.path.isNotBlank()) {
193 FileRow(icon = true, label = "..", onClick = onUp)
197 items(state.entries, key = { it.path }) { node ->
199 icon = node.isDirectory,
201 onClick = { if (node.isDirectory) onOpenDir(node.path) else onOpenFile(node.path) },
202 status = state.statusMap[node.path],
214 status: FileStatusEntry? = null,
216 val desc = if (label == "..") stringResource(R.string.parent_dir)
217 else if (icon) stringResource(R.string.folder, label)
218 else stringResource(R.string.file_label, label)
222 .clickable(role = Role.Button, onClick = onClick)
223 .semantics(mergeDescendants = true) { contentDescription = desc }
224 .padding(horizontal = 16.dp, vertical = 14.dp),
225 verticalAlignment = Alignment.CenterVertically,
228 if (icon) Icons.Filled.Folder else Icons.Filled.Description,
229 contentDescription = null,
230 modifier = Modifier.size(20.dp),
231 tint = if (icon) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
235 style = MaterialTheme.typography.bodyLarge,
237 overflow = TextOverflow.Ellipsis,
238 modifier = Modifier.weight(1f).padding(start = 8.dp),
240 if (status != null) StatusBadge(status)
245private fun StatusBadge(status: FileStatusEntry) {
246 val (letter, color, desc) = when (status.status) {
247 "added" -> Triple("A", Color(0xFF4CAF50), stringResource(R.string.git_added))
248 "modified" -> Triple("M", Color(0xFFFFA000), stringResource(R.string.git_modified))
249 "deleted" -> Triple("D", MaterialTheme.colorScheme.error, stringResource(R.string.git_deleted))
250 else -> Triple("·", MaterialTheme.colorScheme.onSurfaceVariant, "")
253 verticalAlignment = Alignment.CenterVertically,
254 modifier = Modifier.semantics(mergeDescendants = true) {
255 if (desc.isNotEmpty()) contentDescription = desc
260 style = MaterialTheme.typography.labelMedium,
261 fontFamily = FontFamily.Monospace,
264 if (status.added > 0 || status.removed > 0) {
266 "+${status.added} −${status.removed}",
267 style = MaterialTheme.typography.labelSmall,
268 fontFamily = FontFamily.Monospace,
269 color = MaterialTheme.colorScheme.onSurfaceVariant,
270 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