git.iko.soy/opencode-android
on 0bc1eda help sign in
271 lines 11 kB history raw
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)
59@Composable
60fun FileBrowserScreen(
61 container: AppContainer,
62 onOpenFile: (String) -> Unit,
63 onBack: () -> Unit,
64) {
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()
74 }
76 Scaffold(
77 topBar = {
78 TopAppBar(
79 title = { Text(stringResource(R.string.files), maxLines = 1, overflow = TextOverflow.Ellipsis) },
80 navigationIcon = {
81 IconButton(onClick = onBack) {
82 Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
83 }
84 },
85 )
86 },
87 snackbarHost = { SnackbarHost(snackbar) },
88 ) { padding ->
89 Column(modifier = Modifier.fillMaxSize().padding(padding)) {
90 // Tappable breadcrumb trail so deep paths are navigable.
91 Breadcrumbs(
92 path = state.path,
93 onNavigate = vm::open,
94 modifier = Modifier.fillMaxWidth(),
95 )
96 OutlinedTextField(
97 value = state.query,
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) },
102 singleLine = true,
103 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
104 )
106 Box(modifier = Modifier.fillMaxSize()) {
107 when {
108 state.loading || state.searching -> CircularProgressIndicator(Modifier.align(Alignment.Center))
109 state.error != null && !state.isSearching -> Text(
110 state.error ?: "",
111 modifier = Modifier.align(Alignment.Center).padding(24.dp),
112 color = MaterialTheme.colorScheme.error,
113 )
114 state.isSearching -> SearchResults(state.results, onOpenFile)
115 else -> DirectoryListing(
116 state = state,
117 onOpenDir = vm::open,
118 onUp = vm::up,
119 onOpenFile = onOpenFile,
120 )
121 }
122 }
123 }
124 }
127@Composable
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() }
130 Row(
131 modifier = modifier
132 .horizontalScroll(rememberScrollState())
133 .padding(horizontal = 16.dp, vertical = 8.dp),
134 verticalAlignment = Alignment.CenterVertically,
135 ) {
136 IconButton(onClick = { onNavigate("") }, modifier = Modifier.size(20.dp)) {
137 Icon(Icons.Filled.Home, contentDescription = stringResource(R.string.root), modifier = Modifier.size(18.dp))
138 }
139 var acc = ""
140 segments.forEachIndexed { index, segment ->
141 acc = if (acc.isEmpty()) segment else "$acc/$segment"
142 Icon(
143 Icons.Filled.ChevronRight,
144 contentDescription = null,
145 modifier = Modifier.size(16.dp),
146 tint = MaterialTheme.colorScheme.onSurfaceVariant,
147 )
148 val target = acc
149 Text(
150 segment,
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),
154 )
155 }
156 }
159@Composable
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))
163 return
164 }
165 LazyColumn(modifier = Modifier.fillMaxSize()) {
166 items(results, key = { it }) { path ->
167 FileRow(icon = false, label = path, onClick = { onOpenFile(path) })
168 HorizontalDivider()
169 }
170 }
173@Composable
174private fun DirectoryListing(
175 state: FileBrowserState,
176 onOpenDir: (String) -> Unit,
177 onUp: () -> Unit,
178 onOpenFile: (String) -> Unit,
179) {
180 if (state.entries.isEmpty() && state.path.isBlank()) {
181 Text(
182 stringResource(R.string.empty_folder),
183 modifier = Modifier.fillMaxWidth().padding(24.dp),
184 color = MaterialTheme.colorScheme.onSurfaceVariant,
185 )
186 return
187 }
188 LazyColumn(modifier = Modifier.fillMaxSize()) {
189 if (state.path.isNotBlank()) {
190 item(key = "__up") {
191 FileRow(icon = true, label = "..", onClick = onUp)
192 HorizontalDivider()
193 }
194 }
195 items(state.entries, key = { it.path }) { node ->
196 FileRow(
197 icon = node.isDirectory,
198 label = node.name,
199 onClick = { if (node.isDirectory) onOpenDir(node.path) else onOpenFile(node.path) },
200 status = state.statusMap[node.path],
201 )
202 HorizontalDivider()
203 }
204 }
207@Composable
208private fun FileRow(
209 icon: Boolean,
210 label: String,
211 onClick: () -> Unit,
212 status: FileStatusEntry? = null,
213) {
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)
217 Row(
218 modifier = Modifier
219 .fillMaxWidth()
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,
224 ) {
225 Icon(
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,
230 )
231 Text(
232 " $label",
233 style = MaterialTheme.typography.bodyLarge,
234 maxLines = 1,
235 overflow = TextOverflow.Ellipsis,
236 modifier = Modifier.weight(1f),
237 )
238 if (status != null) StatusBadge(status)
239 }
242@Composable
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, "")
249 }
250 Row(
251 verticalAlignment = Alignment.CenterVertically,
252 modifier = Modifier.semantics(mergeDescendants = true) {
253 if (desc.isNotEmpty()) contentDescription = desc
254 },
255 ) {
256 Text(
257 letter,
258 style = MaterialTheme.typography.labelMedium,
259 fontFamily = FontFamily.Monospace,
260 color = color,
261 )
262 if (status.added > 0 || status.removed > 0) {
263 Text(
264 " +${status.added} −${status.removed}",
265 style = MaterialTheme.typography.labelSmall,
266 fontFamily = FontFamily.Monospace,
267 color = MaterialTheme.colorScheme.onSurfaceVariant,
268 )
269 }
270 }