1package soy.iko.opencode.ui.search
3import androidx.compose.foundation.clickable
4import androidx.compose.foundation.layout.Arrangement
5import androidx.compose.foundation.layout.Box
6import androidx.compose.foundation.layout.Column
7import androidx.compose.foundation.layout.PaddingValues
8import androidx.compose.foundation.layout.Row
9import androidx.compose.foundation.layout.fillMaxSize
10import androidx.compose.foundation.layout.fillMaxWidth
11import androidx.compose.foundation.layout.padding
12import androidx.compose.foundation.layout.size
13import androidx.compose.foundation.lazy.LazyColumn
14import androidx.compose.foundation.lazy.items
15import androidx.compose.foundation.text.KeyboardActions
16import androidx.compose.foundation.text.KeyboardOptions
17import androidx.compose.material.icons.Icons
18import androidx.compose.material.icons.filled.Close
19import androidx.compose.material.icons.filled.ErrorOutline
20import androidx.compose.material.icons.filled.Search
21import androidx.compose.material.icons.filled.SearchOff
22import androidx.compose.material3.Card
23import androidx.compose.material3.CircularProgressIndicator
24import androidx.compose.material3.ExperimentalMaterial3Api
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.runtime.Composable
32import androidx.compose.runtime.LaunchedEffect
33import androidx.compose.runtime.getValue
34import androidx.compose.runtime.remember
35import androidx.compose.ui.Alignment
36import androidx.compose.ui.Modifier
37import androidx.compose.ui.focus.focusRequester
38import androidx.compose.ui.platform.LocalSoftwareKeyboardController
39import androidx.compose.ui.res.pluralStringResource
40import androidx.compose.ui.res.stringResource
41import androidx.compose.ui.semantics.Role
42import androidx.compose.ui.semantics.contentDescription
43import androidx.compose.ui.semantics.heading
44import androidx.compose.ui.semantics.semantics
45import androidx.compose.ui.text.AnnotatedString
46import androidx.compose.ui.text.SpanStyle
47import androidx.compose.ui.text.buildAnnotatedString
48import androidx.compose.ui.text.font.FontWeight
49import androidx.compose.ui.text.input.ImeAction
50import androidx.compose.ui.text.style.TextOverflow
51import androidx.compose.ui.text.withStyle
52import androidx.compose.ui.unit.dp
53import androidx.lifecycle.compose.collectAsStateWithLifecycle
54import androidx.lifecycle.viewmodel.compose.viewModel
55import soy.iko.opencode.R
56import soy.iko.opencode.di.AppContainer
57import soy.iko.opencode.ui.components.AppTopBar
58import soy.iko.opencode.ui.components.ConnectionBannerFor
59import soy.iko.opencode.ui.components.EmptyState
60import soy.iko.opencode.ui.components.RelativeTimeText
61import soy.iko.opencode.ui.vmFactory
62import soy.iko.opencode.util.runCatchingCancellable
64/** Cross-session message search: type a query, tap a result to open that conversation. */
65@OptIn(ExperimentalMaterial3Api::class)
67fun GlobalSearchScreen(
68 container: AppContainer,
69 onOpenSession: (String) -> Unit,
72 val vm: GlobalSearchViewModel = viewModel(factory = vmFactory { GlobalSearchViewModel(container) })
73 val state by vm.state.collectAsStateWithLifecycle()
74 val focusRequester = remember { androidx.compose.ui.focus.FocusRequester() }
75 val keyboard = LocalSoftwareKeyboardController.current
77 LaunchedEffect(Unit) {
78 runCatchingCancellable { focusRequester.requestFocus() }
83 AppTopBar(title = stringResource(R.string.search_all_title), onBack = onBack)
86 Column(modifier = Modifier.fillMaxSize().padding(padding)) {
89 onValueChange = vm::setQuery,
92 .padding(horizontal = 16.dp, vertical = 8.dp)
93 .focusRequester(focusRequester),
94 placeholder = { Text(stringResource(R.string.search_all_hint)) },
96 leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
98 if (state.query.isNotEmpty()) {
99 IconButton(onClick = { vm.setQuery("") }) {
100 Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.clear_search))
104 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
105 keyboardActions = KeyboardActions(onSearch = { keyboard?.hide() }),
107 Box(modifier = Modifier.fillMaxSize()) {
108 ConnectionBannerFor(container)
109 val searchingLabel = stringResource(R.string.searching)
111 state.searching -> CircularProgressIndicator(
112 Modifier.align(Alignment.Center).semantics { contentDescription = searchingLabel },
114 state.error != null -> EmptyState(
115 icon = Icons.Filled.ErrorOutline,
116 title = state.error ?: "",
117 modifier = Modifier.align(Alignment.Center),
118 actionLabel = stringResource(R.string.retry),
119 onAction = vm::retry,
121 state.hasSearched && state.results.isEmpty() -> EmptyState(
122 icon = Icons.Filled.SearchOff,
123 title = stringResource(R.string.search_no_matches),
124 modifier = Modifier.align(Alignment.Center),
126 // Typed something but below the min length (no search run yet): nudge to keep
127 // typing rather than reverting to the untouched start state, which reads as if
128 // the field were empty.
129 state.query.isNotBlank() && !state.hasSearched -> EmptyState(
130 icon = Icons.Filled.Search,
131 title = stringResource(R.string.search_keep_typing),
132 modifier = Modifier.align(Alignment.Center),
134 state.results.isEmpty() -> EmptyState(
135 icon = Icons.Filled.Search,
136 title = stringResource(R.string.search_all_start),
137 description = stringResource(R.string.search_all_start_hint),
138 modifier = Modifier.align(Alignment.Center),
141 modifier = Modifier.fillMaxSize(),
142 contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 24.dp),
143 verticalArrangement = Arrangement.spacedBy(12.dp),
145 item(key = "__count") {
147 pluralStringResource(R.plurals.search_results_count, state.results.size, state.results.size),
148 style = MaterialTheme.typography.labelMedium,
149 color = MaterialTheme.colorScheme.onSurfaceVariant,
150 modifier = Modifier.padding(top = 8.dp).semantics { heading() },
153 if (state.truncated) {
154 item(key = "__truncated") {
156 stringResource(R.string.search_truncated),
157 style = MaterialTheme.typography.labelSmall,
158 color = MaterialTheme.colorScheme.onSurfaceVariant,
159 modifier = Modifier.padding(vertical = 4.dp),
163 items(state.results, key = { it.session.id }) { hit ->
166 query = state.query.trim(),
167 onClick = { onOpenSession(hit.session.id) },
178private fun SearchResultCard(hit: SearchHit, query: String, onClick: () -> Unit) {
179 val highlightColor = MaterialTheme.colorScheme.primary
180 // Highlight the title too when the match was in the title (otherwise the body snippet below
181 // is highlighted but the title above stays plain — an inconsistency).
182 val title = remember(hit.session.displayTitle, query, hit.matchedTitle, highlightColor) {
183 if (hit.matchedTitle) highlightMatches(hit.session.displayTitle, query, highlightColor)
184 else AnnotatedString(hit.session.displayTitle)
186 Card(modifier = Modifier.fillMaxWidth().clickable(role = Role.Button) { onClick() }) {
187 Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
188 Row(verticalAlignment = Alignment.CenterVertically) {
191 style = MaterialTheme.typography.titleMedium,
193 overflow = TextOverflow.Ellipsis,
194 modifier = Modifier.weight(1f),
196 // Show how many times the query occurs so the user knows whether the session is
197 // worth opening (multiple hits) instead of seeing only the single returned snippet.
198 if (hit.matchCount > 1) {
199 val countLabel = pluralStringResource(R.plurals.search_results_count, hit.matchCount, hit.matchCount)
202 style = MaterialTheme.typography.labelSmall,
203 color = MaterialTheme.colorScheme.primary,
204 modifier = Modifier.padding(start = 8.dp),
209 hit.session.time?.updated ?: hit.session.time?.created,
210 modifier = Modifier.padding(top = 2.dp),
212 if (hit.snippet.isNotEmpty()) {
213 val snippet = remember(hit.snippet, query, highlightColor) {
214 highlightMatches(hit.snippet, query, highlightColor)
218 style = MaterialTheme.typography.bodyMedium,
219 color = MaterialTheme.colorScheme.onSurfaceVariant,
221 overflow = TextOverflow.Ellipsis,
222 modifier = Modifier.padding(top = 6.dp),
229/** Bold + tint every case-insensitive occurrence of [query] within [text] so the user can
230 * see where the hit is in the surrounding snippet. */
231private fun highlightMatches(
234 color: androidx.compose.ui.graphics.Color,
236 if (query.isEmpty()) return AnnotatedString(text)
237 return buildAnnotatedString {
240 val idx = text.indexOf(query, start, ignoreCase = true)
242 append(text.substring(start))
245 append(text.substring(start, idx))
246 withStyle(SpanStyle(color = color, fontWeight = FontWeight.Bold)) {
247 append(text.substring(idx, idx + query.length))
249 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