git.iko.soy/opencode-android
on 325e359 help sign in
292 lines 12 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.platform.testTag
43import androidx.compose.ui.semantics.Role
44import androidx.compose.ui.semantics.contentDescription
45import androidx.compose.ui.semantics.role
46import androidx.compose.ui.semantics.semantics
47import androidx.compose.ui.text.font.FontFamily
48import androidx.compose.ui.text.input.ImeAction
49import androidx.compose.ui.text.style.TextOverflow
50import androidx.compose.ui.unit.dp
51import androidx.compose.ui.res.stringResource
52import androidx.lifecycle.compose.collectAsStateWithLifecycle
53import androidx.lifecycle.viewmodel.compose.viewModel
54import soy.iko.opencode.data.model.FileStatusEntry
55import soy.iko.opencode.di.AppContainer
56import soy.iko.opencode.R
57import soy.iko.opencode.ui.vmFactory
59@OptIn(ExperimentalMaterial3Api::class)
60@Composable
61fun FileBrowserScreen(
62 container: AppContainer,
63 onOpenFile: (String) -> Unit,
64 onBack: () -> Unit,
65) {
66 val vm: FileBrowserViewModel = viewModel(factory = vmFactory { FileBrowserViewModel(container) })
67 val state by vm.state.collectAsStateWithLifecycle()
68 val transientError by vm.transientError.collectAsStateWithLifecycle()
69 val snackbar = remember { SnackbarHostState() }
71 LaunchedEffect(transientError) {
72 val msg = transientError ?: return@LaunchedEffect
73 try {
74 snackbar.showSnackbar(msg)
75 } finally {
76 vm.clearTransientError()
77 }
78 }
80 Scaffold(
81 topBar = {
82 TopAppBar(
83 title = { Text(stringResource(R.string.files), maxLines = 1, overflow = TextOverflow.Ellipsis) },
84 navigationIcon = {
85 IconButton(onClick = onBack) {
86 Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
87 }
88 },
89 )
90 },
91 snackbarHost = { SnackbarHost(snackbar) },
92 ) { padding ->
93 Column(modifier = Modifier.fillMaxSize().padding(padding)) {
94 // Tappable breadcrumb trail so deep paths are navigable.
95 Breadcrumbs(
96 path = state.path,
97 onNavigate = vm::open,
98 modifier = Modifier.fillMaxWidth(),
99 )
100 OutlinedTextField(
101 value = state.query,
102 onValueChange = vm::setQuery,
103 modifier = Modifier.fillMaxWidth().padding(12.dp).testTag("file_search"),
104 placeholder = { Text(stringResource(R.string.search_files)) },
105 label = { Text(stringResource(R.string.search_files)) },
106 leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
107 singleLine = true,
108 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
109 )
111 Box(modifier = Modifier.fillMaxSize()) {
112 when {
113 state.loading || state.searching -> {
114 val loadingLabel = stringResource(R.string.loading)
115 CircularProgressIndicator(
116 Modifier.align(Alignment.Center).semantics { contentDescription = loadingLabel },
117 )
118 }
119 state.error != null -> Column(
120 modifier = Modifier.align(Alignment.Center).padding(24.dp),
121 horizontalAlignment = Alignment.CenterHorizontally,
122 ) {
123 Text(
124 state.error ?: "",
125 color = MaterialTheme.colorScheme.error,
126 )
127 androidx.compose.foundation.layout.Spacer(Modifier.size(12.dp))
128 androidx.compose.material3.TextButton(onClick = { vm.open(state.path) }) {
129 Text(stringResource(R.string.retry))
130 }
131 }
132 state.isSearching -> SearchResults(state.results, onOpenFile)
133 else -> DirectoryListing(
134 state = state,
135 onOpenDir = vm::open,
136 onUp = vm::up,
137 onOpenFile = onOpenFile,
138 )
139 }
140 }
141 }
142 }
145@Composable
146private fun Breadcrumbs(path: String, onNavigate: (String) -> Unit, modifier: Modifier = Modifier) {
147 val segments = if (path.isBlank()) emptyList() else path.trim('/').split('/').filter { it.isNotEmpty() }
148 Row(
149 modifier = modifier
150 .horizontalScroll(rememberScrollState())
151 .padding(horizontal = 16.dp, vertical = 8.dp),
152 verticalAlignment = Alignment.CenterVertically,
153 ) {
154 IconButton(onClick = { onNavigate("") }) {
155 Icon(Icons.Filled.Home, contentDescription = stringResource(R.string.root), modifier = Modifier.size(24.dp))
156 }
157 var acc = ""
158 segments.forEachIndexed { index, segment ->
159 acc = if (acc.isEmpty()) segment else "$acc/$segment"
160 Icon(
161 Icons.Filled.ChevronRight,
162 contentDescription = null,
163 modifier = Modifier.size(16.dp),
164 tint = MaterialTheme.colorScheme.onSurfaceVariant,
165 )
166 val target = acc
167 Text(
168 segment,
169 style = MaterialTheme.typography.bodyMedium,
170 color = if (index == segments.lastIndex) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.primary,
171 modifier = Modifier
172 .clickable(role = Role.Button) { onNavigate(target) }
173 .padding(horizontal = 6.dp, vertical = 14.dp),
174 )
175 }
176 }
179@Composable
180private fun SearchResults(results: List<String>, onOpenFile: (String) -> Unit) {
181 if (results.isEmpty()) {
182 Text(stringResource(R.string.no_matches), modifier = Modifier.padding(24.dp))
183 return
184 }
185 LazyColumn(modifier = Modifier.fillMaxSize()) {
186 items(results, key = { it }) { path ->
187 FileRow(icon = false, label = path, onClick = { onOpenFile(path) })
188 HorizontalDivider()
189 }
190 }
193@Composable
194private fun DirectoryListing(
195 state: FileBrowserState,
196 onOpenDir: (String) -> Unit,
197 onUp: () -> Unit,
198 onOpenFile: (String) -> Unit,
199) {
200 if (state.entries.isEmpty() && state.path.isBlank()) {
201 Text(
202 stringResource(R.string.empty_folder),
203 modifier = Modifier.fillMaxWidth().padding(24.dp),
204 color = MaterialTheme.colorScheme.onSurfaceVariant,
205 )
206 return
207 }
208 LazyColumn(modifier = Modifier.fillMaxSize()) {
209 if (state.path.isNotBlank()) {
210 item(key = "__up") {
211 FileRow(icon = true, label = "..", onClick = onUp)
212 HorizontalDivider()
213 }
214 }
215 items(state.entries, key = { it.path + "_" + it.name }) { node ->
216 FileRow(
217 icon = node.isDirectory,
218 label = node.name,
219 onClick = { if (node.isDirectory) onOpenDir(node.path) else onOpenFile(node.path) },
220 status = state.statusMap[node.path],
221 )
222 HorizontalDivider()
223 }
224 }
227@Composable
228private fun FileRow(
229 icon: Boolean,
230 label: String,
231 onClick: () -> Unit,
232 status: FileStatusEntry? = null,
233) {
234 val desc = if (label == "..") stringResource(R.string.parent_dir)
235 else if (icon) stringResource(R.string.folder, label)
236 else stringResource(R.string.file_label, label)
237 Row(
238 modifier = Modifier
239 .fillMaxWidth()
240 .clickable(role = Role.Button, onClick = onClick)
241 .semantics(mergeDescendants = true) { contentDescription = desc }
242 .padding(horizontal = 16.dp, vertical = 14.dp),
243 verticalAlignment = Alignment.CenterVertically,
244 ) {
245 Icon(
246 if (icon) Icons.Filled.Folder else Icons.Filled.Description,
247 contentDescription = null,
248 modifier = Modifier.size(20.dp),
249 tint = if (icon) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
250 )
251 Text(
252 label,
253 style = MaterialTheme.typography.bodyLarge,
254 maxLines = 1,
255 overflow = TextOverflow.Ellipsis,
256 modifier = Modifier.weight(1f).padding(start = 8.dp),
257 )
258 if (status != null) StatusBadge(status)
259 }
262@Composable
263private fun StatusBadge(status: FileStatusEntry) {
264 val (letter, color, desc) = when (status.status) {
265 "added" -> Triple("A", MaterialTheme.colorScheme.primary, stringResource(R.string.git_added))
266 "modified" -> Triple("M", MaterialTheme.colorScheme.tertiary, stringResource(R.string.git_modified))
267 "deleted" -> Triple("D", MaterialTheme.colorScheme.error, stringResource(R.string.git_deleted))
268 else -> Triple("·", MaterialTheme.colorScheme.onSurfaceVariant, "")
269 }
270 Row(
271 verticalAlignment = Alignment.CenterVertically,
272 modifier = Modifier.semantics(mergeDescendants = true) {
273 if (desc.isNotEmpty()) contentDescription = desc
274 },
275 ) {
276 Text(
277 letter,
278 style = MaterialTheme.typography.labelMedium,
279 fontFamily = FontFamily.Monospace,
280 color = color,
281 )
282 if (status.added > 0 || status.removed > 0) {
283 Text(
284 "+${status.added} −${status.removed}",
285 style = MaterialTheme.typography.labelSmall,
286 fontFamily = FontFamily.Monospace,
287 color = MaterialTheme.colorScheme.onSurfaceVariant,
288 modifier = Modifier.padding(start = 4.dp),
289 )
290 }
291 }