git.iko.soy/opencode-android
on d29e57b help sign in
317 lines 13 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.defaultMinSize
9import androidx.compose.foundation.layout.fillMaxSize
10import androidx.compose.foundation.layout.fillMaxWidth
11import androidx.compose.foundation.layout.padding
12import androidx.compose.foundation.layout.size
13import androidx.compose.foundation.lazy.LazyColumn
14import androidx.compose.foundation.lazy.items
15import androidx.compose.foundation.rememberScrollState
16import androidx.compose.foundation.text.KeyboardOptions
17import androidx.compose.material.icons.Icons
18import androidx.compose.material.icons.automirrored.filled.ArrowBack
19import androidx.compose.material.icons.filled.ChevronRight
20import androidx.compose.material.icons.filled.Description
21import androidx.compose.material.icons.filled.Folder
22import androidx.compose.material.icons.filled.Home
23import androidx.compose.material.icons.filled.Search
24import androidx.compose.material3.CircularProgressIndicator
25import androidx.compose.material3.ExperimentalMaterial3Api
26import androidx.compose.material3.HorizontalDivider
27import androidx.compose.material3.Icon
28import androidx.compose.material3.IconButton
29import androidx.compose.material3.MaterialTheme
30import androidx.compose.material3.OutlinedTextField
31import androidx.compose.material3.Scaffold
32import androidx.compose.material3.SnackbarHost
33import androidx.compose.material3.SnackbarHostState
34import androidx.compose.material3.Text
35import androidx.compose.material3.TopAppBar
36import androidx.compose.runtime.Composable
37import androidx.compose.runtime.LaunchedEffect
38import androidx.compose.runtime.getValue
39import androidx.compose.runtime.remember
40import androidx.compose.ui.Alignment
41import androidx.compose.ui.Modifier
42import androidx.compose.ui.graphics.Color
43import androidx.compose.ui.platform.testTag
44import androidx.compose.ui.semantics.Role
45import androidx.compose.ui.semantics.contentDescription
46import androidx.compose.ui.semantics.role
47import androidx.compose.ui.semantics.semantics
48import androidx.compose.ui.text.font.FontFamily
49import androidx.compose.ui.text.input.ImeAction
50import androidx.compose.ui.text.style.TextOverflow
51import androidx.compose.ui.unit.dp
52import androidx.compose.ui.res.stringResource
53import androidx.lifecycle.compose.collectAsStateWithLifecycle
54import androidx.lifecycle.viewmodel.compose.viewModel
55import soy.iko.opencode.data.model.FileStatusEntry
56import soy.iko.opencode.di.AppContainer
57import soy.iko.opencode.R
58import soy.iko.opencode.ui.vmFactory
60@OptIn(ExperimentalMaterial3Api::class)
61@Composable
62fun FileBrowserScreen(
63 container: AppContainer,
64 onOpenFile: (String) -> Unit,
65 onBack: () -> Unit,
66) {
67 val vm: FileBrowserViewModel = viewModel(factory = vmFactory { FileBrowserViewModel(container) })
68 val state by vm.state.collectAsStateWithLifecycle()
69 val refreshing by vm.refreshing.collectAsStateWithLifecycle()
70 val snackbar = remember { SnackbarHostState() }
72 LaunchedEffect(Unit) {
73 vm.transientErrors.collect { msg ->
74 snackbar.showSnackbar(msg)
75 }
76 }
78 Scaffold(
79 topBar = {
80 TopAppBar(
81 title = { Text(stringResource(R.string.files), maxLines = 1, overflow = TextOverflow.Ellipsis) },
82 navigationIcon = {
83 IconButton(onClick = onBack) {
84 Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
85 }
86 },
87 )
88 },
89 snackbarHost = { SnackbarHost(snackbar) },
90 ) { padding ->
91 Column(modifier = Modifier.fillMaxSize().padding(padding)) {
92 // Tappable breadcrumb trail so deep paths are navigable.
93 Breadcrumbs(
94 path = state.path,
95 onNavigate = vm::open,
96 modifier = Modifier.fillMaxWidth(),
97 )
98 OutlinedTextField(
99 value = state.query,
100 onValueChange = vm::setQuery,
101 modifier = Modifier.fillMaxWidth().padding(12.dp).testTag("file_search"),
102 placeholder = { Text(stringResource(R.string.search_files)) },
103 label = { Text(stringResource(R.string.search_files)) },
104 leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
105 singleLine = true,
106 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
107 )
109 androidx.compose.material3.pulltorefresh.PullToRefreshBox(
110 isRefreshing = refreshing,
111 onRefresh = { vm.refresh() },
112 modifier = Modifier.fillMaxSize(),
113 ) {
114 Box(modifier = Modifier.fillMaxSize()) {
115 when {
116 state.loading || state.searching -> {
117 val loadingLabel = stringResource(R.string.loading)
118 CircularProgressIndicator(
119 Modifier.align(Alignment.Center).semantics { contentDescription = loadingLabel },
120 )
121 }
122 state.error != null -> Column(
123 modifier = Modifier.align(Alignment.Center).padding(24.dp),
124 horizontalAlignment = Alignment.CenterHorizontally,
125 ) {
126 Text(
127 state.error ?: "",
128 color = MaterialTheme.colorScheme.error,
129 )
130 androidx.compose.foundation.layout.Spacer(Modifier.size(12.dp))
131 androidx.compose.material3.TextButton(onClick = { vm.open(state.path) }) {
132 Text(stringResource(R.string.retry))
133 }
134 }
135 state.isSearching -> SearchResults(state.results, onOpenFile)
136 else -> DirectoryListing(
137 state = state,
138 onOpenDir = vm::open,
139 onUp = vm::up,
140 onOpenFile = onOpenFile,
141 )
142 }
143 }
144 }
145 }
146 }
149@Composable
150private fun Breadcrumbs(path: String, onNavigate: (String) -> Unit, modifier: Modifier = Modifier) {
151 val segments = if (path.isBlank()) emptyList() else path.trim('/').split('/').filter { it.isNotEmpty() }
152 Row(
153 modifier = modifier
154 .horizontalScroll(rememberScrollState())
155 .padding(horizontal = 16.dp, vertical = 8.dp),
156 verticalAlignment = Alignment.CenterVertically,
157 ) {
158 IconButton(onClick = { onNavigate("") }) {
159 Icon(Icons.Filled.Home, contentDescription = stringResource(R.string.root), modifier = Modifier.size(24.dp))
160 }
161 var acc = ""
162 segments.forEachIndexed { index, segment ->
163 acc = if (acc.isEmpty()) segment else "$acc/$segment"
164 Icon(
165 Icons.Filled.ChevronRight,
166 contentDescription = null,
167 modifier = Modifier.size(16.dp),
168 tint = MaterialTheme.colorScheme.onSurfaceVariant,
169 )
170 val target = acc
171 Text(
172 segment,
173 style = MaterialTheme.typography.bodyMedium,
174 color = if (index == segments.lastIndex) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.primary,
175 modifier = Modifier
176 .clickable(role = Role.Button) { onNavigate(target) }
177 .padding(horizontal = 6.dp, vertical = 14.dp),
178 )
179 }
180 }
183@Composable
184private fun SearchResults(results: List<String>, onOpenFile: (String) -> Unit) {
185 if (results.isEmpty()) {
186 Text(stringResource(R.string.no_matches), modifier = Modifier.padding(24.dp))
187 return
188 }
189 LazyColumn(modifier = Modifier.fillMaxSize()) {
190 items(results, key = { it }) { path ->
191 // Split the path into directory (muted) + filename (emphasized) so results
192 // are scannable instead of a wall of identical-looking full paths.
193 val dir = path.substringBeforeLast('/', missingDelimiterValue = "").trimEnd('/')
194 val name = path.substringAfterLast('/')
195 FileRow(
196 icon = false,
197 label = name,
198 sublabel = dir.takeIf { it.isNotEmpty() },
199 onClick = { onOpenFile(path) },
200 )
201 HorizontalDivider()
202 }
203 }
206@Composable
207private fun DirectoryListing(
208 state: FileBrowserState,
209 onOpenDir: (String) -> Unit,
210 onUp: () -> Unit,
211 onOpenFile: (String) -> Unit,
212) {
213 if (state.entries.isEmpty()) {
214 Text(
215 stringResource(R.string.empty_folder),
216 modifier = Modifier.fillMaxWidth().padding(24.dp),
217 color = MaterialTheme.colorScheme.onSurfaceVariant,
218 )
219 return
220 }
221 LazyColumn(modifier = Modifier.fillMaxSize()) {
222 if (state.path.isNotBlank()) {
223 item(key = "__up") {
224 FileRow(icon = true, label = "..", onClick = onUp)
225 HorizontalDivider()
226 }
227 }
228 items(state.entries, key = { it.path + "_" + it.name }) { node ->
229 FileRow(
230 icon = node.isDirectory,
231 label = node.name,
232 onClick = { if (node.isDirectory) onOpenDir(node.path) else onOpenFile(node.path) },
233 status = state.statusMap[node.path],
234 )
235 HorizontalDivider()
236 }
237 }
240@Composable
241private fun FileRow(
242 icon: Boolean,
243 label: String,
244 onClick: () -> Unit,
245 status: FileStatusEntry? = null,
246 sublabel: String? = null,
247) {
248 val desc = if (label == "..") stringResource(R.string.parent_dir)
249 else if (icon) stringResource(R.string.folder, label)
250 else stringResource(R.string.file_label, label)
251 Row(
252 modifier = Modifier
253 .fillMaxWidth()
254 .defaultMinSize(minHeight = 48.dp)
255 .clickable(role = Role.Button, onClick = onClick)
256 .semantics(mergeDescendants = true) { contentDescription = desc }
257 .padding(horizontal = 16.dp, vertical = 14.dp),
258 verticalAlignment = Alignment.CenterVertically,
259 ) {
260 Icon(
261 if (icon) Icons.Filled.Folder else Icons.Filled.Description,
262 contentDescription = null,
263 modifier = Modifier.size(20.dp),
264 tint = if (icon) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
265 )
266 Column(modifier = Modifier.weight(1f).padding(start = 8.dp)) {
267 Text(
268 label,
269 style = MaterialTheme.typography.bodyLarge,
270 maxLines = 1,
271 overflow = TextOverflow.Ellipsis,
272 )
273 if (sublabel != null) {
274 Text(
275 sublabel,
276 style = MaterialTheme.typography.labelSmall,
277 color = MaterialTheme.colorScheme.onSurfaceVariant,
278 maxLines = 1,
279 overflow = TextOverflow.Ellipsis,
280 )
281 }
282 }
283 if (status != null) StatusBadge(status)
284 }
287@Composable
288private fun StatusBadge(status: FileStatusEntry) {
289 val (letter, color, desc) = when (status.status) {
290 "added" -> Triple("A", MaterialTheme.colorScheme.primary, stringResource(R.string.git_added))
291 "modified" -> Triple("M", MaterialTheme.colorScheme.tertiary, stringResource(R.string.git_modified))
292 "deleted" -> Triple("D", MaterialTheme.colorScheme.error, stringResource(R.string.git_deleted))
293 else -> Triple("·", MaterialTheme.colorScheme.onSurfaceVariant, "")
294 }
295 Row(
296 verticalAlignment = Alignment.CenterVertically,
297 modifier = Modifier.semantics(mergeDescendants = true) {
298 if (desc.isNotEmpty()) contentDescription = desc
299 },
300 ) {
301 Text(
302 letter,
303 style = MaterialTheme.typography.labelMedium,
304 fontFamily = FontFamily.Monospace,
305 color = color,
306 )
307 if (status.added > 0 || status.removed > 0) {
308 Text(
309 "+${status.added} −${status.removed}",
310 style = MaterialTheme.typography.labelSmall,
311 fontFamily = FontFamily.Monospace,
312 color = MaterialTheme.colorScheme.onSurfaceVariant,
313 modifier = Modifier.padding(start = 4.dp),
314 )
315 }
316 }