git.iko.soy/opencode-android
on 8bc2cce help sign in
367 lines 15 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.Spacer
9import androidx.compose.foundation.layout.defaultMinSize
10import androidx.compose.foundation.layout.fillMaxSize
11import androidx.compose.foundation.layout.fillMaxWidth
12import androidx.compose.foundation.layout.padding
13import androidx.compose.foundation.layout.size
14import androidx.compose.foundation.lazy.LazyColumn
15import androidx.compose.foundation.lazy.items
16import androidx.compose.foundation.rememberScrollState
17import androidx.compose.foundation.text.KeyboardActions
18import androidx.compose.foundation.text.KeyboardOptions
19import androidx.compose.material.icons.Icons
20import androidx.compose.material.icons.automirrored.filled.ArrowBack
21import androidx.compose.material.icons.filled.ChevronRight
22import androidx.compose.material.icons.filled.Description
23import androidx.compose.material.icons.filled.Folder
24import androidx.compose.material.icons.filled.Home
25import androidx.compose.material.icons.filled.Search
26import androidx.compose.material3.CircularProgressIndicator
27import androidx.compose.material3.ExperimentalMaterial3Api
28import androidx.compose.material3.HorizontalDivider
29import androidx.compose.material3.Icon
30import androidx.compose.material3.IconButton
31import androidx.compose.material3.MaterialTheme
32import androidx.compose.material3.OutlinedTextField
33import androidx.compose.material3.Scaffold
34import androidx.compose.material3.SnackbarHost
35import androidx.compose.material3.SnackbarHostState
36import androidx.compose.material3.Text
37import androidx.compose.material3.TopAppBar
38import androidx.compose.runtime.Composable
39import androidx.compose.runtime.LaunchedEffect
40import androidx.compose.runtime.getValue
41import androidx.compose.runtime.remember
42import androidx.compose.ui.Alignment
43import androidx.compose.ui.Modifier
44import androidx.compose.ui.graphics.Color
45import androidx.compose.ui.platform.LocalSoftwareKeyboardController
46import androidx.compose.ui.platform.testTag
47import androidx.compose.ui.semantics.Role
48import androidx.compose.ui.semantics.contentDescription
49import androidx.compose.ui.semantics.role
50import androidx.compose.ui.semantics.semantics
51import androidx.compose.ui.text.font.FontFamily
52import androidx.compose.ui.text.input.ImeAction
53import androidx.compose.ui.text.style.TextOverflow
54import androidx.compose.ui.unit.dp
55import androidx.compose.ui.res.stringResource
56import androidx.lifecycle.compose.collectAsStateWithLifecycle
57import androidx.lifecycle.viewmodel.compose.viewModel
58import soy.iko.opencode.data.model.FileStatusEntry
59import soy.iko.opencode.di.AppContainer
60import soy.iko.opencode.R
61import soy.iko.opencode.ui.vmFactory
63@OptIn(ExperimentalMaterial3Api::class)
64@Composable
65fun FileBrowserScreen(
66 container: AppContainer,
67 onOpenFile: (String) -> Unit,
68 onBack: () -> Unit,
69) {
70 val vm: FileBrowserViewModel = viewModel(factory = vmFactory { FileBrowserViewModel(container) })
71 val state by vm.state.collectAsStateWithLifecycle()
72 val refreshing by vm.refreshing.collectAsStateWithLifecycle()
73 val snackbar = remember { SnackbarHostState() }
75 LaunchedEffect(Unit) {
76 vm.transientErrors.collect { msg ->
77 snackbar.showSnackbar(msg)
78 }
79 }
81 Scaffold(
82 topBar = {
83 TopAppBar(
84 title = { Text(stringResource(R.string.files), maxLines = 1, overflow = TextOverflow.Ellipsis) },
85 navigationIcon = {
86 IconButton(onClick = onBack) {
87 Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
88 }
89 },
90 )
91 },
92 snackbarHost = { SnackbarHost(snackbar) },
93 ) { padding ->
94 Column(modifier = Modifier.fillMaxSize().padding(padding)) {
95 // Tappable breadcrumb trail so deep paths are navigable.
96 Breadcrumbs(
97 path = state.path,
98 onNavigate = vm::open,
99 modifier = Modifier.fillMaxWidth(),
100 )
101 val keyboardController = LocalSoftwareKeyboardController.current
102 OutlinedTextField(
103 value = state.query,
104 onValueChange = vm::setQuery,
105 modifier = Modifier.fillMaxWidth().padding(12.dp).testTag("file_search"),
106 label = { Text(stringResource(R.string.search_files)) },
107 leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
108 singleLine = true,
109 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
110 keyboardActions = KeyboardActions(onSearch = { keyboardController?.hide() }),
111 )
113 androidx.compose.material3.pulltorefresh.PullToRefreshBox(
114 isRefreshing = refreshing,
115 onRefresh = { vm.refresh() },
116 modifier = Modifier.fillMaxSize(),
117 ) {
118 Box(modifier = Modifier.fillMaxSize()) {
119 when {
120 state.loading || state.searching -> {
121 val loadingLabel = stringResource(R.string.loading)
122 CircularProgressIndicator(
123 Modifier.align(Alignment.Center).semantics { contentDescription = loadingLabel },
124 )
125 }
126 state.error != null -> Column(
127 modifier = Modifier.align(Alignment.Center).padding(24.dp),
128 horizontalAlignment = Alignment.CenterHorizontally,
129 ) {
130 Text(
131 state.error ?: "",
132 color = MaterialTheme.colorScheme.error,
133 )
134 androidx.compose.foundation.layout.Spacer(Modifier.size(12.dp))
135 // Retry the operation that actually failed: re-run the search
136 // when one was active (open() would reset the query and drop the
137 // user back into the directory listing), otherwise reload the dir.
138 androidx.compose.material3.TextButton(
139 onClick = { if (state.isSearching) vm.setQuery(state.query) else vm.open(state.path) },
140 ) {
141 Text(stringResource(R.string.retry))
142 }
143 }
144 state.isSearching -> SearchResults(state.results, onOpenFile)
145 else -> DirectoryListing(
146 state = state,
147 onOpenDir = vm::open,
148 onUp = vm::up,
149 onOpenFile = onOpenFile,
150 )
151 }
152 }
153 }
154 }
155 }
158@Composable
159private fun Breadcrumbs(path: String, onNavigate: (String) -> Unit, modifier: Modifier = Modifier) {
160 val segments = if (path.isBlank()) emptyList() else path.trim('/').split('/').filter { it.isNotEmpty() }
161 Row(
162 modifier = modifier
163 .horizontalScroll(rememberScrollState())
164 .padding(horizontal = 16.dp, vertical = 8.dp),
165 verticalAlignment = Alignment.CenterVertically,
166 ) {
167 IconButton(onClick = { onNavigate("") }) {
168 Icon(Icons.Filled.Home, contentDescription = stringResource(R.string.root), modifier = Modifier.size(24.dp))
169 }
170 var acc = ""
171 segments.forEachIndexed { index, segment ->
172 acc = if (acc.isEmpty()) segment else "$acc/$segment"
173 Icon(
174 Icons.Filled.ChevronRight,
175 contentDescription = null,
176 modifier = Modifier.size(16.dp),
177 tint = MaterialTheme.colorScheme.onSurfaceVariant,
178 )
179 val target = acc
180 Text(
181 segment,
182 style = MaterialTheme.typography.bodyMedium,
183 color = if (index == segments.lastIndex) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.primary,
184 modifier = Modifier
185 .clickable(role = Role.Button) { onNavigate(target) }
186 .defaultMinSize(minHeight = 48.dp)
187 .padding(horizontal = 6.dp, vertical = 14.dp),
188 )
189 }
190 }
193@Composable
194private fun SearchResults(results: List<String>, onOpenFile: (String) -> Unit) {
195 if (results.isEmpty()) {
196 EmptyFileState(
197 icon = Icons.Filled.Search,
198 message = stringResource(R.string.no_matches),
199 modifier = Modifier.fillMaxWidth().padding(24.dp),
200 )
201 return
202 }
203 LazyColumn(modifier = Modifier.fillMaxSize()) {
204 items(results, key = { it }) { path ->
205 // Split the path into directory (muted) + filename (emphasized) so results
206 // are scannable instead of a wall of identical-looking full paths.
207 val dir = path.substringBeforeLast('/', missingDelimiterValue = "").trimEnd('/')
208 val name = path.substringAfterLast('/')
209 FileRow(
210 icon = false,
211 label = name,
212 sublabel = dir.takeIf { it.isNotEmpty() },
213 onClick = { onOpenFile(path) },
214 modifier = Modifier.animateItem(),
215 )
216 HorizontalDivider()
217 }
218 }
221@Composable
222private fun DirectoryListing(
223 state: FileBrowserState,
224 onOpenDir: (String) -> Unit,
225 onUp: () -> Unit,
226 onOpenFile: (String) -> Unit,
227) {
228 if (state.entries.isEmpty()) {
229 EmptyFileState(
230 icon = Icons.Filled.Folder,
231 message = stringResource(R.string.empty_folder),
232 modifier = Modifier.fillMaxWidth().padding(24.dp),
233 )
234 return
235 }
236 LazyColumn(modifier = Modifier.fillMaxSize()) {
237 if (state.path.isNotBlank()) {
238 item(key = "__up") {
239 FileRow(icon = true, label = "..", onClick = onUp)
240 HorizontalDivider()
241 }
242 }
243 items(state.entries, key = { it.path + "_" + it.name }) { node ->
244 FileRow(
245 icon = node.isDirectory,
246 label = node.name,
247 onClick = { if (node.isDirectory) onOpenDir(node.path) else onOpenFile(node.path) },
248 status = state.statusMap[node.path],
249 modifier = Modifier.animateItem(),
250 )
251 HorizontalDivider()
252 }
253 }
256/** Shared empty-state for the file browser: icon + message, matching the icon+text
257 * pattern used by the session and server lists (the prior versions were bare Text
258 * lines that read as status messages rather than intentional empty states). */
259@Composable
260private fun EmptyFileState(icon: androidx.compose.ui.graphics.vector.ImageVector, message: String, modifier: Modifier = Modifier) {
261 Column(
262 modifier = modifier,
263 horizontalAlignment = Alignment.CenterHorizontally,
264 ) {
265 Icon(
266 icon,
267 contentDescription = null,
268 modifier = Modifier.size(48.dp),
269 tint = MaterialTheme.colorScheme.onSurfaceVariant,
270 )
271 Spacer(Modifier.size(12.dp))
272 Text(
273 message,
274 style = MaterialTheme.typography.bodyMedium,
275 color = MaterialTheme.colorScheme.onSurfaceVariant,
276 )
277 }
280@Composable
281private fun FileRow(
282 icon: Boolean,
283 label: String,
284 onClick: () -> Unit,
285 status: FileStatusEntry? = null,
286 sublabel: String? = null,
287 modifier: Modifier = Modifier,
288) {
289 val fileDesc = if (label == "..") stringResource(R.string.parent_dir)
290 else if (icon) stringResource(R.string.folder, label)
291 else stringResource(R.string.file_label, label)
292 // Build a combined description so TalkBack announces both the file name and its git
293 // status. The parent sets an explicit contentDescription (mergeDescendants), which
294 // would otherwise suppress the StatusBadge's own description.
295 val statusDesc = status?.let {
296 when (it.status) {
297 "added" -> stringResource(R.string.git_added)
298 "modified" -> stringResource(R.string.git_modified)
299 "deleted" -> stringResource(R.string.git_deleted)
300 else -> ""
301 }
302 }.orEmpty()
303 val fullDesc = if (statusDesc.isNotEmpty()) "$fileDesc, $statusDesc" else fileDesc
304 Row(
305 modifier = modifier
306 .fillMaxWidth()
307 .defaultMinSize(minHeight = 48.dp)
308 .clickable(role = Role.Button, onClick = onClick)
309 .semantics(mergeDescendants = true) { contentDescription = fullDesc }
310 .padding(horizontal = 16.dp, vertical = 14.dp),
311 verticalAlignment = Alignment.CenterVertically,
312 ) {
313 Icon(
314 if (icon) Icons.Filled.Folder else Icons.Filled.Description,
315 contentDescription = null,
316 modifier = Modifier.size(20.dp),
317 tint = if (icon) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
318 )
319 Column(modifier = Modifier.weight(1f).padding(start = 8.dp)) {
320 Text(
321 label,
322 style = MaterialTheme.typography.bodyLarge,
323 maxLines = 1,
324 overflow = TextOverflow.Ellipsis,
325 )
326 if (sublabel != null) {
327 Text(
328 sublabel,
329 style = MaterialTheme.typography.labelSmall,
330 color = MaterialTheme.colorScheme.onSurfaceVariant,
331 maxLines = 1,
332 overflow = TextOverflow.Ellipsis,
333 )
334 }
335 }
336 if (status != null) StatusBadge(status)
337 }
340@Composable
341private fun StatusBadge(status: FileStatusEntry) {
342 val (letter, color) = when (status.status) {
343 "added" -> "A" to MaterialTheme.colorScheme.primary
344 "modified" -> "M" to MaterialTheme.colorScheme.tertiary
345 "deleted" -> "D" to MaterialTheme.colorScheme.error
346 else -> "·" to MaterialTheme.colorScheme.onSurfaceVariant
347 }
348 Row(
349 verticalAlignment = Alignment.CenterVertically,
350 ) {
351 Text(
352 letter,
353 style = MaterialTheme.typography.labelMedium,
354 fontFamily = FontFamily.Monospace,
355 color = color,
356 )
357 if (status.added > 0 || status.removed > 0) {
358 Text(
359 "+${status.added} −${status.removed}",
360 style = MaterialTheme.typography.labelSmall,
361 fontFamily = FontFamily.Monospace,
362 color = MaterialTheme.colorScheme.onSurfaceVariant,
363 modifier = Modifier.padding(start = 4.dp),
364 )
365 }
366 }