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.material.icons.Icons
16import androidx.compose.material.icons.automirrored.filled.ArrowBack
17import androidx.compose.material.icons.filled.ChevronRight
18import androidx.compose.material.icons.filled.Description
19import androidx.compose.material.icons.filled.Folder
20import androidx.compose.material.icons.filled.Home
21import androidx.compose.material.icons.filled.Search
22import androidx.compose.material3.CircularProgressIndicator
23import androidx.compose.material3.ExperimentalMaterial3Api
24import androidx.compose.material3.HorizontalDivider
25import androidx.compose.material3.Icon
26import androidx.compose.material3.IconButton
27import androidx.compose.material3.MaterialTheme
28import androidx.compose.material3.OutlinedTextField
29import androidx.compose.material3.Scaffold
30import androidx.compose.material3.Text
31import androidx.compose.material3.TopAppBar
32import androidx.compose.runtime.Composable
33import androidx.compose.runtime.getValue
34import androidx.compose.ui.Alignment
35import androidx.compose.ui.Modifier
36import androidx.compose.ui.text.style.TextOverflow
37import androidx.compose.ui.unit.dp
38import androidx.lifecycle.compose.collectAsStateWithLifecycle
39import androidx.lifecycle.viewmodel.compose.viewModel
40import soy.iko.opencode.di.AppContainer
41import soy.iko.opencode.ui.vmFactory
43@OptIn(ExperimentalMaterial3Api::class)
46 container: AppContainer,
47 onOpenFile: (String) -> Unit,
50 val vm: FileBrowserViewModel = viewModel(factory = vmFactory { FileBrowserViewModel(container) })
51 val state by vm.state.collectAsStateWithLifecycle()
56 title = { Text("Files", maxLines = 1, overflow = TextOverflow.Ellipsis) },
58 IconButton(onClick = onBack) {
59 Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
65 Column(modifier = Modifier.fillMaxSize().padding(padding)) {
66 // Tappable breadcrumb trail so deep paths are navigable.
69 onNavigate = vm::open,
70 modifier = Modifier.fillMaxWidth(),
74 onValueChange = vm::setQuery,
75 modifier = Modifier.fillMaxWidth().padding(12.dp),
76 placeholder = { Text("Search files…") },
77 leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
81 Box(modifier = Modifier.fillMaxSize()) {
83 state.loading || state.searching -> CircularProgressIndicator(Modifier.align(Alignment.Center))
84 state.error != null && !state.isSearching -> Text(
86 modifier = Modifier.align(Alignment.Center).padding(24.dp),
87 color = MaterialTheme.colorScheme.error,
89 state.isSearching -> SearchResults(state.results, onOpenFile)
90 else -> DirectoryListing(
94 onOpenFile = onOpenFile,
103private fun Breadcrumbs(path: String, onNavigate: (String) -> Unit, modifier: Modifier = Modifier) {
104 val segments = if (path.isBlank()) emptyList() else path.trim('/').split('/').filter { it.isNotEmpty() }
107 .horizontalScroll(rememberScrollState())
108 .padding(horizontal = 16.dp, vertical = 8.dp),
109 verticalAlignment = Alignment.CenterVertically,
111 IconButton(onClick = { onNavigate("") }, modifier = Modifier.size(20.dp)) {
112 Icon(Icons.Filled.Home, contentDescription = "Root", modifier = Modifier.size(18.dp))
115 segments.forEachIndexed { index, segment ->
116 acc = if (acc.isEmpty()) segment else "$acc/$segment"
118 Icons.Filled.ChevronRight,
119 contentDescription = null,
120 modifier = Modifier.size(16.dp),
121 tint = MaterialTheme.colorScheme.onSurfaceVariant,
126 style = MaterialTheme.typography.bodyMedium,
127 color = if (index == segments.lastIndex) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.primary,
128 modifier = Modifier.clickable { onNavigate(target) }.padding(vertical = 2.dp),
135private fun SearchResults(results: List<String>, onOpenFile: (String) -> Unit) {
136 if (results.isEmpty()) {
137 Text("No matches.", modifier = Modifier.padding(24.dp))
140 LazyColumn(modifier = Modifier.fillMaxSize()) {
141 items(results, key = { it }) { path ->
142 FileRow(icon = false, label = path, onClick = { onOpenFile(path) })
149private fun DirectoryListing(
150 state: FileBrowserState,
151 onOpenDir: (String) -> Unit,
153 onOpenFile: (String) -> Unit,
155 LazyColumn(modifier = Modifier.fillMaxSize()) {
156 if (state.path.isNotBlank()) {
158 FileRow(icon = true, label = "..", onClick = onUp)
162 items(state.entries, key = { it.path }) { node ->
164 icon = node.isDirectory,
166 onClick = { if (node.isDirectory) onOpenDir(node.path) else onOpenFile(node.path) },
174private fun FileRow(icon: Boolean, label: String, onClick: () -> Unit) {
176 modifier = Modifier.fillMaxWidth().clickable(onClick = onClick).padding(horizontal = 16.dp, vertical = 14.dp),
177 verticalAlignment = Alignment.CenterVertically,
180 if (icon) Icons.Filled.Folder else Icons.Filled.Description,
181 contentDescription = null,
182 modifier = Modifier.size(20.dp),
183 tint = if (icon) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
187 style = MaterialTheme.typography.bodyLarge,
189 overflow = TextOverflow.Ellipsis,
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