@@ -7,23 +7,36 @@ import androidx.compose.animation.core.tween
77import androidx.compose.animation.fadeIn
88import androidx.compose.animation.fadeOut
99import androidx.compose.animation.togetherWith
10+import androidx.compose.foundation.background
1011import androidx.compose.foundation.horizontalScroll
1112import androidx.compose.foundation.layout.Box
13+import androidx.compose.foundation.layout.BoxScope
1214import androidx.compose.foundation.layout.Column
15+import androidx.compose.foundation.layout.Arrangement
1316import androidx.compose.foundation.layout.Row
1417import androidx.compose.foundation.layout.Spacer
1518import androidx.compose.foundation.layout.fillMaxSize
19+import androidx.compose.foundation.layout.fillMaxWidth
1620import androidx.compose.foundation.layout.padding
1721import androidx.compose.foundation.layout.size
1822import androidx.compose.foundation.layout.width
1923import androidx.compose.foundation.lazy.LazyColumn
24+import androidx.compose.foundation.lazy.LazyListState
2025import androidx.compose.foundation.lazy.itemsIndexed
26+import androidx.compose.foundation.lazy.rememberLazyListState
2127import androidx.compose.foundation.rememberScrollState
28+import androidx.compose.foundation.text.KeyboardActions
29+import androidx.compose.foundation.text.KeyboardOptions
2230import androidx.compose.foundation.verticalScroll
2331import androidx.compose.material.icons.Icons
2432import androidx.compose.material.icons.automirrored.filled.ArrowBack
33+import androidx.compose.material.icons.automirrored.filled.WrapText
34+import androidx.compose.material.icons.filled.Close
2535import androidx.compose.material.icons.filled.ContentCopy
36+import androidx.compose.material.icons.filled.KeyboardArrowDown
37+import androidx.compose.material.icons.filled.KeyboardArrowUp
2638import androidx.compose.material.icons.filled.Refresh
39+import androidx.compose.material.icons.filled.Search
2740import androidx.compose.material.icons.filled.Share
2841import androidx.compose.material3.CircularProgressIndicator
2942import androidx.compose.material3.ExperimentalMaterial3Api
@@ -31,12 +44,15 @@ import androidx.compose.material3.FilterChip
3144import androidx.compose.material3.Icon
3245import androidx.compose.material3.IconButton
3346import androidx.compose.material3.MaterialTheme
47+import androidx.compose.material3.OutlinedTextField
3448import androidx.compose.material3.Scaffold
3549import androidx.compose.material3.Text
3650import androidx.compose.material3.TextButton
3751import androidx.compose.material3.TopAppBar
3852import androidx.compose.runtime.Composable
53+import androidx.compose.runtime.LaunchedEffect
3954import androidx.compose.runtime.getValue
55+import androidx.compose.runtime.mutableIntStateOf
4056import androidx.compose.runtime.mutableStateOf
4157import androidx.compose.runtime.remember
4258import androidx.compose.runtime.saveable.rememberSaveable
@@ -47,6 +63,7 @@ import androidx.compose.ui.platform.LocalContext
4763import androidx.compose.ui.semantics.contentDescription
4864import androidx.compose.ui.semantics.semantics
4965import androidx.compose.ui.text.font.FontFamily
66+import androidx.compose.ui.text.input.ImeAction
5067import androidx.compose.ui.text.style.TextOverflow
5168import androidx.compose.ui.unit.dp
5269import androidx.compose.ui.res.stringResource
@@ -71,167 +88,374 @@ fun FileViewScreen(
7188 val vm: FileViewModel = viewModel(factory = vmFactory { FileViewModel(container, path) })
7289 val state by vm.state.collectAsStateWithLifecycle()
7390 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 "/").
7691 val filename = remember(path) {
7792 val trimmed = path.trimEnd('/')
7893 trimmed.substringAfterLast('/').ifBlank { path.ifBlank { "/" } }
7994 }
8095 val shareLabel = stringResource(R.string.share)
81− // Diff vs raw view toggle. Defaults to showing the diff when one exists (the common
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.
96+ // Diff vs raw view toggle. Defaults to showing the diff when one exists; the user can
97+ // switch to the raw new content. Persisted via rememberSaveable across rotation.
8598 val hasDiff = state.content?.diff != null && state.content?.diff?.isNotBlank() == true
8699 var showDiff by rememberSaveable(path) { mutableStateOf(true) }
87100 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 }
114+ .filter { it >= 0 }
115+ }
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) } }
119+ }
88120
89121 Scaffold(
90122 topBar = {
91− TopAppBar(
92− title = { Text(filename, maxLines = 1, overflow = TextOverflow.Ellipsis) },
93− navigationIcon = {
94− IconButton(onClick = onBack) {
95− Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
96− }
97− },
98− actions = {
99− // In-place refresh: a file's content can change on the server (the
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))
104− }
105− val content = state.content?.content.orEmpty()
106− if (content.isNotEmpty()) {
107− IconButton(onClick = { copyToClipboard(context, filename, content) }) {
108− Icon(Icons.Filled.ContentCopy, contentDescription = stringResource(R.string.copy))
109− }
110− IconButton(onClick = {
111− val send = Intent(Intent.ACTION_SEND).apply {
112− type = "text/plain"
113− putExtra(Intent.EXTRA_SUBJECT, filename)
114− putExtra(Intent.EXTRA_TEXT, content)
115− }
116− runCatchingCancellable { context.startActivity(Intent.createChooser(send, shareLabel)) }
117− .onFailure {
118− showToast(context, context.getString(R.string.no_share_app))
119− }
120− }) {
121− Icon(Icons.Filled.Share, contentDescription = stringResource(R.string.share))
122− }
123+ FileViewTopBar(
124+ filename = filename,
125+ loading = state.loading,
126+ content = rawText,
127+ wrap = wrap,
128+ onBack = onBack,
129+ onReload = { vm.reload() },
130+ onToggleFind = { findActive = !findActive; if (!findActive) findQuery = "" },
131+ onToggleWrap = { wrap = !wrap },
132+ onCopy = { copyToClipboard(context, filename, rawText) },
133+ onShare = {
134+ val send = Intent(Intent.ACTION_SEND).apply {
135+ type = "text/plain"
136+ putExtra(Intent.EXTRA_SUBJECT, filename)
137+ putExtra(Intent.EXTRA_TEXT, rawText)
123138 }
139+ runCatchingCancellable { context.startActivity(Intent.createChooser(send, shareLabel)) }
140+ .onFailure { showToast(context, context.getString(R.string.no_share_app)) }
124141 },
125142 )
126143 },
127144 ) { padding ->
128145 Box(modifier = Modifier.fillMaxSize().padding(padding)) {
129− // AnimatedContent cross-fades between the loading / error / content states
130− // so the viewer doesn't snap abruptly when a reload finishes or fails. The
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.
146+ // AnimatedContent cross-fades between loading / error / content states so the
147+ // viewer doesn't snap abruptly when a reload finishes or fails.
133148 AnimatedContent(
134149 targetState = state,
135150 transitionSpec = { fadeIn(tween(180)) togetherWith fadeOut(tween(180)) },
136151 label = "fileViewState",
137152 ) { s ->
138− when {
139− s.loading -> {
140− val loadingLabel = stringResource(R.string.loading)
141− CircularProgressIndicator(
142− Modifier.align(Alignment.Center).semantics { contentDescription = loadingLabel },
143− )
144− }
145− s.error != null -> Column(
146− modifier = Modifier.align(Alignment.Center).padding(24.dp),
147− horizontalAlignment = Alignment.CenterHorizontally,
148− ) {
149− Text(
150− s.error ?: "",
151− color = MaterialTheme.colorScheme.error,
152− )
153− Spacer(Modifier.size(12.dp))
154− TextButton(onClick = { vm.reload() }) {
155− Text(stringResource(R.string.retry))
156− }
157− }
158− s.content?.isBinary == true -> Text(
159− stringResource(R.string.binary_file),
160− modifier = Modifier.align(Alignment.Center).padding(24.dp),
161− color = MaterialTheme.colorScheme.onSurfaceVariant,
153+ FileViewStateContent(
154+ state = s,
155+ filename = filename,
156+ showToggle = showToggle,
157+ showDiff = showDiff,
158+ onSetShowDiff = { showDiff = it },
159+ findActive = findActive,
160+ findQuery = findQuery,
161+ onQueryChange = { findQuery = it; matchPos = 0 },
162+ matchIndices = matchIndices,
163+ matchPos = matchPos,
164+ onPrev = { if (matchIndices.isNotEmpty()) matchPos = (matchPos - 1 + matchIndices.size) % matchIndices.size },
165+ onNext = { if (matchIndices.isNotEmpty()) matchPos = (matchPos + 1) % matchIndices.size },
166+ onCloseFind = { findActive = false; findQuery = "" },
167+ wrap = wrap,
168+ listState = listState,
169+ onRetry = { vm.reload() },
170+ )
171+ }
172+ }
173+ }
174+}
175+
176+@OptIn(ExperimentalMaterial3Api::class)
177+@Composable
178+private fun FileViewTopBar(
179+ filename: String,
180+ loading: Boolean,
181+ content: String,
182+ wrap: Boolean,
183+ onBack: () -> Unit,
184+ onReload: () -> Unit,
185+ onToggleFind: () -> Unit,
186+ onToggleWrap: () -> Unit,
187+ onCopy: () -> Unit,
188+ onShare: () -> Unit,
189+) {
190+ TopAppBar(
191+ title = { Text(filename, maxLines = 1, overflow = TextOverflow.Ellipsis) },
192+ navigationIcon = {
193+ IconButton(onClick = onBack) {
194+ Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
195+ }
196+ },
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))
202+ }
203+ if (content.isNotEmpty()) {
204+ // Find-in-file: toggles a search bar over the raw content.
205+ IconButton(onClick = onToggleFind) {
206+ Icon(Icons.Filled.Search, contentDescription = stringResource(R.string.find_in_file))
207+ }
208+ // Wrap long lines instead of horizontal-scrolling, useful for prose.
209+ IconButton(onClick = onToggleWrap) {
210+ Icon(
211+ Icons.AutoMirrored.Filled.WrapText,
212+ contentDescription = stringResource(R.string.wrap_lines),
213+ tint = if (wrap) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
162214 )
163− else -> {
164− val content = s.content
165− // Diff/raw toggle: when both a diff and raw content are present,
166− // let the user switch. The chip row sits above the content so
167− // it's always reachable while scrolling.
168− if (showToggle) {
169− Row(
170− modifier = Modifier
171− .align(Alignment.TopCenter)
172− .padding(top = 4.dp),
173− horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(8.dp),
174− ) {
175− FilterChip(
176− selected = showDiff,
177− onClick = { showDiff = true },
178− label = { Text(stringResource(R.string.show_diff)) },
179− )
180− FilterChip(
181− selected = !showDiff,
182− onClick = { showDiff = false },
183− label = { Text(stringResource(R.string.show_raw)) },
184− )
185− }
186− }
187− if (showDiff && content?.diff != null && content.diff.isNotBlank()) {
188− DiffView(
189− diff = content.diff,
190− modifier = Modifier
191− .fillMaxSize()
192− .padding(top = if (showToggle) 48.dp else 0.dp)
193− .verticalScroll(rememberScrollState())
194− .padding(8.dp),
195− )
196− } else {
197− val text = content?.content.orEmpty()
198− if (text.isEmpty()) {
199− Text(
200− stringResource(R.string.empty_file),
201− modifier = Modifier.align(Alignment.Center).padding(24.dp),
202− color = MaterialTheme.colorScheme.onSurfaceVariant,
203− )
204− } else {
205− FileTextContent(
206− text = text,
207− filename = filename,
208− topInset = if (showToggle) 48.dp else 0.dp,
209− )
210− }
211− }
212− }
215+ }
216+ IconButton(onClick = onCopy) {
217+ Icon(Icons.Filled.ContentCopy, contentDescription = stringResource(R.string.copy))
218+ }
219+ IconButton(onClick = onShare) {
220+ Icon(Icons.Filled.Share, contentDescription = stringResource(R.string.share))
213221 }
214222 }
223+ },
224+ )
225+}
226+
227+@Composable
228+private fun BoxScope.FileViewStateContent(
229+ state: FileViewState,
230+ filename: String,
231+ showToggle: Boolean,
232+ showDiff: Boolean,
233+ onSetShowDiff: (Boolean) -> Unit,
234+ findActive: Boolean,
235+ findQuery: String,
236+ onQueryChange: (String) -> Unit,
237+ matchIndices: List<Int>,
238+ matchPos: Int,
239+ onPrev: () -> Unit,
240+ onNext: () -> Unit,
241+ onCloseFind: () -> Unit,
242+ wrap: Boolean,
243+ listState: LazyListState,
244+ onRetry: () -> Unit,
245+) {
246+ when {
247+ state.loading -> {
248+ val loadingLabel = stringResource(R.string.loading)
249+ CircularProgressIndicator(
250+ Modifier.align(Alignment.Center).semantics { contentDescription = loadingLabel },
251+ )
252+ }
253+ state.error != null -> Column(
254+ modifier = Modifier.align(Alignment.Center).padding(24.dp),
255+ horizontalAlignment = Alignment.CenterHorizontally,
256+ ) {
257+ Text(state.error ?: "", color = MaterialTheme.colorScheme.error)
258+ Spacer(Modifier.size(12.dp))
259+ TextButton(onClick = onRetry) { Text(stringResource(R.string.retry)) }
260+ }
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,
265+ )
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,
277+ onPrev = onPrev,
278+ onNext = onNext,
279+ onCloseFind = onCloseFind,
280+ wrap = wrap,
281+ listState = listState,
282+ )
283+ }
284+}
285+
286+@Composable
287+private fun BoxScope.FileViewContentBody(
288+ content: soy.iko.opencode.data.model.FileContent?,
289+ filename: String,
290+ showToggle: Boolean,
291+ showDiff: Boolean,
292+ onSetShowDiff: (Boolean) -> Unit,
293+ findActive: Boolean,
294+ findQuery: String,
295+ onQueryChange: (String) -> Unit,
296+ matchIndices: List<Int>,
297+ matchPos: Int,
298+ onPrev: () -> Unit,
299+ onNext: () -> Unit,
300+ onCloseFind: () -> Unit,
301+ wrap: Boolean,
302+ listState: LazyListState,
303+) {
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) {
310+ Column(
311+ modifier = Modifier
312+ .align(Alignment.TopCenter)
313+ .fillMaxWidth()
314+ .padding(top = 4.dp),
315+ verticalArrangement = Arrangement.spacedBy(4.dp),
316+ ) {
317+ if (showToggle) {
318+ Row(
319+ modifier = Modifier.fillMaxWidth(),
320+ horizontalArrangement = Arrangement.Center,
321+ ) {
322+ FilterChip(
323+ selected = showDiff,
324+ onClick = { onSetShowDiff(true) },
325+ label = { Text(stringResource(R.string.show_diff)) },
326+ )
327+ Spacer(Modifier.size(8.dp))
328+ FilterChip(
329+ selected = !showDiff,
330+ onClick = { onSetShowDiff(false) },
331+ label = { Text(stringResource(R.string.show_raw)) },
332+ )
333+ }
334+ }
335+ if (findActive) {
336+ FindBar(
337+ query = findQuery,
338+ onQueryChange = onQueryChange,
339+ matchCount = matchIndices.size,
340+ matchPos = matchPos,
341+ onPrev = onPrev,
342+ onNext = onNext,
343+ onClose = onCloseFind,
344+ )
345+ }
346+ }
347+ }
348+ if (showDiff && content?.diff != null && content.diff.isNotBlank()) {
349+ DiffView(
350+ diff = content.diff,
351+ modifier = Modifier
352+ .fillMaxSize()
353+ .padding(top = topInset)
354+ .verticalScroll(rememberScrollState())
355+ .padding(8.dp),
356+ )
357+ } else {
358+ val text = content?.content.orEmpty()
359+ if (text.isEmpty()) {
360+ Text(
361+ stringResource(R.string.empty_file),
362+ modifier = Modifier.align(Alignment.Center).padding(24.dp),
363+ color = MaterialTheme.colorScheme.onSurfaceVariant,
364+ )
365+ } else {
366+ FileTextContent(
367+ text = text,
368+ filename = filename,
369+ topInset = topInset,
370+ wrap = wrap,
371+ findQuery = findQuery,
372+ matchIndices = matchIndices,
373+ listState = listState,
374+ )
215375 }
216376 }
217377}
218378
219379@Composable
220−private fun FileTextContent(text: String, filename: String, topInset: androidx.compose.ui.unit.Dp) {
380+private fun FindBar(
381+ query: String,
382+ onQueryChange: (String) -> Unit,
383+ matchCount: Int,
384+ matchPos: Int,
385+ onPrev: () -> Unit,
386+ onNext: () -> Unit,
387+ onClose: () -> Unit,
388+) {
389+ Row(
390+ modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp),
391+ verticalAlignment = Alignment.CenterVertically,
392+ ) {
393+ OutlinedTextField(
394+ value = query,
395+ onValueChange = onQueryChange,
396+ modifier = Modifier.weight(1f),
397+ placeholder = { Text(stringResource(R.string.find_hint)) },
398+ singleLine = true,
399+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
400+ keyboardActions = KeyboardActions(onSearch = { onQueryChange(query) }),
401+ trailingIcon = {
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()) {
406+ Text(
407+ countText,
408+ style = MaterialTheme.typography.labelSmall,
409+ color = MaterialTheme.colorScheme.onSurfaceVariant,
410+ )
411+ }
412+ },
413+ )
414+ IconButton(onClick = onPrev, enabled = matchCount > 0) {
415+ Icon(Icons.Filled.KeyboardArrowUp, contentDescription = stringResource(R.string.find_in_file))
416+ }
417+ IconButton(onClick = onNext, enabled = matchCount > 0) {
418+ Icon(Icons.Filled.KeyboardArrowDown, contentDescription = stringResource(R.string.find_in_file))
419+ }
420+ IconButton(onClick = onClose) {
421+ Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.close))
422+ }
423+ }
424+}
425+
426+@Composable
427+private fun FileTextContent(
428+ text: String,
429+ filename: String,
430+ topInset: androidx.compose.ui.unit.Dp,
431+ wrap: Boolean,
432+ findQuery: String,
433+ matchIndices: List<Int>,
434+ listState: LazyListState,
435+) {
221436 val lines = remember(text) { text.split("\n") }
222437 val gutterWidth = remember(lines.size) {
223438 (lines.size.toString().length.coerceAtLeast(3) * 10).dp
224439 }
225440 val hScrollState = rememberScrollState()
226441 val palette = rememberHighlightPalette()
442+ val q = findQuery.trim()
443+ val matchSet = remember(matchIndices) { matchIndices.toHashSet() }
227444 LazyColumn(
445+ state = listState,
228446 modifier = Modifier
229447 .fillMaxSize()
230448 .padding(top = topInset)
231449 .padding(8.dp),
232450 ) {
233451 itemsIndexed(lines, key = { index, _ -> index }) { index, line ->
234− Row(modifier = Modifier.horizontalScroll(hScrollState)) {
452+ val isMatch = q.isNotEmpty() && index in matchSet
453+ Row(
454+ modifier = Modifier
455+ .fillMaxWidth()
456+ .then(if (wrap) Modifier else Modifier.horizontalScroll(hScrollState))
457+ .then(if (isMatch) Modifier.background(MaterialTheme.colorScheme.secondaryContainer) else Modifier),
458+ ) {
235459 Text(
236460 "${index + 1}",
237461 modifier = Modifier.width(gutterWidth),