1package soy.iko.opencode.ui.search
3import androidx.compose.animation.Crossfade
4import androidx.compose.animation.core.tween
5import androidx.compose.foundation.clickable
6import androidx.compose.foundation.layout.Arrangement
7import androidx.compose.foundation.layout.Box
8import androidx.compose.foundation.layout.Column
9import androidx.compose.foundation.layout.Spacer
10import androidx.compose.foundation.layout.PaddingValues
11import androidx.compose.foundation.layout.Row
12import androidx.compose.foundation.layout.ExperimentalLayoutApi
13import androidx.compose.foundation.layout.FlowRow
14import androidx.compose.foundation.layout.fillMaxSize
15import androidx.compose.foundation.layout.fillMaxWidth
16import androidx.compose.foundation.layout.imePadding
17import androidx.compose.foundation.layout.padding
18import androidx.compose.foundation.layout.size
19import androidx.compose.foundation.layout.widthIn
20import androidx.compose.foundation.layout.wrapContentWidth
21import androidx.compose.foundation.lazy.LazyColumn
22import androidx.compose.foundation.lazy.items
23import androidx.compose.foundation.text.KeyboardActions
24import androidx.compose.foundation.text.KeyboardOptions
25import androidx.compose.material.icons.Icons
26import androidx.compose.material.icons.filled.Close
27import androidx.compose.material.icons.filled.ErrorOutline
28import androidx.compose.material.icons.filled.History
29import androidx.compose.material.icons.filled.Search
30import androidx.compose.material.icons.filled.SearchOff
31import androidx.compose.material3.AssistChip
32import androidx.compose.material3.Card
33import androidx.compose.material3.CircularProgressIndicator
34import androidx.compose.material3.ExperimentalMaterial3Api
35import androidx.compose.material3.FilterChip
36import androidx.compose.material3.Icon
37import androidx.compose.material3.IconButton
38import androidx.compose.material3.LinearProgressIndicator
39import androidx.compose.material3.MaterialTheme
40import androidx.compose.material3.OutlinedTextField
41import androidx.compose.material3.Scaffold
42import androidx.compose.material3.Text
43import androidx.compose.material3.TextButton
44import androidx.compose.runtime.Composable
45import androidx.compose.runtime.LaunchedEffect
46import androidx.compose.runtime.getValue
47import androidx.compose.runtime.remember
48import androidx.compose.ui.Alignment
49import androidx.compose.ui.Modifier
50import androidx.compose.ui.focus.focusRequester
51import androidx.compose.ui.platform.LocalSoftwareKeyboardController
52import androidx.compose.ui.platform.testTag
53import androidx.compose.ui.res.pluralStringResource
54import androidx.compose.ui.res.stringResource
55import androidx.compose.ui.semantics.Role
56import androidx.compose.ui.semantics.contentDescription
57import androidx.compose.ui.semantics.heading
58import androidx.compose.ui.semantics.semantics
59import androidx.compose.ui.text.AnnotatedString
60import androidx.compose.ui.text.SpanStyle
61import androidx.compose.ui.text.buildAnnotatedString
62import androidx.compose.ui.text.font.FontWeight
63import androidx.compose.ui.text.input.ImeAction
64import androidx.compose.ui.text.style.TextOverflow
65import androidx.compose.ui.text.withStyle
66import androidx.compose.ui.unit.dp
67import androidx.lifecycle.compose.collectAsStateWithLifecycle
68import androidx.lifecycle.viewmodel.compose.viewModel
69import soy.iko.opencode.R
70import soy.iko.opencode.di.AppContainer
71import soy.iko.opencode.ui.components.AppTopBar
72import soy.iko.opencode.ui.components.ConnectionBannerFor
73import soy.iko.opencode.ui.components.EmptyState
74import soy.iko.opencode.ui.components.RelativeTimeText
75import soy.iko.opencode.ui.components.reducedMotionAnimateItem
76import soy.iko.opencode.ui.vmFactory
77import soy.iko.opencode.util.runCatchingCancellable
79/** Cross-session message search: type a query, tap a result to open that conversation. */
80@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
82fun GlobalSearchScreen(
83 container: AppContainer,
84 onOpenSession: (String, String?) -> Unit,
87 val vm: GlobalSearchViewModel = viewModel(factory = vmFactory { GlobalSearchViewModel(container) })
88 val state by vm.state.collectAsStateWithLifecycle()
89 val focusRequester = remember { androidx.compose.ui.focus.FocusRequester() }
90 val keyboard = LocalSoftwareKeyboardController.current
91 val matchCaseLabel = stringResource(R.string.match_case)
93 LaunchedEffect(Unit) {
94 runCatchingCancellable { focusRequester.requestFocus() }
99 AppTopBar(title = stringResource(R.string.search_all_title), onBack = onBack)
102 Column(modifier = Modifier.fillMaxWidth().wrapContentWidth(Alignment.CenterHorizontally).widthIn(max = soy.iko.opencode.data.network.NetworkConfig.listContentMaxWidthDp.dp).imePadding().padding(padding)) {
105 onValueChange = vm::setQuery,
108 .padding(horizontal = 16.dp, vertical = 8.dp)
109 .focusRequester(focusRequester)
110 .testTag("global_search"),
111 label = { Text(stringResource(R.string.search_all_hint)) },
112 placeholder = { Text(stringResource(R.string.search_all_hint)) },
114 leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
116 Row(verticalAlignment = Alignment.CenterVertically) {
117 // "Aa" toggle: a FilterChip beside the clear (x) so the case-sensitivity
118 // control sits with the query it modifies, not in the type-filter row.
120 selected = state.matchCase,
121 onClick = { vm.setMatchCase(!state.matchCase) },
122 label = { Text("Aa") },
123 modifier = Modifier.semantics { contentDescription = matchCaseLabel },
125 if (state.query.isNotEmpty()) {
126 IconButton(onClick = { vm.setQuery("") }) {
127 Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.clear_search))
132 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
133 keyboardActions = KeyboardActions(onSearch = { keyboard?.hide() }),
135 // Type-filter row: All / Messages / Tool calls / Reasoning. Wraps via FlowRow so a
136 // long localized label doesn't overflow on narrow screens.
138 modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
139 horizontalArrangement = Arrangement.spacedBy(8.dp),
141 val filters = listOf(
142 SearchTypeFilter.ALL to R.string.search_filter_all,
143 SearchTypeFilter.MESSAGES to R.string.search_filter_messages,
144 SearchTypeFilter.TOOLS to R.string.search_filter_tools,
145 SearchTypeFilter.REASONING to R.string.search_filter_reasoning,
147 filters.forEach { (filter, labelRes) ->
149 selected = state.typeFilter == filter,
150 onClick = { vm.setTypeFilter(filter) },
151 label = { Text(stringResource(labelRes)) },
155 Box(modifier = Modifier.fillMaxSize()) {
156 ConnectionBannerFor(container)
157 val searchingLabel = stringResource(R.string.searching)
158 // When a re-search runs on top of existing results, keep the prior results
159 // visible with a slim top progress bar (matching the file browser) instead of
160 // blanking the list to a full-screen spinner — incremental typing shouldn't
161 // flash an empty screen each keystroke. The full-screen spinner is only used
162 // for the very first search (no results yet).
163 val showResultsWhileSearching = state.searching && state.hasSearched && state.results.isNotEmpty()
164 // Crossfade between content states so transitions read as a smooth fade instead
165 // of an instant snap. Matches the session list's Crossfade pattern; reduced
166 // motion is honored by Crossfade's default spec.
167 val stateKey = globalSearchStateKey(state)
168 @Suppress("UnusedCrossfadeTargetStateParameter")
170 targetState = stateKey,
171 animationSpec = tween(soy.iko.opencode.data.network.NetworkConfig.motionFadeDurationMs.toInt()),
172 label = "global_search_state",
175 state.searching && !showResultsWhileSearching -> Column(
176 modifier = Modifier.align(Alignment.Center),
177 horizontalAlignment = Alignment.CenterHorizontally,
179 CircularProgressIndicator(
180 Modifier.semantics { contentDescription = searchingLabel },
182 // Surface determinate progress so a 50-session pass doesn't look stuck.
183 if (state.totalCount > 1) {
184 Spacer(Modifier.size(12.dp))
186 stringResource(R.string.search_progress, state.searchedCount, state.totalCount),
187 style = MaterialTheme.typography.labelMedium,
188 color = MaterialTheme.colorScheme.onSurfaceVariant,
192 state.error != null -> EmptyState(
193 icon = Icons.Filled.ErrorOutline,
194 title = state.error ?: "",
195 modifier = Modifier.align(Alignment.Center),
196 actionLabel = stringResource(R.string.retry),
197 onAction = vm::retry,
199 state.hasSearched && state.results.isEmpty() -> EmptyState(
200 icon = Icons.Filled.SearchOff,
201 title = stringResource(R.string.search_no_matches),
202 modifier = Modifier.align(Alignment.Center),
204 // Typed something but below the min length (no search run yet): nudge to keep
205 // typing rather than reverting to the untouched start state, which reads as if
206 // the field were empty.
207 state.query.isNotBlank() && !state.hasSearched -> EmptyState(
208 icon = Icons.Filled.Search,
209 title = stringResource(R.string.search_keep_typing),
210 modifier = Modifier.align(Alignment.Center),
212 // Empty query + persisted history: surface suggestions so a returning user can
213 // re-run a prior search in one tap instead of retyping it.
214 state.query.isBlank() && state.history.isNotEmpty() -> HistorySuggestions(
215 history = state.history,
220 onClear = vm::clearHistory,
221 modifier = Modifier.fillMaxSize(),
223 state.results.isEmpty() -> EmptyState(
224 icon = Icons.Filled.Search,
225 title = stringResource(R.string.search_all_start),
226 description = stringResource(R.string.search_all_start_hint),
227 modifier = Modifier.align(Alignment.Center),
230 modifier = Modifier.fillMaxSize(),
231 contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 24.dp),
232 verticalArrangement = Arrangement.spacedBy(12.dp),
234 item(key = "__count") {
236 pluralStringResource(R.plurals.search_results_count, state.results.size, state.results.size),
237 style = MaterialTheme.typography.labelMedium,
238 color = MaterialTheme.colorScheme.onSurfaceVariant,
239 modifier = Modifier.padding(top = 8.dp).semantics { heading() },
242 if (state.truncated) {
243 item(key = "__truncated") {
244 // Surface that the search was capped AND offer a one-tap "search
245 // more" so users with >50 sessions can page through older matches
246 // instead of being stuck at the cap.
248 modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
251 stringResource(R.string.search_truncated),
252 style = MaterialTheme.typography.labelSmall,
253 color = MaterialTheme.colorScheme.onSurfaceVariant,
256 onClick = { vm.searchMore() },
257 enabled = !state.searching,
258 contentPadding = PaddingValues(horizontal = 0.dp),
260 Text(stringResource(R.string.search_more))
265 items(state.results, key = { it.session.id }) { hit ->
268 query = state.query.trim(),
269 ignoreCase = !state.matchCase,
270 onClick = { onOpenSession(hit.session.id, hit.firstMatchMessageId) },
271 modifier = Modifier.then(reducedMotionAnimateItem()),
277 // Slim progress bar overlay for an in-flight re-search that keeps the prior
278 // results visible underneath (matches the file browser's pattern). Only shown
279 // when results are already on screen; the first-search spinner is handled by
280 // the Crossfade branch above.
281 if (showResultsWhileSearching) {
282 LinearProgressIndicator(
284 .align(Alignment.TopCenter)
286 .semantics { contentDescription = searchingLabel },
294/** Maps the global-search view state to a stable string key for the Crossfade, so the screen's
295 * content-state transitions fade smoothly. Extracted from [GlobalSearchScreen] to keep its
296 * cyclomatic complexity under the detekt threshold. A re-search that keeps prior results
297 * visible maps to "results" (not "searching") so the Crossfade doesn't fade the list out. */
298private fun globalSearchStateKey(state: GlobalSearchState): String = when {
299 state.searching && state.hasSearched && state.results.isNotEmpty() -> "results"
300 state.searching -> "searching"
301 state.error != null -> "error"
302 state.hasSearched && state.results.isEmpty() -> "no_matches"
303 state.query.isNotBlank() && !state.hasSearched -> "keep_typing"
304 state.query.isBlank() && state.history.isNotEmpty() -> "history"
305 state.results.isEmpty() -> "start"
309@OptIn(ExperimentalLayoutApi::class)
311private fun HistorySuggestions(
312 history: List<String>,
313 onPick: (String) -> Unit,
315 modifier: Modifier = Modifier,
317 Column(modifier = modifier.padding(16.dp)) {
319 modifier = Modifier.fillMaxWidth(),
320 verticalAlignment = Alignment.CenterVertically,
323 stringResource(R.string.recent_searches),
324 style = MaterialTheme.typography.labelMedium,
325 color = MaterialTheme.colorScheme.onSurfaceVariant,
326 modifier = Modifier.semantics { heading() },
328 Spacer(Modifier.weight(1f))
329 TextButton(onClick = onClear) {
330 Text(stringResource(R.string.clear_history))
333 Spacer(Modifier.size(8.dp))
335 modifier = Modifier.fillMaxWidth(),
336 horizontalArrangement = Arrangement.spacedBy(8.dp),
337 verticalArrangement = Arrangement.spacedBy(8.dp),
339 history.forEach { query ->
341 onClick = { onPick(query) },
342 leadingIcon = { Icon(Icons.Filled.History, contentDescription = null) },
343 label = { Text(query, maxLines = 1, overflow = TextOverflow.Ellipsis) },
351private fun SearchResultCard(hit: SearchHit, query: String, ignoreCase: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier) {
352 val highlightColor = MaterialTheme.colorScheme.primary
353 // Highlight the title too when the match was in the title (otherwise the body snippet below
354 // is highlighted but the title above stays plain — an inconsistency).
355 val title = remember(hit.session.displayTitle, query, hit.matchedTitle, highlightColor, ignoreCase) {
356 if (hit.matchedTitle) highlightMatches(hit.session.displayTitle, query, highlightColor, ignoreCase)
357 else AnnotatedString(hit.session.displayTitle)
359 Card(modifier = modifier.fillMaxWidth().clickable(role = Role.Button) { onClick() }) {
360 Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
361 Row(verticalAlignment = Alignment.CenterVertically) {
364 style = MaterialTheme.typography.titleMedium,
366 overflow = TextOverflow.Ellipsis,
367 modifier = Modifier.weight(1f),
369 // Show how many times the query occurs so the user knows whether the session is
370 // worth opening (multiple hits) instead of seeing only the single returned snippet.
371 if (hit.matchCount > 1) {
372 val countLabel = pluralStringResource(R.plurals.search_results_count, hit.matchCount, hit.matchCount)
375 style = MaterialTheme.typography.labelSmall,
376 color = MaterialTheme.colorScheme.primary,
377 modifier = Modifier.padding(start = 8.dp),
382 hit.session.time?.updated ?: hit.session.time?.created,
383 modifier = Modifier.padding(top = 2.dp),
385 if (hit.snippet.isNotEmpty()) {
386 val snippet = remember(hit.snippet, query, highlightColor, ignoreCase) {
387 highlightMatches(hit.snippet, query, highlightColor, ignoreCase)
391 style = MaterialTheme.typography.bodyMedium,
392 color = MaterialTheme.colorScheme.onSurfaceVariant,
394 overflow = TextOverflow.Ellipsis,
395 modifier = Modifier.padding(top = 6.dp),
402/** Bold + tint every occurrence of [query] within [text] so the user can see where the hit is
403 * in the surrounding snippet. [ignoreCase] matches the search's case mode so highlights and
404 * matches never diverge. */
405private fun highlightMatches(
408 color: androidx.compose.ui.graphics.Color,
411 if (query.isEmpty()) return AnnotatedString(text)
412 return buildAnnotatedString {
415 val idx = text.indexOf(query, start, ignoreCase = ignoreCase)
417 append(text.substring(start))
420 append(text.substring(start, idx))
421 withStyle(SpanStyle(color = color, fontWeight = FontWeight.Bold)) {
422 append(text.substring(idx, idx + query.length))
424 start = idx + query.length
keysesc to close
- j k
- move down, up
- enter
- open
- esc
- back, or up a level
- 1 2 3 4
- readme, files, commits, refs
- /
- filter
- y
- copy the clone command
- c
- compare the chosen commit with another (or shift-click both)
- ?
- show or hide this list