git.iko.soy/opencode-android
on d643c13 help sign in
281 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.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 snackbar.showSnackbar(msg)
74 vm.clearTransientError()
75 }
77 Scaffold(
78 topBar = {
79 TopAppBar(
80 title = { Text(stringResource(R.string.files), maxLines = 1, overflow = TextOverflow.Ellipsis) },
81 navigationIcon = {
82 IconButton(onClick = onBack) {
83 Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
84 }
85 },
86 )
87 },
88 snackbarHost = { SnackbarHost(snackbar) },
89 ) { padding ->
90 Column(modifier = Modifier.fillMaxSize().padding(padding)) {
91 // Tappable breadcrumb trail so deep paths are navigable.
92 Breadcrumbs(
93 path = state.path,
94 onNavigate = vm::open,
95 modifier = Modifier.fillMaxWidth(),
96 )
97 OutlinedTextField(
98 value = state.query,
99 onValueChange = vm::setQuery,
100 modifier = Modifier.fillMaxWidth().padding(12.dp).testTag("file_search"),
101 placeholder = { Text(stringResource(R.string.search_files)) },
102 label = { Text(stringResource(R.string.search_files)) },
103 leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
104 singleLine = true,
105 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
106 )
108 Box(modifier = Modifier.fillMaxSize()) {
109 when {
110 state.loading || state.searching -> {
111 val loadingLabel = stringResource(R.string.loading)
112 CircularProgressIndicator(
113 Modifier.align(Alignment.Center).semantics { contentDescription = loadingLabel },
114 )
115 }
116 state.error != null && !state.isSearching -> Text(
117 state.error ?: "",
118 modifier = Modifier.align(Alignment.Center).padding(24.dp),
119 color = MaterialTheme.colorScheme.error,
120 )
121 state.isSearching -> SearchResults(state.results, onOpenFile)
122 else -> DirectoryListing(
123 state = state,
124 onOpenDir = vm::open,
125 onUp = vm::up,
126 onOpenFile = onOpenFile,
127 )
128 }
129 }
130 }
131 }
134@Composable
135private fun Breadcrumbs(path: String, onNavigate: (String) -> Unit, modifier: Modifier = Modifier) {
136 val segments = if (path.isBlank()) emptyList() else path.trim('/').split('/').filter { it.isNotEmpty() }
137 Row(
138 modifier = modifier
139 .horizontalScroll(rememberScrollState())
140 .padding(horizontal = 16.dp, vertical = 8.dp),
141 verticalAlignment = Alignment.CenterVertically,
142 ) {
143 IconButton(onClick = { onNavigate("") }) {
144 Icon(Icons.Filled.Home, contentDescription = stringResource(R.string.root), modifier = Modifier.size(24.dp))
145 }
146 var acc = ""
147 segments.forEachIndexed { index, segment ->
148 acc = if (acc.isEmpty()) segment else "$acc/$segment"
149 Icon(
150 Icons.Filled.ChevronRight,
151 contentDescription = null,
152 modifier = Modifier.size(16.dp),
153 tint = MaterialTheme.colorScheme.onSurfaceVariant,
154 )
155 val target = acc
156 Text(
157 segment,
158 style = MaterialTheme.typography.bodyMedium,
159 color = if (index == segments.lastIndex) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.primary,
160 modifier = Modifier
161 .clickable(role = Role.Button) { onNavigate(target) }
162 .padding(horizontal = 6.dp, vertical = 14.dp),
163 )
164 }
165 }
168@Composable
169private fun SearchResults(results: List<String>, onOpenFile: (String) -> Unit) {
170 if (results.isEmpty()) {
171 Text(stringResource(R.string.no_matches), modifier = Modifier.padding(24.dp))
172 return
173 }
174 LazyColumn(modifier = Modifier.fillMaxSize()) {
175 items(results, key = { it }) { path ->
176 FileRow(icon = false, label = path, onClick = { onOpenFile(path) })
177 HorizontalDivider()
178 }
179 }
182@Composable
183private fun DirectoryListing(
184 state: FileBrowserState,
185 onOpenDir: (String) -> Unit,
186 onUp: () -> Unit,
187 onOpenFile: (String) -> Unit,
188) {
189 if (state.entries.isEmpty() && state.path.isBlank()) {
190 Text(
191 stringResource(R.string.empty_folder),
192 modifier = Modifier.fillMaxWidth().padding(24.dp),
193 color = MaterialTheme.colorScheme.onSurfaceVariant,
194 )
195 return
196 }
197 LazyColumn(modifier = Modifier.fillMaxSize()) {
198 if (state.path.isNotBlank()) {
199 item(key = "__up") {
200 FileRow(icon = true, label = "..", onClick = onUp)
201 HorizontalDivider()
202 }
203 }
204 items(state.entries, key = { it.path + "_" + it.name }) { node ->
205 FileRow(
206 icon = node.isDirectory,
207 label = node.name,
208 onClick = { if (node.isDirectory) onOpenDir(node.path) else onOpenFile(node.path) },
209 status = state.statusMap[node.path],
210 )
211 HorizontalDivider()
212 }
213 }
216@Composable
217private fun FileRow(
218 icon: Boolean,
219 label: String,
220 onClick: () -> Unit,
221 status: FileStatusEntry? = null,
222) {
223 val desc = if (label == "..") stringResource(R.string.parent_dir)
224 else if (icon) stringResource(R.string.folder, label)
225 else stringResource(R.string.file_label, label)
226 Row(
227 modifier = Modifier
228 .fillMaxWidth()
229 .clickable(role = Role.Button, onClick = onClick)
230 .semantics(mergeDescendants = true) { contentDescription = desc }
231 .padding(horizontal = 16.dp, vertical = 14.dp),
232 verticalAlignment = Alignment.CenterVertically,
233 ) {
234 Icon(
235 if (icon) Icons.Filled.Folder else Icons.Filled.Description,
236 contentDescription = null,
237 modifier = Modifier.size(20.dp),
238 tint = if (icon) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
239 )
240 Text(
241 label,
242 style = MaterialTheme.typography.bodyLarge,
243 maxLines = 1,
244 overflow = TextOverflow.Ellipsis,
245 modifier = Modifier.weight(1f).padding(start = 8.dp),
246 )
247 if (status != null) StatusBadge(status)
248 }
251@Composable
252private fun StatusBadge(status: FileStatusEntry) {
253 val (letter, color, desc) = when (status.status) {
254 "added" -> Triple("A", MaterialTheme.colorScheme.primary, stringResource(R.string.git_added))
255 "modified" -> Triple("M", MaterialTheme.colorScheme.tertiary, stringResource(R.string.git_modified))
256 "deleted" -> Triple("D", MaterialTheme.colorScheme.error, stringResource(R.string.git_deleted))
257 else -> Triple("·", MaterialTheme.colorScheme.onSurfaceVariant, "")
258 }
259 Row(
260 verticalAlignment = Alignment.CenterVertically,
261 modifier = Modifier.semantics(mergeDescendants = true) {
262 if (desc.isNotEmpty()) contentDescription = desc
263 },
264 ) {
265 Text(
266 letter,
267 style = MaterialTheme.typography.labelMedium,
268 fontFamily = FontFamily.Monospace,
269 color = color,
270 )
271 if (status.added > 0 || status.removed > 0) {
272 Text(
273 "+${status.added} −${status.removed}",
274 style = MaterialTheme.typography.labelSmall,
275 fontFamily = FontFamily.Monospace,
276 color = MaterialTheme.colorScheme.onSurfaceVariant,
277 modifier = Modifier.padding(start = 4.dp),
278 )
279 }
280 }