git.iko.soy/opencode-android
on e9b2cc3 help sign in
192 lines 7.5 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.material.icons.Icons
16import androidx.compose.material.icons.automirrored.filled.ArrowBack
17import androidx.compose.material.icons.filled.ChevronRight
18import androidx.compose.material.icons.filled.Description
19import androidx.compose.material.icons.filled.Folder
20import androidx.compose.material.icons.filled.Home
21import androidx.compose.material.icons.filled.Search
22import androidx.compose.material3.CircularProgressIndicator
23import androidx.compose.material3.ExperimentalMaterial3Api
24import androidx.compose.material3.HorizontalDivider
25import androidx.compose.material3.Icon
26import androidx.compose.material3.IconButton
27import androidx.compose.material3.MaterialTheme
28import androidx.compose.material3.OutlinedTextField
29import androidx.compose.material3.Scaffold
30import androidx.compose.material3.Text
31import androidx.compose.material3.TopAppBar
32import androidx.compose.runtime.Composable
33import androidx.compose.runtime.getValue
34import androidx.compose.ui.Alignment
35import androidx.compose.ui.Modifier
36import androidx.compose.ui.text.style.TextOverflow
37import androidx.compose.ui.unit.dp
38import androidx.lifecycle.compose.collectAsStateWithLifecycle
39import androidx.lifecycle.viewmodel.compose.viewModel
40import soy.iko.opencode.di.AppContainer
41import soy.iko.opencode.ui.vmFactory
43@OptIn(ExperimentalMaterial3Api::class)
44@Composable
45fun FileBrowserScreen(
46 container: AppContainer,
47 onOpenFile: (String) -> Unit,
48 onBack: () -> Unit,
49) {
50 val vm: FileBrowserViewModel = viewModel(factory = vmFactory { FileBrowserViewModel(container) })
51 val state by vm.state.collectAsStateWithLifecycle()
53 Scaffold(
54 topBar = {
55 TopAppBar(
56 title = { Text("Files", maxLines = 1, overflow = TextOverflow.Ellipsis) },
57 navigationIcon = {
58 IconButton(onClick = onBack) {
59 Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
60 }
61 },
62 )
63 },
64 ) { padding ->
65 Column(modifier = Modifier.fillMaxSize().padding(padding)) {
66 // Tappable breadcrumb trail so deep paths are navigable.
67 Breadcrumbs(
68 path = state.path,
69 onNavigate = vm::open,
70 modifier = Modifier.fillMaxWidth(),
71 )
72 OutlinedTextField(
73 value = state.query,
74 onValueChange = vm::setQuery,
75 modifier = Modifier.fillMaxWidth().padding(12.dp),
76 placeholder = { Text("Search files…") },
77 leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
78 singleLine = true,
79 )
81 Box(modifier = Modifier.fillMaxSize()) {
82 when {
83 state.loading || state.searching -> CircularProgressIndicator(Modifier.align(Alignment.Center))
84 state.error != null && !state.isSearching -> Text(
85 state.error!!,
86 modifier = Modifier.align(Alignment.Center).padding(24.dp),
87 color = MaterialTheme.colorScheme.error,
88 )
89 state.isSearching -> SearchResults(state.results, onOpenFile)
90 else -> DirectoryListing(
91 state = state,
92 onOpenDir = vm::open,
93 onUp = vm::up,
94 onOpenFile = onOpenFile,
95 )
96 }
97 }
98 }
99 }
102@Composable
103private fun Breadcrumbs(path: String, onNavigate: (String) -> Unit, modifier: Modifier = Modifier) {
104 val segments = if (path.isBlank()) emptyList() else path.trim('/').split('/').filter { it.isNotEmpty() }
105 Row(
106 modifier = modifier
107 .horizontalScroll(rememberScrollState())
108 .padding(horizontal = 16.dp, vertical = 8.dp),
109 verticalAlignment = Alignment.CenterVertically,
110 ) {
111 IconButton(onClick = { onNavigate("") }, modifier = Modifier.size(20.dp)) {
112 Icon(Icons.Filled.Home, contentDescription = "Root", modifier = Modifier.size(18.dp))
113 }
114 var acc = ""
115 segments.forEachIndexed { index, segment ->
116 acc = if (acc.isEmpty()) segment else "$acc/$segment"
117 Icon(
118 Icons.Filled.ChevronRight,
119 contentDescription = null,
120 modifier = Modifier.size(16.dp),
121 tint = MaterialTheme.colorScheme.onSurfaceVariant,
122 )
123 val target = acc
124 Text(
125 segment,
126 style = MaterialTheme.typography.bodyMedium,
127 color = if (index == segments.lastIndex) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.primary,
128 modifier = Modifier.clickable { onNavigate(target) }.padding(vertical = 2.dp),
129 )
130 }
131 }
134@Composable
135private fun SearchResults(results: List<String>, onOpenFile: (String) -> Unit) {
136 if (results.isEmpty()) {
137 Text("No matches.", modifier = Modifier.padding(24.dp))
138 return
139 }
140 LazyColumn(modifier = Modifier.fillMaxSize()) {
141 items(results, key = { it }) { path ->
142 FileRow(icon = false, label = path, onClick = { onOpenFile(path) })
143 HorizontalDivider()
144 }
145 }
148@Composable
149private fun DirectoryListing(
150 state: FileBrowserState,
151 onOpenDir: (String) -> Unit,
152 onUp: () -> Unit,
153 onOpenFile: (String) -> Unit,
154) {
155 LazyColumn(modifier = Modifier.fillMaxSize()) {
156 if (state.path.isNotBlank()) {
157 item(key = "__up") {
158 FileRow(icon = true, label = "..", onClick = onUp)
159 HorizontalDivider()
160 }
161 }
162 items(state.entries, key = { it.path }) { node ->
163 FileRow(
164 icon = node.isDirectory,
165 label = node.name,
166 onClick = { if (node.isDirectory) onOpenDir(node.path) else onOpenFile(node.path) },
167 )
168 HorizontalDivider()
169 }
170 }
173@Composable
174private fun FileRow(icon: Boolean, label: String, onClick: () -> Unit) {
175 Row(
176 modifier = Modifier.fillMaxWidth().clickable(onClick = onClick).padding(horizontal = 16.dp, vertical = 14.dp),
177 verticalAlignment = Alignment.CenterVertically,
178 ) {
179 Icon(
180 if (icon) Icons.Filled.Folder else Icons.Filled.Description,
181 contentDescription = null,
182 modifier = Modifier.size(20.dp),
183 tint = if (icon) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
184 )
185 Text(
186 " $label",
187 style = MaterialTheme.typography.bodyLarge,
188 maxLines = 1,
189 overflow = TextOverflow.Ellipsis,
190 )
191 }