1package soy.iko.opencode.ui.search
4import androidx.compose.runtime.Immutable
5import androidx.lifecycle.ViewModel
6import androidx.lifecycle.viewModelScope
7import soy.iko.opencode.R
8import soy.iko.opencode.data.model.FilePart
9import soy.iko.opencode.data.model.MessageWithParts
10import soy.iko.opencode.data.model.ReasoningPart
11import soy.iko.opencode.data.model.Session
12import soy.iko.opencode.data.model.TextPart
13import soy.iko.opencode.data.model.ToolCompleted
14import soy.iko.opencode.data.model.ToolError
15import soy.iko.opencode.data.model.ToolPart
16import soy.iko.opencode.data.model.ToolRunning
17import soy.iko.opencode.data.model.inputElement
18import soy.iko.opencode.data.model.sourcePath
19import soy.iko.opencode.data.network.NetworkConfig
20import soy.iko.opencode.di.AppContainer
21import soy.iko.opencode.util.runCatchingCancellable
22import soy.iko.opencode.util.safeExceptionSummary
23import kotlinx.coroutines.Job
24import kotlinx.coroutines.coroutineScope
25import kotlinx.coroutines.delay
26import kotlinx.coroutines.flow.MutableStateFlow
27import kotlinx.coroutines.flow.StateFlow
28import kotlinx.coroutines.flow.asStateFlow
29import kotlinx.coroutines.flow.update
30import kotlinx.coroutines.launch
31import kotlinx.coroutines.sync.Semaphore
32import kotlinx.coroutines.sync.withPermit
33import java.util.concurrent.ConcurrentHashMap
35/** One search result: the [session] that matched, a [snippet] of the matching text, the total
36 * number of [matchCount] occurrences (across body + title), and whether the title itself matched
37 * ([matchedTitle]) so the UI can highlight it like the snippet. */
42 val matchCount: Int = 1,
43 val matchedTitle: Boolean = false,
47data class GlobalSearchState(
48 val query: String = "",
49 val searching: Boolean = false,
50 val results: List<SearchHit> = emptyList(),
51 val error: String? = null,
52 /** True once a search has run for the current query (distinguishes "no results" from
53 * "nothing searched yet" so the UI shows the right empty state). */
54 val hasSearched: Boolean = false,
55 /** True when there were more sessions than the current cap, so the search only covered
56 * the most recent ones. The cap starts at [NetworkConfig.maxSearchSessions] and grows
57 * via [searchMore] so the user can page through older sessions. */
58 val truncated: Boolean = false,
59 /** How many sessions have been scanned so far in the current pass, for progress feedback
60 * (a global search downloads each session's history, so a 50-session pass can take a
61 * while; surfacing the count keeps the spinner from looking stuck). */
62 val searchedCount: Int = 0,
63 /** Total sessions being scanned in the current pass ([searchedCount] / [totalCount]). */
64 val totalCount: Int = 0,
65 /** The cap applied to this pass. Grows when the user taps "Search more". */
66 val sessionCap: Int = NetworkConfig.maxSearchSessions,
67 /** Message-type filter; ALL matches everything, the others restrict which parts are
68 * searchable so a user can scope "just tool calls" vs "just reasoning". */
69 val typeFilter: SearchTypeFilter = SearchTypeFilter.ALL,
70 /** When true the query matches case-sensitively (the default is case-insensitive). */
71 val matchCase: Boolean = false,
72 /** Persisted recent queries shown as suggestions when the search field is empty. */
73 val history: List<String> = emptyList(),
76/** Scopes a global search to a category of message part. */
77enum class SearchTypeFilter { ALL, MESSAGES, TOOLS, REASONING }
80 * Cross-session message search. opencode's `/find` searches project *files*, not chat
81 * history, so message search is done client-side: fetch each session's messages (bounded and
82 * concurrent) and match their text. Runs only on an explicit, debounced query since it
85class GlobalSearchViewModel(private val container: AppContainer) : ViewModel() {
87 private val _state = MutableStateFlow(
88 GlobalSearchState(history = container.searchHistoryStore.queries.value),
90 val state: StateFlow<GlobalSearchState> = _state.asStateFlow()
92 private var searchJob: Job? = null
94 // In-memory caches for the ViewModel's lifetime so a growing/re-typed query re-filters
95 // locally instead of re-downloading every session's history on each debounced keystroke.
96 // Only successful fetches are cached (a failed one isn't, so a later query retries it); an
97 // explicit retry() clears both to force a fresh fetch. ConcurrentHashMap because the
98 // per-session fetches run concurrently under the semaphore.
99 @Volatile private var sessionsCache: List<Session>? = null
100 private val messageCache = ConcurrentHashMap<String, List<MessageWithParts>>()
102 fun setQuery(query: String) {
103 _state.update { it.copy(query = query) }
105 val trimmed = query.trim()
106 if (trimmed.length < NetworkConfig.minSearchQueryLength) {
107 _state.update { it.copy(searching = false, results = emptyList(), hasSearched = false, error = null) }
110 // Show the spinner during the debounce (not just once runSearch starts) so the UI
111 // doesn't briefly render an empty result set before the search begins.
112 _state.update { it.copy(searching = true) }
113 searchJob = viewModelScope.launch {
114 delay(NetworkConfig.searchDebounceMs)
116 // Record the query only after a search actually ran (not on every keystroke that
117 // crossed the min-length threshold), so history reflects real searches.
118 if (_state.value.query.trim() == trimmed) {
119 container.searchHistoryStore.add(trimmed)
120 _state.update { it.copy(history = container.searchHistoryStore.queries.value) }
125 /** Swap the message-type filter and re-run the current query (no debounce) so the chip
126 * change is reflected immediately. The message cache is preserved. */
127 fun setTypeFilter(filter: SearchTypeFilter) {
128 if (_state.value.typeFilter == filter) return
129 _state.update { it.copy(typeFilter = filter) }
133 /** Toggle case-sensitivity and re-run the current query (no debounce). */
134 fun setMatchCase(matchCase: Boolean) {
135 if (_state.value.matchCase == matchCase) return
136 _state.update { it.copy(matchCase = matchCase) }
140 /** Drop all persisted search history (the "Clear history" action). */
142 container.searchHistoryStore.clear()
143 _state.update { it.copy(history = emptyList()) }
146 private fun relaunchCurrent() {
147 val trimmed = _state.value.query.trim()
148 if (trimmed.length < NetworkConfig.minSearchQueryLength) return
150 _state.update { it.copy(searching = true, error = null) }
151 searchJob = viewModelScope.launch { runSearch(trimmed) }
154 /** Re-run the current query immediately (no debounce), used by the error-state Retry. Clears
155 * the caches so the retry actually refetches (the prior attempt may have failed). */
157 val trimmed = _state.value.query.trim()
158 if (trimmed.length < NetworkConfig.minSearchQueryLength) return
162 _state.update { it.copy(searching = true, error = null, sessionCap = NetworkConfig.maxSearchSessions) }
163 searchJob = viewModelScope.launch { runSearch(trimmed) }
166 /** Expand the search to cover more sessions (the next [NetworkConfig.maxSearchSessions] batch)
167 * and re-run. No-op when the prior pass wasn't truncated (there's nothing more to search).
168 * The message cache is preserved so already-downloaded sessions aren't re-fetched. */
170 if (!_state.value.truncated) return
171 val trimmed = _state.value.query.trim()
172 if (trimmed.length < NetworkConfig.minSearchQueryLength) return
173 val newCap = _state.value.sessionCap + NetworkConfig.maxSearchSessions
175 _state.update { it.copy(searching = true, error = null, sessionCap = newCap) }
176 searchJob = viewModelScope.launch { runSearch(trimmed, newCap) }
179 private suspend fun runSearch(query: String, cap: Int = _state.value.sessionCap) {
180 val conn = container.activeConnection.value
182 _state.update { it.copy(searching = false, error = container.string(R.string.not_connected)) }
185 _state.update { it.copy(searching = true, error = null) }
186 val sessions = sessionsCache
187 ?: runCatchingCancellable { conn.repository.listSessions() }.getOrElse {
188 _state.update { s -> s.copy(searching = false, error = container.friendlyError(it)) }
190 }.also { sessionsCache = it }
191 val toSearch = sessions.take(cap)
192 val truncated = sessions.size > toSearch.size
193 _state.update { it.copy(totalCount = toSearch.size, searchedCount = 0, truncated = truncated) }
194 val hits = java.util.Collections.synchronizedMap(HashMap<String, SearchHit>())
195 val semaphore = Semaphore(NetworkConfig.maxConcurrentPreviews)
196 val searched = java.util.concurrent.atomic.AtomicInteger(0)
197 val ignoreCase = !_state.value.matchCase
198 val filter = _state.value.typeFilter
200 toSearch.forEach { session ->
202 semaphore.withPermit {
203 // Reuse cached history when present so re-typing a query never re-fetches;
204 // only sessions not yet cached hit the network.
205 val messages = messageCache[session.id]
206 ?: runCatchingCancellable { conn.api.listMessages(session.id) }
207 .onSuccess { messageCache[session.id] = it }
209 Log.w("GlobalSearch", "listMessages failed: " + safeExceptionSummary(it))
212 val snippet = matchSnippet(messages, query, ignoreCase, filter)
213 // The session title isn't a message part, so the type filter doesn't
214 // apply to it — a title match is always surfaced.
215 val titleMatched = session.displayTitle.contains(query, ignoreCase = ignoreCase)
216 if (snippet != null || titleMatched) {
217 val count = countMatches(messages, query, ignoreCase, filter) +
218 countIn(session.displayTitle, query, ignoreCase)
219 hits[session.id] = SearchHit(
222 ?: session.displayTitle.takeIf { titleMatched }.orEmpty(),
223 matchCount = count.coerceAtLeast(1),
224 matchedTitle = titleMatched,
227 // Publish progress so the spinner can show "Searched N / M sessions".
228 _state.update { it.copy(searchedCount = searched.incrementAndGet()) }
233 // Preserve the session order (already recency-sorted by the server) in the results.
234 val ordered = toSearch.mapNotNull { hits[it.id] }
236 // If the query changed while this search was finishing (e.g. the user cleared the
237 // box), cancelling this job can race the final write; only publish results for the
238 // query still displayed so a stale result set can't land after the reset.
239 if (it.query.trim() != query) it
240 else it.copy(searching = false, results = ordered, hasSearched = true, truncated = truncated)
244 /** Find the first searchable text containing [query] across [messages] and return a short
245 * snippet centered on the match, or null if nothing matches. [ignoreCase] controls
246 * case-sensitivity; [filter] restricts which part types contribute searchable text. */
247 private fun matchSnippet(
248 messages: List<soy.iko.opencode.data.model.MessageWithParts>,
251 filter: SearchTypeFilter = SearchTypeFilter.ALL,
253 for (message in messages) {
254 for (candidate in searchableTexts(message.parts, filter)) {
255 val idx = candidate.indexOf(query, ignoreCase = ignoreCase)
256 if (idx >= 0) return buildSnippet(candidate, idx, query.length)
262 /** The ordered list of searchable strings for a message's parts, scoped by [filter].
263 * Text and reasoning are searched in full; tool calls contribute their name, title,
264 * output, error, and the stringified input; file parts contribute their filename and
265 * source path. A non-ALL filter narrows to only the matching part type, so "Tool calls"
266 * excludes text/reasoning/file matches. */
267 private fun searchableTexts(
268 parts: List<soy.iko.opencode.data.model.Part>,
269 filter: SearchTypeFilter = SearchTypeFilter.ALL,
271 if (parts.isEmpty()) return emptyList()
272 val out = ArrayList<String>(parts.size)
275 is TextPart -> if (filter == SearchTypeFilter.ALL || filter == SearchTypeFilter.MESSAGES) out.add(p.text)
276 is ReasoningPart -> if (filter == SearchTypeFilter.ALL || filter == SearchTypeFilter.REASONING) out.add(p.text)
277 is ToolPart -> if (filter == SearchTypeFilter.ALL || filter == SearchTypeFilter.TOOLS) {
280 is ToolRunning -> p.state.title?.let(out::add)
281 is ToolCompleted -> {
282 p.state.title?.let(out::add)
283 p.state.output?.let(out::add)
285 is ToolError -> p.state.error?.let(out::add)
288 p.state.inputElement()?.toString()?.let(out::add)
290 is FilePart -> if (filter == SearchTypeFilter.ALL) {
291 p.filename?.let(out::add)
292 p.sourcePath?.let(out::add)
300 private fun buildSnippet(text: String, matchStart: Int, matchLength: Int): String {
301 val window = NetworkConfig.searchSnippetLength
302 val start = (matchStart - window / 2).coerceAtLeast(0)
303 val end = (matchStart + matchLength + window / 2).coerceAtMost(text.length)
304 val prefix = if (start > 0) "…" else ""
305 val suffix = if (end < text.length) "…" else ""
306 return prefix + text.substring(start, end).trim() + suffix
309 /** Count all occurrences of [query] across the searchable text of [messages] (text,
310 * reasoning, tool calls, file names) so a result card can show "N matches". */
311 private fun countMatches(
312 messages: List<soy.iko.opencode.data.model.MessageWithParts>,
315 filter: SearchTypeFilter = SearchTypeFilter.ALL,
318 for (message in messages) {
319 for (text in searchableTexts(message.parts, filter)) {
320 count += countIn(text, query, ignoreCase)
326 /** Non-overlapping occurrence count of [query] within [text], case-insensitive unless
327 * [ignoreCase] is false. */
328 private fun countIn(text: String, query: String, ignoreCase: Boolean): Int {
329 if (query.isEmpty()) return 0
333 val next = text.indexOf(query, idx, ignoreCase = ignoreCase)
336 idx = next + 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