@@ -7,23 +7,36 @@ import androidx.compose.animation.core.tween
7import androidx.compose.animation.fadeIn
7import androidx.compose.animation.fadeIn
8import androidx.compose.animation.fadeOut
8import androidx.compose.animation.fadeOut
9import androidx.compose.animation.togetherWith
9import androidx.compose.animation.togetherWith
10+import androidx.compose.foundation.background
10import androidx.compose.foundation.horizontalScroll
11import androidx.compose.foundation.horizontalScroll
11import androidx.compose.foundation.layout.Box
12import androidx.compose.foundation.layout.Box
13+import androidx.compose.foundation.layout.BoxScope
12import androidx.compose.foundation.layout.Column
14import androidx.compose.foundation.layout.Column
15+import androidx.compose.foundation.layout.Arrangement
13import androidx.compose.foundation.layout.Row
16import androidx.compose.foundation.layout.Row
14import androidx.compose.foundation.layout.Spacer
17import androidx.compose.foundation.layout.Spacer
15import androidx.compose.foundation.layout.fillMaxSize
18import androidx.compose.foundation.layout.fillMaxSize
19+import androidx.compose.foundation.layout.fillMaxWidth
16import androidx.compose.foundation.layout.padding
20import androidx.compose.foundation.layout.padding
17import androidx.compose.foundation.layout.size
21import androidx.compose.foundation.layout.size
18import androidx.compose.foundation.layout.width
22import androidx.compose.foundation.layout.width
19import androidx.compose.foundation.lazy.LazyColumn
23import androidx.compose.foundation.lazy.LazyColumn
24+import androidx.compose.foundation.lazy.LazyListState
20import androidx.compose.foundation.lazy.itemsIndexed
25import androidx.compose.foundation.lazy.itemsIndexed
26+import androidx.compose.foundation.lazy.rememberLazyListState
21import androidx.compose.foundation.rememberScrollState
27import androidx.compose.foundation.rememberScrollState
28+import androidx.compose.foundation.text.KeyboardActions
29+import androidx.compose.foundation.text.KeyboardOptions
22import androidx.compose.foundation.verticalScroll
30import androidx.compose.foundation.verticalScroll
23import androidx.compose.material.icons.Icons
31import androidx.compose.material.icons.Icons
24import androidx.compose.material.icons.automirrored.filled.ArrowBack
32import androidx.compose.material.icons.automirrored.filled.ArrowBack
33+import androidx.compose.material.icons.automirrored.filled.WrapText
34+import androidx.compose.material.icons.filled.Close
25import androidx.compose.material.icons.filled.ContentCopy
35import androidx.compose.material.icons.filled.ContentCopy
36+import androidx.compose.material.icons.filled.KeyboardArrowDown
37+import androidx.compose.material.icons.filled.KeyboardArrowUp
26import androidx.compose.material.icons.filled.Refresh
38import androidx.compose.material.icons.filled.Refresh
39+import androidx.compose.material.icons.filled.Search
27import androidx.compose.material.icons.filled.Share
40import androidx.compose.material.icons.filled.Share
28import androidx.compose.material3.CircularProgressIndicator
41import androidx.compose.material3.CircularProgressIndicator
29import androidx.compose.material3.ExperimentalMaterial3Api
42import androidx.compose.material3.ExperimentalMaterial3Api
@@ -31,12 +44,15 @@ import androidx.compose.material3.FilterChip
31import androidx.compose.material3.Icon
44import androidx.compose.material3.Icon
32import androidx.compose.material3.IconButton
45import androidx.compose.material3.IconButton
33import androidx.compose.material3.MaterialTheme
46import androidx.compose.material3.MaterialTheme
47+import androidx.compose.material3.OutlinedTextField
34import androidx.compose.material3.Scaffold
48import androidx.compose.material3.Scaffold
35import androidx.compose.material3.Text
49import androidx.compose.material3.Text
36import androidx.compose.material3.TextButton
50import androidx.compose.material3.TextButton
37import androidx.compose.material3.TopAppBar
51import androidx.compose.material3.TopAppBar
38import androidx.compose.runtime.Composable
52import androidx.compose.runtime.Composable
53+import androidx.compose.runtime.LaunchedEffect
39import androidx.compose.runtime.getValue
54import androidx.compose.runtime.getValue
55+import androidx.compose.runtime.mutableIntStateOf
40import androidx.compose.runtime.mutableStateOf
56import androidx.compose.runtime.mutableStateOf
41import androidx.compose.runtime.remember
57import androidx.compose.runtime.remember
42import androidx.compose.runtime.saveable.rememberSaveable
58import androidx.compose.runtime.saveable.rememberSaveable
@@ -47,6 +63,7 @@ import androidx.compose.ui.platform.LocalContext
47import androidx.compose.ui.semantics.contentDescription
63import androidx.compose.ui.semantics.contentDescription
48import androidx.compose.ui.semantics.semantics
64import androidx.compose.ui.semantics.semantics
49import androidx.compose.ui.text.font.FontFamily
65import androidx.compose.ui.text.font.FontFamily
66+import androidx.compose.ui.text.input.ImeAction
50import androidx.compose.ui.text.style.TextOverflow
67import androidx.compose.ui.text.style.TextOverflow
51import androidx.compose.ui.unit.dp
68import androidx.compose.ui.unit.dp
52import androidx.compose.ui.res.stringResource
69import androidx.compose.ui.res.stringResource
@@ -71,167 +88,374 @@ fun FileViewScreen(
71 val vm: FileViewModel = viewModel(factory = vmFactory { FileViewModel(container, path) })
88 val vm: FileViewModel = viewModel(factory = vmFactory { FileViewModel(container, path) })
72 val state by vm.state.collectAsStateWithLifecycle()
89 val state by vm.state.collectAsStateWithLifecycle()
73 val context = LocalContext.current
90 val context = LocalContext.current
74− // Trim a trailing slash so "dir/" doesn't yield an empty filename; fall back to the
75− // raw path if there's no basename at all (e.g. the root "/").
76 val filename = remember(path) {
91 val filename = remember(path) {
77 val trimmed = path.trimEnd('/')
92 val trimmed = path.trimEnd('/')
78 trimmed.substringAfterLast('/').ifBlank { path.ifBlank { "/" } }
93 trimmed.substringAfterLast('/').ifBlank { path.ifBlank { "/" } }
80 val shareLabel = stringResource(R.string.share)
95 val shareLabel = stringResource(R.string.share)
81− // Diff vs raw view toggle. Defaults to showing the diff when one exists (the common
96+ // Diff vs raw view toggle. Defaults to showing the diff when one exists; the user can
82− // case a developer opens a file for); the user can switch to the raw new content to
83− // see the full file without +/- markers. Persisted across recomposition via
84− // rememberSaveable so a rotation doesn't reset the user's choice.
97+ // switch to the raw new content. Persisted via rememberSaveable across rotation.
85 val hasDiff = state.content?.diff != null && state.content?.diff?.isNotBlank() == true
98 val hasDiff = state.content?.diff != null && state.content?.diff?.isNotBlank() == true
86 var showDiff by rememberSaveable(path) { mutableStateOf(true) }
99 var showDiff by rememberSaveable(path) { mutableStateOf(true) }
87 val showToggle = hasDiff && state.content?.content.orEmpty().isNotEmpty()
100 val showToggle = hasDiff && state.content?.content.orEmpty().isNotEmpty()
101+ // Find-in-file and line-wrapping state, persisted so a rotation or reload keeps the
102+ // user's query/mode.
103+ var findActive by rememberSaveable(path) { mutableStateOf(false) }
104+ var findQuery by rememberSaveable(path) { mutableStateOf("") }
105+ var matchPos by rememberSaveable(path) { mutableIntStateOf(0) }
106+ var wrap by rememberSaveable(path) { mutableStateOf(false) }
107+ val listState = rememberLazyListState()
108+ val rawText = state.content?.content.orEmpty()
109+ val lines = remember(rawText) { rawText.split("\n") }
110+ val matchIndices = remember(lines, findQuery) {
111+ val q = findQuery.trim()
112+ if (q.isEmpty()) emptyList()
113+ else lines.mapIndexed { index, line -> if (line.contains(q, ignoreCase = true)) index else -1 }
116+ // Scroll to the current match whenever it (or the match set) changes.
117+ LaunchedEffect(matchPos, matchIndices) {
118+ matchIndices.getOrNull(matchPos)?.let { idx -> runCatchingCancellable { listState.animateScrollToItem(idx) } }
89 Scaffold(
121 Scaffold(
90 topBar = {
122 topBar = {
91− TopAppBar(
123+ FileViewTopBar(
92− title = { Text(filename, maxLines = 1, overflow = TextOverflow.Ellipsis) },
124+ filename = filename,
93− navigationIcon = {
125+ loading = state.loading,
94− IconButton(onClick = onBack) {
126+ content = rawText,
95− Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
127+ wrap = wrap,
96− }
128+ onBack = onBack,
97− },
129+ onReload = { vm.reload() },
98− actions = {
130+ onToggleFind = { findActive = !findActive; if (!findActive) findQuery = "" },
99− // In-place refresh: a file's content can change on the server (the
131+ onToggleWrap = { wrap = !wrap },
100− // agent may be editing it), so reload without requiring a back-out.
101− // Disabled while loading to avoid stacking parallel fetches.
102− IconButton(onClick = { vm.reload() }, enabled = !state.loading) {
103− Icon(Icons.Filled.Refresh, contentDescription = stringResource(R.string.refresh))
105− val content = state.content?.content.orEmpty()
106− if (content.isNotEmpty()) {
107− IconButton(onClick = { copyToClipboard(context, filename, content) }) {
132+ onCopy = { copyToClipboard(context, filename, rawText) },
108− Icon(Icons.Filled.ContentCopy, contentDescription = stringResource(R.string.copy))
133+ onShare = {
110− IconButton(onClick = {
111− val send = Intent(Intent.ACTION_SEND).apply {
134+ val send = Intent(Intent.ACTION_SEND).apply {
112− type = "text/plain"
135+ type = "text/plain"
113− putExtra(Intent.EXTRA_SUBJECT, filename)
136+ putExtra(Intent.EXTRA_SUBJECT, filename)
114− putExtra(Intent.EXTRA_TEXT, content)
137+ putExtra(Intent.EXTRA_TEXT, rawText)
116− runCatchingCancellable { context.startActivity(Intent.createChooser(send, shareLabel)) }
118− showToast(context, context.getString(R.string.no_share_app))
121− Icon(Icons.Filled.Share, contentDescription = stringResource(R.string.share))
139+ runCatchingCancellable { context.startActivity(Intent.createChooser(send, shareLabel)) }
140+ .onFailure { showToast(context, context.getString(R.string.no_share_app)) }
127 ) { padding ->
144 ) { padding ->
128 Box(modifier = Modifier.fillMaxSize().padding(padding)) {
145 Box(modifier = Modifier.fillMaxSize().padding(padding)) {
129− // AnimatedContent cross-fades between the loading / error / content states
146+ // AnimatedContent cross-fades between loading / error / content states so the
130− // so the viewer doesn't snap abruptly when a reload finishes or fails. The
147+ // viewer doesn't snap abruptly when a reload finishes or fails.
131− // transition is short (180ms) and fade-only to avoid a slide that would
132− // disorient when the content is the same file just re-fetched.
133 AnimatedContent(
148 AnimatedContent(
134 targetState = state,
149 targetState = state,
135 transitionSpec = { fadeIn(tween(180)) togetherWith fadeOut(tween(180)) },
150 transitionSpec = { fadeIn(tween(180)) togetherWith fadeOut(tween(180)) },
136 label = "fileViewState",
151 label = "fileViewState",
138− when {
153+ FileViewStateContent(
139− s.loading -> {
154+ state = s,
140− val loadingLabel = stringResource(R.string.loading)
155+ filename = filename,
141− CircularProgressIndicator(
156+ showToggle = showToggle,
142− Modifier.align(Alignment.Center).semantics { contentDescription = loadingLabel },
157+ showDiff = showDiff,
143− )
158+ onSetShowDiff = { showDiff = it },
144− }
159+ findActive = findActive,
145− s.error != null -> Column(
160+ findQuery = findQuery,
146− modifier = Modifier.align(Alignment.Center).padding(24.dp),
161+ onQueryChange = { findQuery = it; matchPos = 0 },
147− horizontalAlignment = Alignment.CenterHorizontally,
162+ matchIndices = matchIndices,
148− ) {
163+ matchPos = matchPos,
149− Text(
164+ onPrev = { if (matchIndices.isNotEmpty()) matchPos = (matchPos - 1 + matchIndices.size) % matchIndices.size },
150− s.error ?: "",
165+ onNext = { if (matchIndices.isNotEmpty()) matchPos = (matchPos + 1) % matchIndices.size },
151− color = MaterialTheme.colorScheme.error,
166+ onCloseFind = { findActive = false; findQuery = "" },
153− Spacer(Modifier.size(12.dp))
168+ listState = listState,
154− TextButton(onClick = { vm.reload() }) {
169+ onRetry = { vm.reload() },
155− Text(stringResource(R.string.retry))
170+ )
176+@OptIn(ExperimentalMaterial3Api::class)
178+private fun FileViewTopBar(
184+ onReload: () -> Unit,
185+ onToggleFind: () -> Unit,
186+ onToggleWrap: () -> Unit,
188+ onShare: () -> Unit,
191+ title = { Text(filename, maxLines = 1, overflow = TextOverflow.Ellipsis) },
193+ IconButton(onClick = onBack) {
194+ Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
158− s.content?.isBinary == true -> Text(
197+ actions = {
198+ // In-place refresh: a file's content can change on the server (the agent may
199+ // be editing it), so reload without requiring a back-out.
200+ IconButton(onClick = onReload, enabled = !loading) {
201+ Icon(Icons.Filled.Refresh, contentDescription = stringResource(R.string.refresh))
203+ if (content.isNotEmpty()) {
204+ // Find-in-file: toggles a search bar over the raw content.
205+ IconButton(onClick = onToggleFind) {
159− stringResource(R.string.binary_file),
206+ Icon(Icons.Filled.Search, contentDescription = stringResource(R.string.find_in_file))
160− modifier = Modifier.align(Alignment.Center).padding(24.dp),
207+ }
208+ // Wrap long lines instead of horizontal-scrolling, useful for prose.
209+ IconButton(onClick = onToggleWrap) {
211+ Icons.AutoMirrored.Filled.WrapText,
212+ contentDescription = stringResource(R.string.wrap_lines),
161− color = MaterialTheme.colorScheme.onSurfaceVariant,
213+ tint = if (wrap) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
164− val content = s.content
216+ IconButton(onClick = onCopy) {
165− // Diff/raw toggle: when both a diff and raw content are present,
217+ Icon(Icons.Filled.ContentCopy, contentDescription = stringResource(R.string.copy))
166− // let the user switch. The chip row sits above the content so
218+ }
167− // it's always reachable while scrolling.
219+ IconButton(onClick = onShare) {
171− .align(Alignment.TopCenter)
172− .padding(top = 4.dp),
173− horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(8.dp),
176− selected = showDiff,
177− onClick = { showDiff = true },
178− label = { Text(stringResource(R.string.show_diff)) },
181− selected = !showDiff,
182− onClick = { showDiff = false },
183− label = { Text(stringResource(R.string.show_raw)) },
187− if (showDiff && content?.diff != null && content.diff.isNotBlank()) {
189− diff = content.diff,
192− .padding(top = if (showToggle) 48.dp else 0.dp)
193− .verticalScroll(rememberScrollState())
197− val text = content?.content.orEmpty()
198− if (text.isEmpty()) {
200− stringResource(R.string.empty_file),
220+ Icon(Icons.Filled.Share, contentDescription = stringResource(R.string.share))
201− modifier = Modifier.align(Alignment.Center).padding(24.dp),
202− color = MaterialTheme.colorScheme.onSurfaceVariant,
207− filename = filename,
208− topInset = if (showToggle) 48.dp else 0.dp,
228+private fun BoxScope.FileViewStateContent(
229+ state: FileViewState,
231+ showToggle: Boolean,
233+ onSetShowDiff: (Boolean) -> Unit,
234+ findActive: Boolean,
236+ onQueryChange: (String) -> Unit,
237+ matchIndices: List<Int>,
241+ onCloseFind: () -> Unit,
243+ listState: LazyListState,
244+ onRetry: () -> Unit,
248+ val loadingLabel = stringResource(R.string.loading)
249+ CircularProgressIndicator(
250+ Modifier.align(Alignment.Center).semantics { contentDescription = loadingLabel },
253+ state.error != null -> Column(
254+ modifier = Modifier.align(Alignment.Center).padding(24.dp),
255+ horizontalAlignment = Alignment.CenterHorizontally,
257+ Text(state.error ?: "", color = MaterialTheme.colorScheme.error)
258+ Spacer(Modifier.size(12.dp))
259+ TextButton(onClick = onRetry) { Text(stringResource(R.string.retry)) }
261+ state.content?.isBinary == true -> Text(
262+ stringResource(R.string.binary_file),
263+ modifier = Modifier.align(Alignment.Center).padding(24.dp),
264+ color = MaterialTheme.colorScheme.onSurfaceVariant,
266+ else -> FileViewContentBody(
267+ content = state.content,
268+ filename = filename,
269+ showToggle = showToggle,
270+ showDiff = showDiff,
271+ onSetShowDiff = onSetShowDiff,
272+ findActive = findActive,
273+ findQuery = findQuery,
274+ onQueryChange = onQueryChange,
275+ matchIndices = matchIndices,
276+ matchPos = matchPos,
279+ onCloseFind = onCloseFind,
281+ listState = listState,
287+private fun BoxScope.FileViewContentBody(
288+ content: soy.iko.opencode.data.model.FileContent?,
290+ showToggle: Boolean,
292+ onSetShowDiff: (Boolean) -> Unit,
293+ findActive: Boolean,
295+ onQueryChange: (String) -> Unit,
296+ matchIndices: List<Int>,
300+ onCloseFind: () -> Unit,
302+ listState: LazyListState,
304+ // Overlay bar: stacks the diff/raw chips and the find bar at the top so both stay
305+ // reachable while scrolling. Each contributes a top inset so the content below
306+ // isn't hidden behind the overlay.
307+ val overlayRows = (if (showToggle) 1 else 0) + (if (findActive) 1 else 0)
308+ val topInset = (overlayRows * 52).dp
309+ if (overlayRows > 0) {
312+ .align(Alignment.TopCenter)
314+ .padding(top = 4.dp),
315+ verticalArrangement = Arrangement.spacedBy(4.dp),
319+ modifier = Modifier.fillMaxWidth(),
320+ horizontalArrangement = Arrangement.Center,
323+ selected = showDiff,
324+ onClick = { onSetShowDiff(true) },
325+ label = { Text(stringResource(R.string.show_diff)) },
327+ Spacer(Modifier.size(8.dp))
329+ selected = !showDiff,
330+ onClick = { onSetShowDiff(false) },
331+ label = { Text(stringResource(R.string.show_raw)) },
338+ onQueryChange = onQueryChange,
339+ matchCount = matchIndices.size,
340+ matchPos = matchPos,
343+ onClose = onCloseFind,
348+ if (showDiff && content?.diff != null && content.diff.isNotBlank()) {
350+ diff = content.diff,
353+ .padding(top = topInset)
354+ .verticalScroll(rememberScrollState())
358+ val text = content?.content.orEmpty()
359+ if (text.isEmpty()) {
361+ stringResource(R.string.empty_file),
362+ modifier = Modifier.align(Alignment.Center).padding(24.dp),
363+ color = MaterialTheme.colorScheme.onSurfaceVariant,
368+ filename = filename,
369+ topInset = topInset,
371+ findQuery = findQuery,
372+ matchIndices = matchIndices,
373+ listState = listState,
219@Composable
379@Composable
382+ onQueryChange: (String) -> Unit,
387+ onClose: () -> Unit,
390+ modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp),
391+ verticalAlignment = Alignment.CenterVertically,
395+ onValueChange = onQueryChange,
396+ modifier = Modifier.weight(1f),
397+ placeholder = { Text(stringResource(R.string.find_hint)) },
399+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
400+ keyboardActions = KeyboardActions(onSearch = { onQueryChange(query) }),
402+ val countText = if (query.isBlank()) ""
403+ else if (matchCount == 0) stringResource(R.string.no_matches_in_file)
404+ else stringResource(R.string.match_count, matchPos + 1, matchCount)
405+ if (countText.isNotEmpty()) {
408+ style = MaterialTheme.typography.labelSmall,
409+ color = MaterialTheme.colorScheme.onSurfaceVariant,
414+ IconButton(onClick = onPrev, enabled = matchCount > 0) {
415+ Icon(Icons.Filled.KeyboardArrowUp, contentDescription = stringResource(R.string.find_in_file))
417+ IconButton(onClick = onNext, enabled = matchCount > 0) {
418+ Icon(Icons.Filled.KeyboardArrowDown, contentDescription = stringResource(R.string.find_in_file))
420+ IconButton(onClick = onClose) {
421+ Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.close))
427+private fun FileTextContent(
220−private fun FileTextContent(text: String, filename: String, topInset: androidx.compose.ui.unit.Dp) {
430+ topInset: androidx.compose.ui.unit.Dp,
433+ matchIndices: List<Int>,
434+ listState: LazyListState,
221 val lines = remember(text) { text.split("\n") }
436 val lines = remember(text) { text.split("\n") }
222 val gutterWidth = remember(lines.size) {
437 val gutterWidth = remember(lines.size) {
223 (lines.size.toString().length.coerceAtLeast(3) * 10).dp
438 (lines.size.toString().length.coerceAtLeast(3) * 10).dp
225 val hScrollState = rememberScrollState()
440 val hScrollState = rememberScrollState()
226 val palette = rememberHighlightPalette()
441 val palette = rememberHighlightPalette()
442+ val q = findQuery.trim()
443+ val matchSet = remember(matchIndices) { matchIndices.toHashSet() }
227 LazyColumn(
444 LazyColumn(
228 modifier = Modifier
446 modifier = Modifier
229 .fillMaxSize()
447 .fillMaxSize()
230 .padding(top = topInset)
448 .padding(top = topInset)
231 .padding(8.dp),
449 .padding(8.dp),
233 itemsIndexed(lines, key = { index, _ -> index }) { index, line ->
451 itemsIndexed(lines, key = { index, _ -> index }) { index, line ->
452+ val isMatch = q.isNotEmpty() && index in matchSet
234− Row(modifier = Modifier.horizontalScroll(hScrollState)) {
456+ .then(if (wrap) Modifier else Modifier.horizontalScroll(hScrollState))
457+ .then(if (isMatch) Modifier.background(MaterialTheme.colorScheme.secondaryContainer) else Modifier),
236 "${index + 1}",
460 "${index + 1}",
237 modifier = Modifier.width(gutterWidth),
461 modifier = Modifier.width(gutterWidth),