git.iko.soy/opencode-android
on bc6b45e help sign in
362 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 androidx.compose.material3.TextButton(onClick = { vm.open(state.path) }) {
136 Text(stringResource(R.string.retry))
137 }
138 }
139 state.isSearching -> SearchResults(state.results, onOpenFile)
140 else -> DirectoryListing(
141 state = state,
142 onOpenDir = vm::open,
143 onUp = vm::up,
144 onOpenFile = onOpenFile,
145 )
146 }
147 }
148 }
149 }
150 }
153@Composable
154private fun Breadcrumbs(path: String, onNavigate: (String) -> Unit, modifier: Modifier = Modifier) {
155 val segments = if (path.isBlank()) emptyList() else path.trim('/').split('/').filter { it.isNotEmpty() }
156 Row(
157 modifier = modifier
158 .horizontalScroll(rememberScrollState())
159 .padding(horizontal = 16.dp, vertical = 8.dp),
160 verticalAlignment = Alignment.CenterVertically,
161 ) {
162 IconButton(onClick = { onNavigate("") }) {
163 Icon(Icons.Filled.Home, contentDescription = stringResource(R.string.root), modifier = Modifier.size(24.dp))
164 }
165 var acc = ""
166 segments.forEachIndexed { index, segment ->
167 acc = if (acc.isEmpty()) segment else "$acc/$segment"
168 Icon(
169 Icons.Filled.ChevronRight,
170 contentDescription = null,
171 modifier = Modifier.size(16.dp),
172 tint = MaterialTheme.colorScheme.onSurfaceVariant,
173 )
174 val target = acc
175 Text(
176 segment,
177 style = MaterialTheme.typography.bodyMedium,
178 color = if (index == segments.lastIndex) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.primary,
179 modifier = Modifier
180 .clickable(role = Role.Button) { onNavigate(target) }
181 .defaultMinSize(minHeight = 48.dp)
182 .padding(horizontal = 6.dp, vertical = 14.dp),
183 )
184 }
185 }
188@Composable
189private fun SearchResults(results: List<String>, onOpenFile: (String) -> Unit) {
190 if (results.isEmpty()) {
191 EmptyFileState(
192 icon = Icons.Filled.Search,
193 message = stringResource(R.string.no_matches),
194 modifier = Modifier.fillMaxWidth().padding(24.dp),
195 )
196 return
197 }
198 LazyColumn(modifier = Modifier.fillMaxSize()) {
199 items(results, key = { it }) { path ->
200 // Split the path into directory (muted) + filename (emphasized) so results
201 // are scannable instead of a wall of identical-looking full paths.
202 val dir = path.substringBeforeLast('/', missingDelimiterValue = "").trimEnd('/')
203 val name = path.substringAfterLast('/')
204 FileRow(
205 icon = false,
206 label = name,
207 sublabel = dir.takeIf { it.isNotEmpty() },
208 onClick = { onOpenFile(path) },
209 modifier = Modifier.animateItem(),
210 )
211 HorizontalDivider()
212 }
213 }
216@Composable
217private fun DirectoryListing(
218 state: FileBrowserState,
219 onOpenDir: (String) -> Unit,
220 onUp: () -> Unit,
221 onOpenFile: (String) -> Unit,
222) {
223 if (state.entries.isEmpty()) {
224 EmptyFileState(
225 icon = Icons.Filled.Folder,
226 message = stringResource(R.string.empty_folder),
227 modifier = Modifier.fillMaxWidth().padding(24.dp),
228 )
229 return
230 }
231 LazyColumn(modifier = Modifier.fillMaxSize()) {
232 if (state.path.isNotBlank()) {
233 item(key = "__up") {
234 FileRow(icon = true, label = "..", onClick = onUp)
235 HorizontalDivider()
236 }
237 }
238 items(state.entries, key = { it.path + "_" + it.name }) { node ->
239 FileRow(
240 icon = node.isDirectory,
241 label = node.name,
242 onClick = { if (node.isDirectory) onOpenDir(node.path) else onOpenFile(node.path) },
243 status = state.statusMap[node.path],
244 modifier = Modifier.animateItem(),
245 )
246 HorizontalDivider()
247 }
248 }
251/** Shared empty-state for the file browser: icon + message, matching the icon+text
252 * pattern used by the session and server lists (the prior versions were bare Text
253 * lines that read as status messages rather than intentional empty states). */
254@Composable
255private fun EmptyFileState(icon: androidx.compose.ui.graphics.vector.ImageVector, message: String, modifier: Modifier = Modifier) {
256 Column(
257 modifier = modifier,
258 horizontalAlignment = Alignment.CenterHorizontally,
259 ) {
260 Icon(
261 icon,
262 contentDescription = null,
263 modifier = Modifier.size(48.dp),
264 tint = MaterialTheme.colorScheme.onSurfaceVariant,
265 )
266 Spacer(Modifier.size(12.dp))
267 Text(
268 message,
269 style = MaterialTheme.typography.bodyMedium,
270 color = MaterialTheme.colorScheme.onSurfaceVariant,
271 )
272 }
275@Composable
276private fun FileRow(
277 icon: Boolean,
278 label: String,
279 onClick: () -> Unit,
280 status: FileStatusEntry? = null,
281 sublabel: String? = null,
282 modifier: Modifier = Modifier,
283) {
284 val fileDesc = if (label == "..") stringResource(R.string.parent_dir)
285 else if (icon) stringResource(R.string.folder, label)
286 else stringResource(R.string.file_label, label)
287 // Build a combined description so TalkBack announces both the file name and its git
288 // status. The parent sets an explicit contentDescription (mergeDescendants), which
289 // would otherwise suppress the StatusBadge's own description.
290 val statusDesc = status?.let {
291 when (it.status) {
292 "added" -> stringResource(R.string.git_added)
293 "modified" -> stringResource(R.string.git_modified)
294 "deleted" -> stringResource(R.string.git_deleted)
295 else -> ""
296 }
297 }.orEmpty()
298 val fullDesc = if (statusDesc.isNotEmpty()) "$fileDesc, $statusDesc" else fileDesc
299 Row(
300 modifier = modifier
301 .fillMaxWidth()
302 .defaultMinSize(minHeight = 48.dp)
303 .clickable(role = Role.Button, onClick = onClick)
304 .semantics(mergeDescendants = true) { contentDescription = fullDesc }
305 .padding(horizontal = 16.dp, vertical = 14.dp),
306 verticalAlignment = Alignment.CenterVertically,
307 ) {
308 Icon(
309 if (icon) Icons.Filled.Folder else Icons.Filled.Description,
310 contentDescription = null,
311 modifier = Modifier.size(20.dp),
312 tint = if (icon) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
313 )
314 Column(modifier = Modifier.weight(1f).padding(start = 8.dp)) {
315 Text(
316 label,
317 style = MaterialTheme.typography.bodyLarge,
318 maxLines = 1,
319 overflow = TextOverflow.Ellipsis,
320 )
321 if (sublabel != null) {
322 Text(
323 sublabel,
324 style = MaterialTheme.typography.labelSmall,
325 color = MaterialTheme.colorScheme.onSurfaceVariant,
326 maxLines = 1,
327 overflow = TextOverflow.Ellipsis,
328 )
329 }
330 }
331 if (status != null) StatusBadge(status)
332 }
335@Composable
336private fun StatusBadge(status: FileStatusEntry) {
337 val (letter, color) = when (status.status) {
338 "added" -> "A" to MaterialTheme.colorScheme.primary
339 "modified" -> "M" to MaterialTheme.colorScheme.tertiary
340 "deleted" -> "D" to MaterialTheme.colorScheme.error
341 else -> "·" to MaterialTheme.colorScheme.onSurfaceVariant
342 }
343 Row(
344 verticalAlignment = Alignment.CenterVertically,
345 ) {
346 Text(
347 letter,
348 style = MaterialTheme.typography.labelMedium,
349 fontFamily = FontFamily.Monospace,
350 color = color,
351 )
352 if (status.added > 0 || status.removed > 0) {
353 Text(
354 "+${status.added} −${status.removed}",
355 style = MaterialTheme.typography.labelSmall,
356 fontFamily = FontFamily.Monospace,
357 color = MaterialTheme.colorScheme.onSurfaceVariant,
358 modifier = Modifier.padding(start = 4.dp),
359 )
360 }
361 }