git.iko.soy/opencode-android
on 06fd41b help sign in
opencode-android/app/src/main/java/soy/iko/opencode/ui/search/GlobalSearchViewModel.kt
213 lines 9.7 kB history raw
1package soy.iko.opencode.ui.search
3import android.util.Log
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. */
30@Immutable
31data class SearchHit(
32 val session: Session,
33 val snippet: String,
34 val matchCount: Int = 1,
35 val matchedTitle: Boolean = false,
38@Immutable
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,
52/**
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
56 * downloads history.
57 */
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) }
75 searchJob?.cancel()
76 val trimmed = query.trim()
77 if (trimmed.length < NetworkConfig.minSearchQueryLength) {
78 _state.update { it.copy(searching = false, results = emptyList(), hasSearched = false, error = null) }
79 return
80 }
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)
86 runSearch(trimmed)
87 }
88 }
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). */
92 fun retry() {
93 val trimmed = _state.value.query.trim()
94 if (trimmed.length < NetworkConfig.minSearchQueryLength) return
95 sessionsCache = null
96 messageCache.clear()
97 searchJob?.cancel()
98 _state.update { it.copy(searching = true, error = null) }
99 searchJob = viewModelScope.launch { runSearch(trimmed) }
100 }
102 private suspend fun runSearch(query: String) {
103 val conn = container.activeConnection.value
104 if (conn == null) {
105 _state.update { it.copy(searching = false, error = container.string(R.string.not_connected)) }
106 return
107 }
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)) }
112 return
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)
118 coroutineScope {
119 toSearch.forEach { session ->
120 launch {
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 }
127 .getOrElse {
128 Log.w("GlobalSearch", "listMessages failed: " + safeExceptionSummary(it))
129 emptyList()
130 }
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(
137 session = session,
138 snippet = snippet
139 ?: session.displayTitle.takeIf { titleMatched }.orEmpty(),
140 matchCount = count.coerceAtLeast(1),
141 matchedTitle = titleMatched,
142 )
143 }
144 }
145 }
146 }
147 }
148 // Preserve the session order (already recency-sorted by the server) in the results.
149 val ordered = toSearch.mapNotNull { hits[it.id] }
150 _state.update {
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)
156 }
157 }
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>,
163 query: String,
164 ): String? {
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)
170 }
171 }
172 return null
173 }
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
182 }
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>,
188 query: String,
189 ): Int {
190 var count = 0
191 for (message in messages) {
192 for (part in message.parts) {
193 if (part !is TextPart) continue
194 count += countIn(part.text, query)
195 }
196 }
197 return count
198 }
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
203 var count = 0
204 var idx = 0
205 while (true) {
206 val next = text.indexOf(query, idx, ignoreCase = true)
207 if (next < 0) break
208 count++
209 idx = next + query.length
210 }
211 return count
212 }