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.MessageWithParts
9import soy.iko.opencode.data.model.Session
10import soy.iko.opencode.data.model.TextPart
11import soy.iko.opencode.data.network.NetworkConfig
12import soy.iko.opencode.di.AppContainer
13import soy.iko.opencode.util.runCatchingCancellable
14import soy.iko.opencode.util.safeExceptionSummary
15import kotlinx.coroutines.Job
16import kotlinx.coroutines.coroutineScope
17import kotlinx.coroutines.delay
18import kotlinx.coroutines.flow.MutableStateFlow
19import kotlinx.coroutines.flow.StateFlow
20import kotlinx.coroutines.flow.asStateFlow
21import kotlinx.coroutines.flow.update
22import kotlinx.coroutines.launch
23import kotlinx.coroutines.sync.Semaphore
24import kotlinx.coroutines.sync.withPermit
25import java.util.concurrent.ConcurrentHashMap
27/** One search result: the [session] that matched, a [snippet] of the matching text, the total
28 * number of [matchCount] occurrences (across body + title), and whether the title itself matched
29 * ([matchedTitle]) so the UI can highlight it like the snippet. */
34 val matchCount: Int = 1,
35 val matchedTitle: Boolean = false,
39data class GlobalSearchState(
40 val query: String = "",
41 val searching: Boolean = false,
42 val results: List<SearchHit> = emptyList(),
43 val error: String? = null,
44 /** True once a search has run for the current query (distinguishes "no results" from
45 * "nothing searched yet" so the UI shows the right empty state). */
46 val hasSearched: Boolean = false,
47 /** True when there were more sessions than [NetworkConfig.maxSearchSessions], so the
48 * search only covered the most recent ones. */
49 val truncated: Boolean = false,
53 * Cross-session message search. opencode's `/find` searches project *files*, not chat
54 * history, so message search is done client-side: fetch each session's messages (bounded and
55 * concurrent) and match their text. Runs only on an explicit, debounced query since it
58class GlobalSearchViewModel(private val container: AppContainer) : ViewModel() {
60 private val _state = MutableStateFlow(GlobalSearchState())
61 val state: StateFlow<GlobalSearchState> = _state.asStateFlow()
63 private var searchJob: Job? = null
65 // In-memory caches for the ViewModel's lifetime so a growing/re-typed query re-filters
66 // locally instead of re-downloading every session's history on each debounced keystroke.
67 // Only successful fetches are cached (a failed one isn't, so a later query retries it); an
68 // explicit retry() clears both to force a fresh fetch. ConcurrentHashMap because the
69 // per-session fetches run concurrently under the semaphore.
70 @Volatile private var sessionsCache: List<Session>? = null
71 private val messageCache = ConcurrentHashMap<String, List<MessageWithParts>>()
73 fun setQuery(query: String) {
74 _state.update { it.copy(query = query) }
76 val trimmed = query.trim()
77 if (trimmed.length < NetworkConfig.minSearchQueryLength) {
78 _state.update { it.copy(searching = false, results = emptyList(), hasSearched = false, error = null) }
81 // Show the spinner during the debounce (not just once runSearch starts) so the UI
82 // doesn't briefly render an empty result set before the search begins.
83 _state.update { it.copy(searching = true) }
84 searchJob = viewModelScope.launch {
85 delay(NetworkConfig.searchDebounceMs)
90 /** Re-run the current query immediately (no debounce), used by the error-state Retry. Clears
91 * the caches so the retry actually refetches (the prior attempt may have failed). */
93 val trimmed = _state.value.query.trim()
94 if (trimmed.length < NetworkConfig.minSearchQueryLength) return
98 _state.update { it.copy(searching = true, error = null) }
99 searchJob = viewModelScope.launch { runSearch(trimmed) }
102 private suspend fun runSearch(query: String) {
103 val conn = container.activeConnection.value
105 _state.update { it.copy(searching = false, error = container.string(R.string.not_connected)) }
108 _state.update { it.copy(searching = true, error = null) }
109 val sessions = sessionsCache
110 ?: runCatchingCancellable { conn.repository.listSessions() }.getOrElse {
111 _state.update { s -> s.copy(searching = false, error = container.friendlyError(it)) }
113 }.also { sessionsCache = it }
114 val toSearch = sessions.take(NetworkConfig.maxSearchSessions)
115 val truncated = sessions.size > toSearch.size
116 val hits = java.util.Collections.synchronizedMap(HashMap<String, SearchHit>())
117 val semaphore = Semaphore(NetworkConfig.maxConcurrentPreviews)
119 toSearch.forEach { session ->
121 semaphore.withPermit {
122 // Reuse cached history when present so re-typing a query never re-fetches;
123 // only sessions not yet cached hit the network.
124 val messages = messageCache[session.id]
125 ?: runCatchingCancellable { conn.api.listMessages(session.id) }
126 .onSuccess { messageCache[session.id] = it }
128 Log.w("GlobalSearch", "listMessages failed: " + safeExceptionSummary(it))
131 val snippet = matchSnippet(messages, query)
132 val titleMatched = session.displayTitle.contains(query, ignoreCase = true)
133 if (snippet != null || titleMatched) {
134 val count = countMatches(messages, query) +
135 countIn(session.displayTitle, query)
136 hits[session.id] = SearchHit(
139 ?: session.displayTitle.takeIf { titleMatched }.orEmpty(),
140 matchCount = count.coerceAtLeast(1),
141 matchedTitle = titleMatched,
148 // Preserve the session order (already recency-sorted by the server) in the results.
149 val ordered = toSearch.mapNotNull { hits[it.id] }
151 // If the query changed while this search was finishing (e.g. the user cleared the
152 // box), cancelling this job can race the final write; only publish results for the
153 // query still displayed so a stale result set can't land after the reset.
154 if (it.query.trim() != query) it
155 else it.copy(searching = false, results = ordered, hasSearched = true, truncated = truncated)
159 /** Find the first text part containing [query] across [messages] and return a short
160 * snippet centered on the match, or null if nothing matches. */
161 private fun matchSnippet(
162 messages: List<soy.iko.opencode.data.model.MessageWithParts>,
165 for (message in messages) {
166 for (part in message.parts) {
167 if (part !is TextPart) continue
168 val idx = part.text.indexOf(query, ignoreCase = true)
169 if (idx >= 0) return buildSnippet(part.text, idx, query.length)
175 private fun buildSnippet(text: String, matchStart: Int, matchLength: Int): String {
176 val window = NetworkConfig.searchSnippetLength
177 val start = (matchStart - window / 2).coerceAtLeast(0)
178 val end = (matchStart + matchLength + window / 2).coerceAtMost(text.length)
179 val prefix = if (start > 0) "…" else ""
180 val suffix = if (end < text.length) "…" else ""
181 return prefix + text.substring(start, end).trim() + suffix
184 /** Count all occurrences of [query] across the text parts of [messages] (case-insensitive,
185 * non-overlapping) so a result card can show "N matches" instead of just one snippet. */
186 private fun countMatches(
187 messages: List<soy.iko.opencode.data.model.MessageWithParts>,
191 for (message in messages) {
192 for (part in message.parts) {
193 if (part !is TextPart) continue
194 count += countIn(part.text, query)
200 /** Non-overlapping case-insensitive occurrence count of [query] within [text]. */
201 private fun countIn(text: String, query: String): Int {
202 if (query.isEmpty()) return 0
206 val next = text.indexOf(query, idx, ignoreCase = true)
209 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