git.iko.soy/opencode-android
on fb38d12 help sign in
284 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 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 -> Text(
120 state.error ?: "",
121 modifier = Modifier.align(Alignment.Center).padding(24.dp),
122 color = MaterialTheme.colorScheme.error,
123 )
124 state.isSearching -> SearchResults(state.results, onOpenFile)
125 else -> DirectoryListing(
126 state = state,
127 onOpenDir = vm::open,
128 onUp = vm::up,
129 onOpenFile = onOpenFile,
130 )
131 }
132 }
133 }
134 }
137@Composable
138private fun Breadcrumbs(path: String, onNavigate: (String) -> Unit, modifier: Modifier = Modifier) {
139 val segments = if (path.isBlank()) emptyList() else path.trim('/').split('/').filter { it.isNotEmpty() }
140 Row(
141 modifier = modifier
142 .horizontalScroll(rememberScrollState())
143 .padding(horizontal = 16.dp, vertical = 8.dp),
144 verticalAlignment = Alignment.CenterVertically,
145 ) {
146 IconButton(onClick = { onNavigate("") }) {
147 Icon(Icons.Filled.Home, contentDescription = stringResource(R.string.root), modifier = Modifier.size(24.dp))
148 }
149 var acc = ""
150 segments.forEachIndexed { index, segment ->
151 acc = if (acc.isEmpty()) segment else "$acc/$segment"
152 Icon(
153 Icons.Filled.ChevronRight,
154 contentDescription = null,
155 modifier = Modifier.size(16.dp),
156 tint = MaterialTheme.colorScheme.onSurfaceVariant,
157 )
158 val target = acc
159 Text(
160 segment,
161 style = MaterialTheme.typography.bodyMedium,
162 color = if (index == segments.lastIndex) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.primary,
163 modifier = Modifier
164 .clickable(role = Role.Button) { onNavigate(target) }
165 .padding(horizontal = 6.dp, vertical = 14.dp),
166 )
167 }
168 }
171@Composable
172private fun SearchResults(results: List<String>, onOpenFile: (String) -> Unit) {
173 if (results.isEmpty()) {
174 Text(stringResource(R.string.no_matches), modifier = Modifier.padding(24.dp))
175 return
176 }
177 LazyColumn(modifier = Modifier.fillMaxSize()) {
178 items(results, key = { it }) { path ->
179 FileRow(icon = false, label = path, onClick = { onOpenFile(path) })
180 HorizontalDivider()
181 }
182 }
185@Composable
186private fun DirectoryListing(
187 state: FileBrowserState,
188 onOpenDir: (String) -> Unit,
189 onUp: () -> Unit,
190 onOpenFile: (String) -> Unit,
191) {
192 if (state.entries.isEmpty() && state.path.isBlank()) {
193 Text(
194 stringResource(R.string.empty_folder),
195 modifier = Modifier.fillMaxWidth().padding(24.dp),
196 color = MaterialTheme.colorScheme.onSurfaceVariant,
197 )
198 return
199 }
200 LazyColumn(modifier = Modifier.fillMaxSize()) {
201 if (state.path.isNotBlank()) {
202 item(key = "__up") {
203 FileRow(icon = true, label = "..", onClick = onUp)
204 HorizontalDivider()
205 }
206 }
207 items(state.entries, key = { it.path + "_" + it.name }) { node ->
208 FileRow(
209 icon = node.isDirectory,
210 label = node.name,
211 onClick = { if (node.isDirectory) onOpenDir(node.path) else onOpenFile(node.path) },
212 status = state.statusMap[node.path],
213 )
214 HorizontalDivider()
215 }
216 }
219@Composable
220private fun FileRow(
221 icon: Boolean,
222 label: String,
223 onClick: () -> Unit,
224 status: FileStatusEntry? = null,
225) {
226 val desc = if (label == "..") stringResource(R.string.parent_dir)
227 else if (icon) stringResource(R.string.folder, label)
228 else stringResource(R.string.file_label, label)
229 Row(
230 modifier = Modifier
231 .fillMaxWidth()
232 .clickable(role = Role.Button, onClick = onClick)
233 .semantics(mergeDescendants = true) { contentDescription = desc }
234 .padding(horizontal = 16.dp, vertical = 14.dp),
235 verticalAlignment = Alignment.CenterVertically,
236 ) {
237 Icon(
238 if (icon) Icons.Filled.Folder else Icons.Filled.Description,
239 contentDescription = null,
240 modifier = Modifier.size(20.dp),
241 tint = if (icon) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
242 )
243 Text(
244 label,
245 style = MaterialTheme.typography.bodyLarge,
246 maxLines = 1,
247 overflow = TextOverflow.Ellipsis,
248 modifier = Modifier.weight(1f).padding(start = 8.dp),
249 )
250 if (status != null) StatusBadge(status)
251 }
254@Composable
255private fun StatusBadge(status: FileStatusEntry) {
256 val (letter, color, desc) = when (status.status) {
257 "added" -> Triple("A", MaterialTheme.colorScheme.primary, stringResource(R.string.git_added))
258 "modified" -> Triple("M", MaterialTheme.colorScheme.tertiary, stringResource(R.string.git_modified))
259 "deleted" -> Triple("D", MaterialTheme.colorScheme.error, stringResource(R.string.git_deleted))
260 else -> Triple("·", MaterialTheme.colorScheme.onSurfaceVariant, "")
261 }
262 Row(
263 verticalAlignment = Alignment.CenterVertically,
264 modifier = Modifier.semantics(mergeDescendants = true) {
265 if (desc.isNotEmpty()) contentDescription = desc
266 },
267 ) {
268 Text(
269 letter,
270 style = MaterialTheme.typography.labelMedium,
271 fontFamily = FontFamily.Monospace,
272 color = color,
273 )
274 if (status.added > 0 || status.removed > 0) {
275 Text(
276 "+${status.added} −${status.removed}",
277 style = MaterialTheme.typography.labelSmall,
278 fontFamily = FontFamily.Monospace,
279 color = MaterialTheme.colorScheme.onSurfaceVariant,
280 modifier = Modifier.padding(start = 4.dp),
281 )
282 }
283 }