git.iko.soy/opencode-android
on 61cdc75 help sign in
opencode-android/app/src/main/java/soy/iko/opencode/ui/search/GlobalSearchViewModel.kt
340 lines 17 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.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. */
38@Immutable
39data class SearchHit(
40 val session: Session,
41 val snippet: String,
42 val matchCount: Int = 1,
43 val matchedTitle: Boolean = false,
46@Immutable
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 }
79/**
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
83 * downloads history.
84 */
85class GlobalSearchViewModel(private val container: AppContainer) : ViewModel() {
87 private val _state = MutableStateFlow(
88 GlobalSearchState(history = container.searchHistoryStore.queries.value),
89 )
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) }
104 searchJob?.cancel()
105 val trimmed = query.trim()
106 if (trimmed.length < NetworkConfig.minSearchQueryLength) {
107 _state.update { it.copy(searching = false, results = emptyList(), hasSearched = false, error = null) }
108 return
109 }
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)
115 runSearch(trimmed)
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) }
121 }
122 }
123 }
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) }
130 relaunchCurrent()
131 }
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) }
137 relaunchCurrent()
138 }
140 /** Drop all persisted search history (the "Clear history" action). */
141 fun clearHistory() {
142 container.searchHistoryStore.clear()
143 _state.update { it.copy(history = emptyList()) }
144 }
146 private fun relaunchCurrent() {
147 val trimmed = _state.value.query.trim()
148 if (trimmed.length < NetworkConfig.minSearchQueryLength) return
149 searchJob?.cancel()
150 _state.update { it.copy(searching = true, error = null) }
151 searchJob = viewModelScope.launch { runSearch(trimmed) }
152 }
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). */
156 fun retry() {
157 val trimmed = _state.value.query.trim()
158 if (trimmed.length < NetworkConfig.minSearchQueryLength) return
159 sessionsCache = null
160 messageCache.clear()
161 searchJob?.cancel()
162 _state.update { it.copy(searching = true, error = null, sessionCap = NetworkConfig.maxSearchSessions) }
163 searchJob = viewModelScope.launch { runSearch(trimmed) }
164 }
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. */
169 fun searchMore() {
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
174 searchJob?.cancel()
175 _state.update { it.copy(searching = true, error = null, sessionCap = newCap) }
176 searchJob = viewModelScope.launch { runSearch(trimmed, newCap) }
177 }
179 private suspend fun runSearch(query: String, cap: Int = _state.value.sessionCap) {
180 val conn = container.activeConnection.value
181 if (conn == null) {
182 _state.update { it.copy(searching = false, error = container.string(R.string.not_connected)) }
183 return
184 }
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)) }
189 return
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
199 coroutineScope {
200 toSearch.forEach { session ->
201 launch {
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 }
208 .getOrElse {
209 Log.w("GlobalSearch", "listMessages failed: " + safeExceptionSummary(it))
210 emptyList()
211 }
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(
220 session = session,
221 snippet = snippet
222 ?: session.displayTitle.takeIf { titleMatched }.orEmpty(),
223 matchCount = count.coerceAtLeast(1),
224 matchedTitle = titleMatched,
225 )
226 }
227 // Publish progress so the spinner can show "Searched N / M sessions".
228 _state.update { it.copy(searchedCount = searched.incrementAndGet()) }
229 }
230 }
231 }
232 }
233 // Preserve the session order (already recency-sorted by the server) in the results.
234 val ordered = toSearch.mapNotNull { hits[it.id] }
235 _state.update {
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)
241 }
242 }
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>,
249 query: String,
250 ignoreCase: Boolean,
251 filter: SearchTypeFilter = SearchTypeFilter.ALL,
252 ): String? {
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)
257 }
258 }
259 return null
260 }
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,
270 ): List<String> {
271 if (parts.isEmpty()) return emptyList()
272 val out = ArrayList<String>(parts.size)
273 for (p in parts) {
274 when (p) {
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) {
278 out.add(p.tool)
279 when (p.state) {
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)
284 }
285 is ToolError -> p.state.error?.let(out::add)
286 else -> {}
287 }
288 p.state.inputElement()?.toString()?.let(out::add)
289 }
290 is FilePart -> if (filter == SearchTypeFilter.ALL) {
291 p.filename?.let(out::add)
292 p.sourcePath?.let(out::add)
293 }
294 else -> {}
295 }
296 }
297 return out
298 }
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
307 }
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>,
313 query: String,
314 ignoreCase: Boolean,
315 filter: SearchTypeFilter = SearchTypeFilter.ALL,
316 ): Int {
317 var count = 0
318 for (message in messages) {
319 for (text in searchableTexts(message.parts, filter)) {
320 count += countIn(text, query, ignoreCase)
321 }
322 }
323 return count
324 }
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
330 var count = 0
331 var idx = 0
332 while (true) {
333 val next = text.indexOf(query, idx, ignoreCase = ignoreCase)
334 if (next < 0) break
335 count++
336 idx = next + query.length
337 }
338 return count
339 }