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