Arcology Engine

Search Screen — Full-Text Search, Tags & Results

Contents

The search mode within the Find tab: a search bar with mode chips (Combined/Title/Content/File/Tags), a tag filter row, and a debounced results list that feeds into the cross-mode context menu (pin, center on graph). The screen, its ViewModel, the SearchService query engine, and all tests are documented together since they form a closed vertical slice — the service drives the ViewModel, the ViewModel drives the screen, and the tests validate both layers.

Introduction

SearchScreen is the default mode in the Find tab. It provides full-text search across the indexed org-roam database with five search modes (SearchMode enum: COMBINED, TITLE, CONTENT, FILENAME, TAGS), a 300ms debounced query flow, tag-based filtering, and a scored results list. Each result renders as a compact Quiz-style card: title with match-source/pin badges, a highlighted FTS5 content snippet (query matches rendered bold in the primary color) when the body mentions the query, tag pills, and an Arcology publish footer (site/key) when it carries an ARCOLOGY_KEY — unpublished cards simply end with their tags. Long-press opens a context menu with Open, Center on Graph, and Pin/Unpin actions — wired by the parent FindScreen container.

SearchViewModel owns a reactive pipeline: four mutable state flows (query, searchMode, selectedTags, _refreshTrigger) combine via debounce(300) into a StateFlow<List<SearchResultItem>> that automatically re-queries on any state change. It also exposes availableTags loaded from the repository at init and on refresh.

The ViewModel also exports two cross-cutting methods: searchForTag(tag) sets the mode to TAGS with a single pre-selected tag (used by the editor's tag chips), and getNodesWithBacklinksFromFlashcards() provides the quiz screen's "explore backlinks" view.

Design Decisions

**Why debounce on the query but not on mode/tags?

Only _query is debounced (300ms). _searchMode and _selectedTags flow through the combine immediately. This is intentional: keyboard typing generates rapid-fire state changes that thrash the FTS index, so debouncing is necessary. Mode switches and tag toggles are single explicit user actions — a button tap — with no need for debounce. The combine triggers on any of the four flows changing, so tag toggles are instantaneous while typing is delayed.

**Why Eagerly sharing over Lazily?

searchResults is a stateIn(SharingStarted.Eagerly) flow. Eager sharing means the pipeline starts immediately when the ViewModel is created, populating the initial empty-query results (recent nodes + pinned nodes) without waiting for the first collector. This matters because SearchScreen needs to display content immediately when the Find tab is first shown — if it used Lazily, the screen would briefly flash empty before the initial load completes.

**Why runBlocking in getNodesWithBacklinksFromFlashcards?

QuizScreen calls getNodesWithBacklinksFromFlashcards() synchronously during composable composition (not from a coroutine). It needs immediate results to build the explore-backlinks view. While runBlocking is generally an anti-pattern in ViewModels, the alternative would require restructuring QuizScreen to launch a coroutine and manage its own loading state — a larger refactor. The method is called rarely (only when the user views the flashcard explore screen) and queries are fast (simple list of node IDs).

**Why FILENAME bypasses SearchService?

Filename search uses repository.searchNodesByFilePath() with SQL LIKE rather than FTS because file paths aren't indexed in the full-text search table. Path matching is purely structural (pattern matching against a column), not content-based, so LIKE is the right tool. The performSearch method has an early return for FILENAME mode before dispatching to SearchService.

**Why recent nodes intersperse pinned nodes at the top?

When query.isBlank(), the ViewModel loads "recent" content: pinned nodes first (with Double.MAX_VALUE rank to sort highest), then recent nodes from SearchService.getRecentNodes() filtered to exclude already-pinned IDs. This means pinned nodes always appear at the top of the empty-state view, giving users persistent quick-access bookmarks in the search tab.

SearchService — Query Engine

SearchService sits between the ViewModel and the repository, providing tiered full-text search with BM25 ranking. It owns the query pipeline: preparing FTS queries (escaping special characters), dispatching to title/metadata search (searchPrimary) or content search (searchContent), and a combined strategy (searchCombined) that runs title search first (limit 20) then fills remaining slots with content results for a total of 50.

Both FTS paths carry snippets: searchContentBM25 returns an FTS5 snippet() window directly from SQL, and searchPrimary runs its own content-table MATCH to attach body snippets to title-matched nodes — without that second query, COMBINED searches with many title matches would return zero snippets because the content search is skipped entirely (this was the bug that motivated the card redesign: the SQL and drivers were fine, the snippet() window only existed on content-fallback rows).

The service also provides getRecentNodes() for the empty-state recent-content view. SearchResult and SearchSource are defined here because they are the service's output types — separate from the UI-layer SearchResultItem that the ViewModel enriches with tags and publish keys.

kotlin#+name: search-service-imports
package computer.whatthefuck.arcology.search

import computer.whatthefuck.arcology.database.RoamRepository
import computer.whatthefuck.arcology.domain.FtsSearchResult
import computer.whatthefuck.arcology.domain.OrgNode

private const val TAG = "SearchService"
kotlin#+name: search-service-class
/**
 * High-level search service that provides tiered search functionality:
 * 1. Fast title/metadata search for primary results
 * 2. Content search for comprehensive results when needed
 * 3. BM25 ranking for relevance scoring
 */
class SearchService(
    private val repository: RoamRepository
) {

    /**
     * Primary search function - searches titles, tags, and aliases with BM25 ranking
     */
    suspend fun searchPrimary(query: String, limit: Int = 50): List<SearchResult> {
        if (query.isBlank()) return emptyList()

        val ftsQuery = prepareFtsQuery(query)
        println("[$TAG] query for $query: $ftsQuery")
        val results = repository.searchNodesByTitlesBM25(ftsQuery, limit.toLong())
        val len = results.count()
        println("[$TAG] results.length $len")

        // Title-matched nodes may also mention the query in their body — fetch
        // content-table snippets for them so every card can show a highlighted
        // window, not just pure content matches. One extra FTS MATCH on an
        // indexed virtual table costs tens of milliseconds.
        val contentSnippets = repository
            .searchNodesByContentBM25(ftsQuery, limit.toLong())
            .mapNotNull { result -> result.snippet?.let { result.nodeId to it } }
            .toMap()

        return results.map { ftsResult ->
            val node = repository.getNodeById(ftsResult.nodeId)
            SearchResult(
                node = node!!,
                rank = ftsResult.rank,
                source = SearchSource.TITLE_METADATA,
                snippet = contentSnippets[ftsResult.nodeId]
            )
        }
    }

    /**
     * Extended search function - searches full content when title search insufficient
     */
    suspend fun searchContent(query: String, excludeNodeIds: Set<String> = emptySet(), limit: Int = 50): List<SearchResult> {
        if (query.isBlank()) return emptyList()

        val ftsQuery = prepareFtsQuery(query)
        val results = repository.searchNodesByContentBM25(ftsQuery, limit.toLong())

        return results
            .filter { it.nodeId !in excludeNodeIds }
            .map { ftsResult ->
                val node = repository.getNodeById(ftsResult.nodeId)
                SearchResult(
                    node = node!!,
                    rank = ftsResult.rank,
                    source = SearchSource.CONTENT,
                    snippet = ftsResult.snippet
                )
            }
    }

    /**
     * Combined search - primary search with content search fallback
     */
    suspend fun searchCombined(query: String, primaryLimit: Int = 20, totalLimit: Int = 50): List<SearchResult> {
        if (query.isBlank()) return emptyList()

        // Get primary results from title/metadata search
        val primaryResults = searchPrimary(query, primaryLimit)

        // If we have enough results, return them
        if (primaryResults.size >= totalLimit) {
            return primaryResults.take(totalLimit)
        }

        // Get additional results from content search
        val primaryNodeIds = primaryResults.map { it.node.id }.toSet()
        val contentLimit = totalLimit - primaryResults.size
        val contentResults = searchContent(query, primaryNodeIds, contentLimit)

        // Combine and sort by rank (BM25 score - lower is better)
        return (primaryResults + contentResults)
            .sortedBy { it.rank }
            .take(totalLimit)
    }

    /**
     * Simple search using basic FTS without ranking (fallback)
     */
    suspend fun searchSimple(query: String, searchContent: Boolean = false, limit: Int = 50): List<OrgNode> {
        if (query.isBlank()) return emptyList()

        val ftsQuery = prepareFtsQuery(query)
        val nodeIds = if (searchContent) {
            repository.searchNodesByContent(ftsQuery, limit.toLong())
        } else {
            repository.searchNodesByTitles(ftsQuery, limit.toLong())
        }

        return nodeIds.mapNotNull { nodeId ->
            repository.getNodeById(nodeId)
        }
    }

    /**
     * Get recent nodes ordered by file modification time
     */
    suspend fun getRecentNodes(limit: Int = 100): List<SearchResult> {
        val nodes = repository.getRecentNodes(limit.toLong())
        return nodes.map { node ->
            SearchResult(
                node = node,
                rank = 0.0,
                source = SearchSource.TITLE_METADATA
            )
        }
    }

    /**
     * Prepare FTS query by escaping special characters and handling edge cases.
     */
    private fun prepareFtsQuery(query: String): String {
        if (query.isBlank()) return ""

        return query.trim()
            .replace("\"", "\"\"") // Escape quotes
            .replace("-", " ") // FTS5 splits on hyphens; space-separate for AND matching
            .let { if (it.isEmpty()) "\"\"" else it } // Handle empty queries
    }
}

/**
 * Search result with ranking and source information
 *
 * @param snippet FTS5 highlight window around query matches in the node body.
 * Populated for content-table matches directly from SQL, and for title-matched
 * nodes whose body also mentions the query (via searchPrimary's content-table
 * side-query). Null when the node's body has no match — title-only matches
 * whose body never mentions the query, and recent/filename/tag-list results.
 */
data class SearchResult(
    val node: OrgNode,
    val rank: Double, // BM25 rank (lower is better)
    val source: SearchSource,
    val snippet: String? = null
)

/**
 * Source of search result for UI feedback
 */
enum class SearchSource {
    TITLE_METADATA,  // Found in title, tags, or aliases
    CONTENT         // Found in full content
}
kotlin#+name: search-service-full:tangle ../src/commonMain/kotlin/computer/whatthefuck/arcology/search/SearchService.kt:noweb yes
<<search-service-imports>>

<<search-service-class>>

SearchMode Enum

SearchMode defines the five search strategies:

  • COMBINED — searches both title/metadata and content, prioritizing title matches (20 primary, 50 total)

  • TITLE — searches title/metadata only via searchService.searchPrimary()

  • CONTENT — searches body content only via searchService.searchContent()

  • FILENAME — searches file paths via SQL LIKE

  • TAGS — filters by tag intersection

kotlin#+name: search-screen-searchmode
enum class SearchMode {
    COMBINED,  // Search both title and content
    TITLE,     // Search title/metadata only
    CONTENT,   // Search content only
    FILENAME,  // Search by file path
    TAGS       // Filter by tags
}

SearchResultItem & TagWithCount

Two data classes that bridge the search pipeline to the UI:

  • SearchResultItem — what the screen renders per card: nodeId, title, filePath, rank, tags, optional matchSource (T/C badge), optional highlighted snippet, and optional publishKey (the site/key route of an =ARCOLOGY_KEY=-bearing node, or null when private/unpublished)

  • TagWithCount — what the filter chip row displays: tag name + frequency count from the database

kotlin#+name: search-screen-models
data class SearchResultItem(
    val nodeId: String,
    val title: String,
    val filePath: String,
    val rank: Double = 0.0,
    val tags: List<String> = emptyList(),
    val matchSource: SearchSource? = null,
    val snippet: String? = null,
    val publishKey: String? = null
)

data class TagWithCount(
    val tag: String,
    val count: Long
)

SearchViewModel State

The ViewModel manages seven state flows:

  • _query — the current search text (mutable, debounced at 300ms)

  • _searchMode — active search mode (mutable)

  • _selectedTags — set of active tag filters (mutable)

  • _refreshTrigger — integer counter incremented to force recomputation

  • _isSearching — loading flag set during query execution

  • _availableTags — list of all tags with counts (loaded at init)

  • searchResults — the derived reactive pipeline combining the above

The searchResults pipeline is the core: it ~combine~s _query.debounce(300), _searchMode, _selectedTags, and _refreshTrigger into a tuple, ~map~s to the appropriate query method, and stateIn with SharingStarted.Eagerly.

kotlin#+name: search-screen-viewmodel-state
@OptIn(FlowPreview::class)
class SearchViewModel(
    private val searchService: SearchService,
    private val repository: RoamRepository,
    private val quizRepository: QuizRepository
) : ViewModel() {

    private val _query = MutableStateFlow("")
    val query: StateFlow<String> = _query

    private val _searchMode = MutableStateFlow(SearchMode.COMBINED)
    val searchMode: StateFlow<SearchMode> = _searchMode

    private val _isSearching = MutableStateFlow(false)
    val isSearching: StateFlow<Boolean> = _isSearching

    private val _selectedTags = MutableStateFlow<Set<String>>(emptySet())
    val selectedTags: StateFlow<Set<String>> = _selectedTags

    private val _availableTags = MutableStateFlow<List<TagWithCount>>(emptyList())
    val availableTags: StateFlow<List<TagWithCount>> = _availableTags

    private val _refreshTrigger = MutableStateFlow(0)

    val searchResults: StateFlow<List<SearchResultItem>> = combine(
        _query.debounce(300),
        _searchMode,
        _selectedTags,
        _refreshTrigger
    ) { queryText, mode, tags, _ -> Triple(queryText, mode, tags) }
        .map { (queryText, mode, selectedTags) ->
            _isSearching.value = true
            try {
                when {
                    // Tag mode: filter by selected tags
                    mode == SearchMode.TAGS && selectedTags.isNotEmpty() -> {
                        searchByTags(selectedTags, queryText)
                    }
                    // No query: show recent nodes
                    queryText.isBlank() -> {
                        loadRecentNodes()
                    }
                    // Search with mode
                    else -> {
                        performSearch(queryText, mode)
                    }
                }
            } finally {
                _isSearching.value = false
            }
        }
        .stateIn(
            scope = viewModelScope,
            started = SharingStarted.Eagerly,
            initialValue = emptyList()
        )

    init {
        loadAvailableTags()
    }

Query & Mode Control

Public methods that update mutable state:

  • updateQuery() — sets the query text, which fires the debounce pipeline

  • setSearchMode() — switches between COMBINED/TITLE/CONTENT/FILENAME/TAGS

  • toggleTag() — adds or removes a tag from the selected set

  • clearSelectedTags() — resets the tag filter

  • searchForTag() — sets mode to TAGS and selects a single tag (external API for editor tag chips)

  • refresh() — increments the trigger and reloads available tags

kotlin#+name: search-screen-viewmodel-control

    fun updateQuery(newQuery: String) {
        _query.value = newQuery
    }

    fun setSearchMode(mode: SearchMode) {
        _searchMode.value = mode
    }

    fun toggleTag(tag: String) {
        val current = _selectedTags.value
        _selectedTags.value = if (tag in current) {
            current - tag
        } else {
            current + tag
        }
    }

    fun clearSelectedTags() {
        _selectedTags.value = emptySet()
    }

    /**
     * Selects a single tag for searching, clearing any previously selected tags.
     * Also sets the search mode to TAGS.
     */
    fun searchForTag(tag: String) {
        _searchMode.value = SearchMode.TAGS
        _selectedTags.value = setOf(tag)
    }

    fun refresh() {
        _refreshTrigger.value++
        loadAvailableTags()
    }

    private fun loadAvailableTags() {
        viewModelScope.launch {
            try {
                val tagsWithCount = quizRepository.getTagsForFlashcardNodesWithCount()
                _availableTags.value = tagsWithCount.map { (tag, count) ->
                    TagWithCount(tag, count)
                }
            } catch (e: Exception) {
                _availableTags.value = emptyList()
            }
        }
    }

Cross-cutting: Flashcard Backlinks

QuizScreen uses this method to build its "explore backlinks" view. It calls runBlocking because the quiz screen consumes it during composable composition rather than from a coroutine scope.

Result Enrichment

Every search pipeline branch (FTS, filename, tag-filter, recent, backlinks) builds =SearchResultItem=s the same way: fetch tags, resolve the Arcology publish key, wrap it up. Five near-identical construction blocks grew over time; buildItem() consolidates them so enrichment changes land in one place.

Publish-key resolution is deliberately two-tier:

  1. fetchPublishKeys() batch-fetches getNodesByPropertyKey(ARCOLOGY_KEY) — this covers :ARCOLOGY_KEY: in heading :PROPERTIES: drawers, including file-level nodes that carry the preamble properties when the file has an :ID:.

  2. Nodes not covered get a per-*distinct-file* getFileProperties() fallback — files whose #+ARCOLOGY_KEY: preamble exists but which have no :ID: never produce a file-level node, so their key lives only in file_properties. Results repeat files heavily, so the fallback dedupes on file path.

Heading keys win over file keys when both exist. ARCOLOGY_EXPIRE display is deliberately not implemented here — "is it expired yet?" is the server's semantics; the card shows just the site/key route.

kotlin#+name: search-screen-viewmodel-enrichment

    /**
     * Fetch publish keys for a batch of nodes: heading-drawer properties first,
     * then file-property fallback for files without a file-level node.
     */
    private suspend fun fetchPublishKeys(nodeIds: Collection<String>): Map<String, String> {
        if (nodeIds.isEmpty()) return emptyMap()
        return try {
            val keysByNode = mutableMapOf<String, String>()
            try {
                repository.getNodesByPropertyKey(PUBLISH_KEY_PROPERTY).forEach { (nodeId, value) ->
                    if (!value.isNullOrBlank() && nodeId in nodeIds) {
                        keysByNode[nodeId] = value.trim()
                    }
                }
            } catch (_: Exception) {
            }
            val missing = nodeIds.filter { it !in keysByNode }
            val filesByNode = mutableMapOf<String, String>()
            missing.forEach { nodeId ->
                try {
                    val node = repository.getNodeById(nodeId) ?: return@forEach
                    filesByNode[nodeId] = node.file
                } catch (_: Exception) {
                }
            }
            for ((nodeId, file) in filesByNode) {
                try {
                    val value = repository.getFileProperties(file)[PUBLISH_KEY_PROPERTY]
                    if (!value.isNullOrBlank()) keysByNode[nodeId] = value.trim()
                } catch (_: Exception) {
                }
            }
            keysByNode
        } catch (_: Exception) {
            emptyMap()
        }
    }

    /**
     * Single construction point for search result items: enriches a node with
     * tags and its Arcology publish key (when published).
     */
    private suspend fun buildItem(
        node: OrgNode,
        rank: Double = 0.0,
        matchSource: SearchSource? = null,
        snippet: String? = null,
        publishKeys: Map<String, String>? = null
    ): SearchResultItem {
        val tags = try {
            repository.getTagsByNode(node.id)
        } catch (_: Exception) {
            emptyList()
        }
        val publishKey = publishKeys?.get(node.id)
        return SearchResultItem(
            nodeId = node.id,
            title = node.title ?: "Untitled",
            filePath = node.file,
            rank = rank,
            tags = tags,
            matchSource = matchSource,
            snippet = snippet,
            publishKey = publishKey
        )
    }

Search Implementation

The private search methods dispatch to SearchService for FTS queries or RoamRepository for structural queries:

  • performSearch() — dispatches to the appropriate FTS method based on mode, then enriches results with tags

  • searchByFilename() — uses repository.searchNodesByFilePath() with LIKE matching

  • searchByTags() — intersects node ID sets for all selected tags, optionally filtering by title query

  • loadRecentNodes() — loads pinned nodes (ranked highest) then recent nodes from SearchService

SearchScreen Composable

SearchScreen renders the full search interface: search input bar with clear button, mode filter chips row (with result count), tag filter row when in TAGS mode, loading indicator, empty states (no directory, no results, no tags), and a LazyColumn of SearchResultCard items with long-press context menus.

It receives an optional SearchResultActions bundle from FindScreen for the cross-mode context menu (Center on Graph, Pin/Unpin).

State is collected from SearchViewModel via collectAsState(), and directory presence is checked from AppPreferences.

SearchScreen Source Code

The complete Kotlin source file containing the SearchScreen composable and its private composables: NoDirectoryEmptyState, TagFilterRow, SearchResultCard, and snippet-title helpers.

searchscreen-preamble

kotlin#+name: searchscreen-preamble
package computer.whatthefuck.arcology.app.ui.screens

import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Clear
import androidx.compose.material.icons.filled.FolderOpen
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import computer.whatthefuck.arcology.app.data.AppPreferences
import computer.whatthefuck.arcology.app.ui.components.stripLinksToSourceOnly
import computer.whatthefuck.arcology.app.viewmodel.SearchMode
import computer.whatthefuck.arcology.app.viewmodel.SearchResultItem
import computer.whatthefuck.arcology.app.viewmodel.SearchViewModel
import computer.whatthefuck.arcology.app.viewmodel.TagWithCount
import computer.whatthefuck.arcology.search.SearchSource
import org.koin.androidx.compose.koinViewModel
import org.koin.compose.koinInject

searchscreen-searchresultactions

kotlin#+name: searchscreen-searchresultactions
/**
 * Context menu actions for search results.
 */
data class SearchResultActions(
    val onOpen: (nodeId: String) -> Unit,
    val onCenterOnGraph: ((nodeId: String) -> Unit)? = null,
    val onPin: ((nodeId: String) -> Unit)? = null,
    val onUnpin: ((nodeId: String) -> Unit)? = null,
    val isPinned: ((nodeId: String) -> Boolean)? = null
)

searchscreen-composable

kotlin#+name: searchscreen-composable

@Composable
fun SearchScreen(
    viewModel: SearchViewModel = koinViewModel(),
    onNodeSelected: (nodeId: String) -> Unit,
    onNavigateToIndexing: (() -> Unit)? = null,
    resultActions: SearchResultActions? = null
) {
    val appPreferences: AppPreferences = koinInject()
    val query by viewModel.query.collectAsState()
    val results by viewModel.searchResults.collectAsState()
    val isSearching by viewModel.isSearching.collectAsState()
    val searchMode by viewModel.searchMode.collectAsState()
    val selectedTags by viewModel.selectedTags.collectAsState()
    val availableTags by viewModel.availableTags.collectAsState()
    val hasDirectory = appPreferences.getSelectedDirectoryUri() != null

    Column(
        modifier = Modifier
            .fillMaxSize()
            .imePadding()
            .padding(horizontal = 12.dp, vertical = 8.dp)
    ) {
        // Compact search bar with mode chips inline
        Row(
            modifier = Modifier.fillMaxWidth(),
            verticalAlignment = Alignment.CenterVertically,
            horizontalArrangement = Arrangement.spacedBy(8.dp)
        ) {
            // Search input
            Surface(
                modifier = Modifier.weight(1f),
                shape = MaterialTheme.shapes.small,
                color = MaterialTheme.colorScheme.surfaceVariant
            ) {
                Row(
                    modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
                    verticalAlignment = Alignment.CenterVertically
                ) {
                    Icon(
                        Icons.Default.Search,
                        contentDescription = null,
                        modifier = Modifier.size(20.dp),
                        tint = MaterialTheme.colorScheme.onSurfaceVariant
                    )
                    BasicTextField(
                        value = query,
                        onValueChange = viewModel::updateQuery,
                        modifier = Modifier
                            .weight(1f)
                            .padding(horizontal = 8.dp),
                        textStyle = TextStyle(
                            fontSize = 16.sp,
                            color = MaterialTheme.colorScheme.onSurface
                        ),
                        cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
                        singleLine = true,
                        decorationBox = { innerTextField ->
                            Box {
                                if (query.isEmpty()) {
                                    Text(
                                        "Search...",
                                        color = MaterialTheme.colorScheme.onSurfaceVariant,
                                        fontSize = 16.sp
                                    )
                                }
                                innerTextField()
                            }
                        }
                    )
                    if (query.isNotEmpty()) {
                        IconButton(
                            onClick = { viewModel.updateQuery("") },
                            modifier = Modifier.size(20.dp)
                        ) {
                            Icon(
                                Icons.Default.Clear,
                                contentDescription = "Clear",
                                modifier = Modifier.size(16.dp)
                            )
                        }
                    }
                }
            }
        }

        // Mode chips - compact single row
        Row(
            modifier = Modifier
                .fillMaxWidth()
                .padding(vertical = 6.dp)
                .horizontalScroll(rememberScrollState()),
            horizontalArrangement = Arrangement.spacedBy(6.dp)
        ) {
            SearchMode.entries.forEach { mode ->
                FilterChip(
                    selected = searchMode == mode,
                    onClick = { viewModel.setSearchMode(mode) },
                    label = {
                        Text(
                            text = when (mode) {
                                SearchMode.COMBINED -> "All"
                                SearchMode.TITLE -> "Title"
                                SearchMode.CONTENT -> "Content"
                                SearchMode.FILENAME -> "File"
                                SearchMode.TAGS -> "Tags"
                            },
                            fontSize = 12.sp
                        )
                    },
                    modifier = Modifier.height(28.dp)
                )
            }

            // Show result count inline
            if (results.isNotEmpty()) {
                Text(
                    text = "${results.size} results",
                    style = MaterialTheme.typography.labelSmall,
                    color = MaterialTheme.colorScheme.onSurfaceVariant,
                    modifier = Modifier.align(Alignment.CenterVertically)
                )
            }
        }

        // Tag filter row - only when in tag mode
        if (searchMode == SearchMode.TAGS || selectedTags.isNotEmpty()) {
            TagFilterRow(
                availableTags = availableTags,
                selectedTags = selectedTags,
                onTagToggle = viewModel::toggleTag,
                onClearAll = viewModel::clearSelectedTags
            )
        }

        // Loading indicator
        if (isSearching) {
            LinearProgressIndicator(
                modifier = Modifier
                    .fillMaxWidth()
                    .padding(vertical = 2.dp)
            )
        }

        // Empty state
        if (results.isEmpty() && !isSearching) {
            if (!hasDirectory && query.isEmpty() && selectedTags.isEmpty()) {
                // No directory configured
                NoDirectoryEmptyState(onNavigateToIndexing = onNavigateToIndexing)
            } else {
                Text(
                    text = when {
                        query.isEmpty() && selectedTags.isEmpty() -> "No notes indexed yet"
                        searchMode == SearchMode.TAGS && selectedTags.isEmpty() -> "Select tags"
                        else -> "No results"
                    },
                    modifier = Modifier.padding(top = 8.dp),
                    style = MaterialTheme.typography.bodySmall,
                    color = MaterialTheme.colorScheme.onSurfaceVariant
                )
            }
        }

        // Build effective actions (merge with default open action)
        val effectiveActions = remember(resultActions) {
            resultActions ?: SearchResultActions(onOpen = onNodeSelected)
        }

        // Results list
        LazyColumn(
            modifier = Modifier.fillMaxSize(),
            contentPadding = PaddingValues(vertical = 4.dp),
            verticalArrangement = Arrangement.spacedBy(6.dp)
        ) {
            items(results, key = { it.nodeId }) { result ->
                SearchResultCard(
                    result = result,
                    actions = effectiveActions
                )
            }
        }
    }
}

searchscreen-nodirectory

kotlin#+name: searchscreen-nodirectory

@Composable
private fun NoDirectoryEmptyState(
    onNavigateToIndexing: (() -> Unit)?
) {
    Column(
        modifier = Modifier
            .fillMaxWidth()
            .padding(32.dp),
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.spacedBy(16.dp)
    ) {
        Icon(
            imageVector = Icons.Default.FolderOpen,
            contentDescription = null,
            modifier = Modifier.size(48.dp),
            tint = MaterialTheme.colorScheme.onSurfaceVariant
        )
        Text(
            text = "No notes folder selected",
            style = MaterialTheme.typography.titleMedium,
            textAlign = TextAlign.Center
        )
        Text(
            text = if (onNavigateToIndexing != null) {
                "Select a folder containing your .org files to get started."
            } else {
                "Go to the Indexing tab to select a folder containing your .org files."
            },
            style = MaterialTheme.typography.bodyMedium,
            textAlign = TextAlign.Center,
            color = MaterialTheme.colorScheme.onSurfaceVariant
        )
        if (onNavigateToIndexing != null) {
            Button(onClick = onNavigateToIndexing) {
                Text("Set Up")
            }
        }
    }
}

searchscreen-tagfilter

kotlin#+name: searchscreen-tagfilter

@Composable
private fun TagFilterRow(
    availableTags: List<TagWithCount>,
    selectedTags: Set<String>,
    onTagToggle: (String) -> Unit,
    onClearAll: () -> Unit
) {
    Row(
        modifier = Modifier
            .fillMaxWidth()
            .padding(bottom = 6.dp)
            .horizontalScroll(rememberScrollState()),
        horizontalArrangement = Arrangement.spacedBy(6.dp),
        verticalAlignment = Alignment.CenterVertically
    ) {
        if (selectedTags.isNotEmpty()) {
            TextButton(
                onClick = onClearAll,
                modifier = Modifier.height(28.dp),
                contentPadding = PaddingValues(horizontal = 8.dp, vertical = 0.dp)
            ) {
                Text("Clear", fontSize = 12.sp)
            }
        }
        availableTags.take(15).forEach { tagWithCount ->
            FilterChip(
                selected = tagWithCount.tag in selectedTags,
                onClick = { onTagToggle(tagWithCount.tag) },
                label = {
                    Text(
                        "${tagWithCount.tag} ${tagWithCount.count}",
                        fontSize = 11.sp
                    )
                },
                modifier = Modifier.height(26.dp)
            )
        }
    }
}

searchscreen-resultcard

kotlin#+name: searchscreen-resultcard

/**
 * Search result rendered as a compact Quiz-style card (16dp corners, low
 * elevation). Layers, top to bottom: title + metadata badges, highlighted
 * content snippet (when the body mentions the query), tag pills, and an
 * Arcology publish key footer for published nodes. The file path is not
 * shown — it's navigational noise on the card; FILENAME search still
 * filters on it. Long-press opens the cross-mode context menu.
 */
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun SearchResultCard(
    result: SearchResultItem,
    actions: SearchResultActions
) {
    var showContextMenu by remember { mutableStateOf(false) }
    val isPinned = actions.isPinned?.invoke(result.nodeId) ?: false

    Box {
        Card(
            modifier = Modifier
                .fillMaxWidth()
                .combinedClickable(
                    onClick = { actions.onOpen(result.nodeId) },
                    onLongClick = { showContextMenu = true }
                ),
            shape = RoundedCornerShape(16.dp),
            elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
        ) {
            Column(
                modifier = Modifier
                    .fillMaxWidth()
                    .padding(horizontal = 12.dp, vertical = 10.dp)
            ) {
                // Title with pin indicator and match-source badge
                Row(
                    modifier = Modifier.fillMaxWidth(),
                    verticalAlignment = Alignment.CenterVertically
                ) {
                    Text(
                        text = result.title.stripLinksToSourceOnly(),
                        style = MaterialTheme.typography.bodyLarge,
                        maxLines = 1,
                        overflow = TextOverflow.Ellipsis,
                        modifier = Modifier.weight(1f)
                    )
                    if (isPinned) {
                        Text(
                            text = "\u2605", // Star character
                            style = MaterialTheme.typography.labelSmall,
                            color = MaterialTheme.colorScheme.tertiary,
                            modifier = Modifier.padding(start = 4.dp)
                        )
                    }
                    result.matchSource?.let { source ->
                        Badge(
                            containerColor = when (source) {
                                SearchSource.TITLE_METADATA -> MaterialTheme.colorScheme.secondaryContainer
                                SearchSource.CONTENT -> MaterialTheme.colorScheme.tertiaryContainer
                            },
                            modifier = Modifier.padding(start = 6.dp)
                        ) {
                            Text(
                                text = when (source) {
                                    SearchSource.TITLE_METADATA -> "T"
                                    SearchSource.CONTENT -> "C"
                                },
                                style = MaterialTheme.typography.labelSmall,
                                fontWeight = FontWeight.Bold
                            )
                        }
                    }
                }

                // Highlighted content snippet (FTS5 snippet window)
                if (result.snippet != null) {
                    Text(
                        text = snippetAnnotatedString(result.snippet),
                        style = MaterialTheme.typography.bodySmall,
                        color = MaterialTheme.colorScheme.onSurfaceVariant,
                        maxLines = 1,
                        overflow = TextOverflow.Ellipsis,
                        modifier = Modifier.padding(top = 2.dp)
                    )
                }

                // Tag pills (display-only)
                if (result.tags.isNotEmpty()) {
                    Row(
                        modifier = Modifier
                            .fillMaxWidth()
                            .padding(top = 6.dp),
                        horizontalArrangement = Arrangement.spacedBy(6.dp),
                        verticalAlignment = Alignment.CenterVertically
                    ) {
                        result.tags.take(3).forEach { tag ->
                            TagPill(tag)
                        }
                        if (result.tags.size > 3) {
                            Text(
                                text = "+${result.tags.size - 3}",
                                style = MaterialTheme.typography.labelSmall,
                                color = MaterialTheme.colorScheme.onSurfaceVariant
                            )
                        }
                    }
                }

                // Footer: Arcology publish state (or plain filename-derived
                // nothing — unpublished cards end here, footer row collapses)
                result.publishKey?.let { key ->
                    Row(
                        modifier = Modifier
                            .fillMaxWidth()
                            .padding(top = 4.dp),
                        verticalAlignment = Alignment.CenterVertically
                    ) {
                        Text(
                            text = "\u26ac $key", // Globe marker + site/key route
                            style = MaterialTheme.typography.labelSmall,
                            color = MaterialTheme.colorScheme.primary,
                            maxLines = 1,
                            overflow = TextOverflow.Ellipsis
                        )
                    }
                }
            }
        }

        // Context menu
        DropdownMenu(
            expanded = showContextMenu,
            onDismissRequest = { showContextMenu = false },
            offset = DpOffset(16.dp, 0.dp)
        ) {
            DropdownMenuItem(
                text = { Text("Open") },
                onClick = {
                    showContextMenu = false
                    actions.onOpen(result.nodeId)
                }
            )
            actions.onCenterOnGraph?.let { onCenter ->
                DropdownMenuItem(
                    text = { Text("Center on Graph") },
                    onClick = {
                        showContextMenu = false
                        onCenter(result.nodeId)
                    }
                )
            }
            if (isPinned) {
                actions.onUnpin?.let { onUnpin ->
                    DropdownMenuItem(
                        text = { Text("Unpin") },
                        onClick = {
                            showContextMenu = false
                            onUnpin(result.nodeId)
                        }
                    )
                }
            } else {
                actions.onPin?.let { onPin ->
                    DropdownMenuItem(
                        text = { Text("Pin to Graph") },
                        onClick = {
                            showContextMenu = false
                            onPin(result.nodeId)
                        }
                    )
                }
            }
        }
    }
}

/**
 * Small non-interactive tag label — a rounded surface pill rather than a
 * FilterChip, since tapping is not wired (tag filtering happens through the
 * row above the results list).
 */
@Composable
private fun TagPill(tag: String) {
    Surface(
        shape = RoundedCornerShape(8.dp),
        color = MaterialTheme.colorScheme.surfaceVariant
    ) {
        Text(
            text = tag,
            style = MaterialTheme.typography.labelSmall,
            color = MaterialTheme.colorScheme.onSurfaceVariant,
            maxLines = 1,
            modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp)
        )
    }
}

private const val SNIPPET_HL_START = "<hl>"
private const val SNIPPET_HL_END = "</hl>"

/**
 * Convert an FTS5 snippet() window into an AnnotatedString with matched terms
 * rendered bold in the primary color. Newlines collapse to spaces; missing or
 * unbalanced <hl> markers degrade to plain text with markers stripped so a
 * format change in the SQL layer can never leak raw markup into the UI.
 */
@Composable
private fun snippetAnnotatedString(snippet: String): AnnotatedString {
    val highlightColor = MaterialTheme.colorScheme.primary
    return remember(snippet, highlightColor) {
        buildAnnotatedString {
            val plain = snippet.replace('\n', ' ')
            var i = 0
            while (i <= plain.length) {
                val start = plain.indexOf(SNIPPET_HL_START, i)
                if (start == -1) {
                    append(plain.substring(i).replace(SNIPPET_HL_END, ""))
                    break
                }
                append(plain.substring(i, start))
                val end = plain.indexOf(SNIPPET_HL_END, start + SNIPPET_HL_START.length)
                if (end == -1) {
                    // Unbalanced open marker: strip and render remainder plain
                    append(
                        plain.substring(start + SNIPPET_HL_START.length)
                            .replace(SNIPPET_HL_END, "")
                    )
                    break
                }
                pushStyle(
                    SpanStyle(fontWeight = FontWeight.Bold, color = highlightColor)
                )
                append(plain.substring(start + SNIPPET_HL_START.length, end))
                pop()
                i = end + SNIPPET_HL_END.length
            }
        }
    }
}

SearchViewModel Source Code

searchviewmodel-preamble

kotlin#+name: searchviewmodel-preamble
package computer.whatthefuck.arcology.app.viewmodel

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import computer.whatthefuck.arcology.database.RoamRepository
import computer.whatthefuck.arcology.database.QuizRepository
import computer.whatthefuck.arcology.domain.OrgNode
import computer.whatthefuck.arcology.search.SearchService
import computer.whatthefuck.arcology.search.SearchResult
import computer.whatthefuck.arcology.search.SearchSource
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking

/**
 * Heading/file property whose presence marks a node as published to an
 * Arcology site at the given site/key route.
 */
const val PUBLISH_KEY_PROPERTY = "ARCOLOGY_KEY"

SearchViewModel Assembly

kotlin#+name: searchviewmodel-full:tangle ../app/src/main/kotlin/computer/whatthefuck/arcology/app/viewmodel/SearchViewModel.kt:noweb yes
<<searchviewmodel-preamble>>

<<search-screen-searchmode>>

<<search-screen-models>>

<<search-screen-viewmodel-state>>

<<search-screen-viewmodel-control>>

<<search-screen-viewmodel-backlinks>>

<<search-screen-viewmodel-enrichment>>

<<search-screen-viewmodel-search>>

SearchScreen Assembly

kotlin#+name: searchscreen-full:tangle ../app/src/main/kotlin/computer/whatthefuck/arcology/app/ui/screens/SearchScreen.kt:noweb yes
<<searchscreen-preamble>>

<<searchscreen-searchresultactions>>

<<searchscreen-composable>>

<<searchscreen-nodirectory>>

<<searchscreen-tagfilter>>

<<searchscreen-resultcard>>

Tests

SearchViewModelTest is a JUnit 4 test class using a StandardTestDispatcher for coroutine control, MockK for mocking SearchService and RoamRepository, and Kotest matchers for assertions.

The test strategy is unit-level: validate state flows react correctly to user actions (updateQuery, setSearchMode, toggleTag, clearSelectedTags, searchForTag, refresh), verify the debounced search pipeline produces expected results, and confirm that empty queries load recent nodes.

Test Double Pattern

MockK relaxed mocks (mockk(relaxed = true)) are used for both SearchService and RoamRepository. The setup block configures default returns (empty lists) that individual tests override with specific results via coEvery.

SearchService tests

SearchServiceTest is an integration-style test using DescribeSpec with a real RoamRepository (via DatabaseTestUtils), TestFileSystem, and FlowFileIndexer. It indexes sample org files, then verifies that FTS queries return ranked results (including combined search with content fallback). The empty-query tests validate that blank and whitespace-only inputs return empty.

It uses the Shared Test Fixtures.

kotlin#+name: searchservice-test:tangle ../src/commonTest/kotlin/computer/whatthefuck/arcology/search/SearchServiceTest.kt
package computer.whatthefuck.arcology.search

import computer.whatthefuck.arcology.database.DatabaseTestUtils
import computer.whatthefuck.arcology.fixtures.SampleOrgFiles
import computer.whatthefuck.arcology.indexer.FlowFileIndexer
import computer.whatthefuck.arcology.indexer.TestFileSystem
import computer.whatthefuck.arcology.parser.OrgFileParser
import io.kotest.core.spec.style.DescribeSpec
import io.kotest.matchers.collections.shouldHaveSize
import io.kotest.matchers.shouldBe
import io.kotest.matchers.shouldNotBe

class SearchServiceTest : DescribeSpec({

    describe("SearchService") {

        it("should perform basic FTS search on indexed data") {
            val repository = DatabaseTestUtils.createTestRepository()
            val quizRepository = DatabaseTestUtils.createTestQuizRepository()
            val fileSystem = TestFileSystem()
            val indexer = FlowFileIndexer(repository, fileSystem = fileSystem)
            val searchService = SearchService(repository)

            // Add test files with searchable content
            fileSystem.addFile("/test/sample1.org", SampleOrgFiles.SIMPLE_ORG_CONTENT)
            fileSystem.addFile("/test/sample2.org", SampleOrgFiles.COMPLEX_ORG_CONTENT)

            // Index the files
            indexer.indexDirectory("/test")

            // Perform search
            val results = searchService.searchPrimary("Simple")

            // Verify results
            results shouldHaveSize 1
            results.first().node.title shouldBe "Simple Heading"
            results.first().rank shouldNotBe 0.0
            results.first().source shouldBe SearchSource.TITLE_METADATA

            // Clean up
            DatabaseTestUtils.clearDatabase(repository)
        }

        it("should handle combined search with content fallback") {
            val repository = DatabaseTestUtils.createTestRepository()
            val quizRepository = DatabaseTestUtils.createTestQuizRepository()
            val fileSystem = TestFileSystem()
            val indexer = FlowFileIndexer(repository, fileSystem = fileSystem)
            val searchService = SearchService(repository)

            // Add test files
            fileSystem.addFile("/test/sample1.org", SampleOrgFiles.SIMPLE_ORG_CONTENT)
            fileSystem.addFile("/test/sample2.org", SampleOrgFiles.COMPLEX_ORG_CONTENT)

            // Index the files
            indexer.indexDirectory("/test")

            // Perform combined search - search for "Project" which is in COMPLEX_ORG_CONTENT
            val results = searchService.searchCombined("Project")

            // Should find results
            results.size shouldBe 1
            results.first().node.title shouldNotBe ""

            // Snippet regression: every content-source match must carry an FTS5
            // snippet window (and title matches whose body mentions the query
            // get one via searchPrimary's side-query)
            if (results.first().source == SearchSource.CONTENT) {
                results.first().snippet shouldNotBe null
            }

            // Clean up
            DatabaseTestUtils.clearDatabase(repository)
        }

        it("should attach content snippets to title-matched nodes") {
            val repository = DatabaseTestUtils.createTestRepository()
            val quizRepository = DatabaseTestUtils.createTestQuizRepository()
            val fileSystem = TestFileSystem()
            val indexer = FlowFileIndexer(repository, fileSystem = fileSystem)
            val searchService = SearchService(repository)

            // A node whose body mentions the query; the same word appears in the title
            fileSystem.addFile(
                "/test/snippets.org",
                """
                #+TITLE: Snippet Test File
                * Research Phase
                :PROPERTIES:
                :ID: research-snippet-id
                :END:
                The research project is about archeology and the arcology project planning.
                """.trimIndent()
            )
            indexer.indexDirectory("/test")

            // Title contains "Research"; body mentions "research" too
            val results = searchService.searchPrimary("Research")

            results.size shouldBe 1
            results.first().source shouldBe SearchSource.TITLE_METADATA
            // The content-table MATCH also hit this node, so the snippet is filled
            results.first().snippet shouldNotBe null
            results.first().snippet shouldNotBe ""
            // markers ride through to the UI layer intact
            (results.first().snippet ?: "").contains("research") shouldBe true

            // Clean up
            DatabaseTestUtils.clearDatabase(repository)
        }

        it("should handle empty queries gracefully") {
            val repository = DatabaseTestUtils.createTestRepository()
            val searchService = SearchService(repository)

            // Test various empty query formats
            searchService.searchPrimary("") shouldHaveSize 0
            searchService.searchPrimary("   ") shouldHaveSize 0
            searchService.searchCombined("") shouldHaveSize 0
            searchService.searchSimple("") shouldHaveSize 0

            // Clean up
            DatabaseTestUtils.clearDatabase(repository)
        }

        it("should perform simple search without ranking") {
            val repository = DatabaseTestUtils.createTestRepository()
            val quizRepository = DatabaseTestUtils.createTestQuizRepository()
            val fileSystem = TestFileSystem()
            val indexer = FlowFileIndexer(repository, fileSystem = fileSystem)
            val searchService = SearchService(repository)

            // Add test files
            fileSystem.addFile("/test/sample1.org", SampleOrgFiles.SIMPLE_ORG_CONTENT)

            // Index the files
            indexer.indexDirectory("/test")

            // Perform simple search
            val results = searchService.searchSimple("Simple")

            // Verify results
            results shouldHaveSize 1
            results.first().title shouldBe "Simple Heading"

            // Clean up
            DatabaseTestUtils.clearDatabase(repository)
        }
    }
})

SearchViewModel tests

kotlin#+name: searchviewmodel-test:tangle ../app/src/test/kotlin/computer/whatthefuck/arcology/app/viewmodel/SearchViewModelTest.kt
package computer.whatthefuck.arcology.app.viewmodel

import computer.whatthefuck.arcology.database.RoamRepository
import computer.whatthefuck.arcology.database.QuizRepository
import computer.whatthefuck.arcology.domain.OrgNode
import computer.whatthefuck.arcology.search.SearchResult
import computer.whatthefuck.arcology.search.SearchService
import computer.whatthefuck.arcology.search.SearchSource
import io.kotest.matchers.shouldBe
import io.kotest.matchers.collections.shouldBeEmpty
import io.kotest.matchers.collections.shouldHaveSize
import io.mockk.coEvery
import io.mockk.mockk
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.junit.After
import org.junit.Before
import org.junit.Test

@OptIn(ExperimentalCoroutinesApi::class)
class SearchViewModelTest {

    private val testDispatcher = StandardTestDispatcher()
    private lateinit var searchService: SearchService
    private lateinit var repository: RoamRepository
    private lateinit var quizRepository: QuizRepository

    @Before
    fun setup() {
        Dispatchers.setMain(testDispatcher)
        searchService = mockk(relaxed = true)
        repository = mockk(relaxed = true)
        quizRepository = mockk(relaxed = true)
        coEvery { quizRepository.getTagsForFlashcardNodesWithCount() } returns emptyList()
        coEvery { repository.getNodesByPropertyKey(any()) } returns emptyList()
        coEvery { searchService.getRecentNodes(any()) } returns emptyList()
    }

    @After
    fun tearDown() {
        Dispatchers.resetMain()
    }

    @Test
    fun `initial query is empty`() = runTest {
        val viewModel = SearchViewModel(searchService, repository, quizRepository)
        viewModel.query.value shouldBe ""
    }

    @Test
    fun `initial search mode is COMBINED`() = runTest {
        val viewModel = SearchViewModel(searchService, repository, quizRepository)
        viewModel.searchMode.value shouldBe SearchMode.COMBINED
    }

    @Test
    fun `initial selected tags is empty`() = runTest {
        val viewModel = SearchViewModel(searchService, repository, quizRepository)
        viewModel.selectedTags.value shouldBe emptySet()
    }

    @Test
    fun `updateQuery updates query state`() = runTest {
        val viewModel = SearchViewModel(searchService, repository, quizRepository)
        viewModel.updateQuery("test query")
        viewModel.query.value shouldBe "test query"
    }

    @Test
    fun `setSearchMode updates mode`() = runTest {
        val viewModel = SearchViewModel(searchService, repository, quizRepository)
        viewModel.setSearchMode(SearchMode.TITLE)
        viewModel.searchMode.value shouldBe SearchMode.TITLE
    }

    @Test
    fun `toggleTag adds tag when not present`() = runTest {
        val viewModel = SearchViewModel(searchService, repository, quizRepository)
        viewModel.toggleTag("project")
        viewModel.selectedTags.value shouldBe setOf("project")
    }

    @Test
    fun `toggleTag removes tag when already present`() = runTest {
        val viewModel = SearchViewModel(searchService, repository, quizRepository)
        viewModel.toggleTag("project")
        viewModel.toggleTag("project")
        viewModel.selectedTags.value shouldBe emptySet()
    }

    @Test
    fun `toggleTag accumulates multiple tags`() = runTest {
        val viewModel = SearchViewModel(searchService, repository, quizRepository)
        viewModel.toggleTag("project")
        viewModel.toggleTag("work")
        viewModel.selectedTags.value shouldBe setOf("project", "work")
    }

    @Test
    fun `clearSelectedTags empties the set`() = runTest {
        val viewModel = SearchViewModel(searchService, repository, quizRepository)
        viewModel.toggleTag("project")
        viewModel.toggleTag("work")
        viewModel.clearSelectedTags()
        viewModel.selectedTags.value shouldBe emptySet()
    }

    @Test
    fun `searchForTag sets mode to TAGS and selects single tag`() = runTest {
        val viewModel = SearchViewModel(searchService, repository, quizRepository)
        viewModel.searchForTag("important")
        viewModel.searchMode.value shouldBe SearchMode.TAGS
        viewModel.selectedTags.value shouldBe setOf("important")
    }

    @Test
    fun `searchForTag replaces previously selected tags`() = runTest {
        val viewModel = SearchViewModel(searchService, repository, quizRepository)
        viewModel.toggleTag("old-tag")
        viewModel.searchForTag("new-tag")
        viewModel.selectedTags.value shouldBe setOf("new-tag")
    }

    @Test
    fun `searchResults returns results after debounce`() = runTest {
        val mockNode = OrgNode(
            id = "test-id",
            file = "/test.org",
            level = 1,
            position = 0,
            title = "Test Node"
        )
        coEvery {
            searchService.searchCombined(any(), any(), any())
        } returns listOf(
            SearchResult(mockNode, 1.0, SearchSource.TITLE_METADATA)
        )
        coEvery { repository.getTagsByNode("test-id") } returns listOf("tag1")

        val viewModel = SearchViewModel(searchService, repository, quizRepository)
        advanceUntilIdle() // let init complete

        viewModel.updateQuery("test")
        advanceTimeBy(350) // past 300ms debounce
        advanceUntilIdle()

        val results = viewModel.searchResults.value
        results shouldHaveSize 1
        results.first().title shouldBe "Test Node"
        results.first().tags shouldBe listOf("tag1")
    }

    @Test
    fun `empty query loads recent nodes`() = runTest {
        val recentNode = OrgNode(
            id = "recent-id",
            file = "/recent.org",
            level = 1,
            position = 0,
            title = "Recent Node"
        )
        coEvery { searchService.getRecentNodes(any()) } returns listOf(
            SearchResult(recentNode, 0.0, SearchSource.TITLE_METADATA)
        )
        coEvery { repository.getTagsByNode("recent-id") } returns emptyList()

        val viewModel = SearchViewModel(searchService, repository, quizRepository)
        advanceTimeBy(350)
        advanceUntilIdle()

        val results = viewModel.searchResults.value
        results shouldHaveSize 1
        results.first().nodeId shouldBe "recent-id"
    }

    @Test
    fun `refresh increments trigger and reloads tags`() = runTest {
        coEvery { quizRepository.getTagsForFlashcardNodesWithCount() } returns listOf("project" to 5L)

        val viewModel = SearchViewModel(searchService, repository, quizRepository)
        advanceUntilIdle()

        viewModel.refresh()
        advanceUntilIdle()

        viewModel.availableTags.value shouldHaveSize 1
        viewModel.availableTags.value.first().tag shouldBe "project"
        viewModel.availableTags.value.first().count shouldBe 5L
    }

    @Test
    fun `searchResults carries snippet from content matches`() = runTest {
        val mockNode = OrgNode(
            id = "content-id",
            file = "/content.org",
            level = 1,
            position = 0,
            title = "Content Node"
        )
        coEvery {
            searchService.searchCombined(any(), any(), any())
        } returns listOf(
            SearchResult(
                mockNode,
                1.0,
                SearchSource.CONTENT,
                snippet = "some text with <hl>needle</hl> inside"
            )
        )
        coEvery { repository.getTagsByNode("content-id") } returns emptyList()

        val viewModel = SearchViewModel(searchService, repository, quizRepository)
        advanceUntilIdle()

        viewModel.updateQuery("needle")
        advanceTimeBy(350)
        advanceUntilIdle()

        val results = viewModel.searchResults.value
        results shouldHaveSize 1
        results.first().snippet shouldBe "some text with <hl>needle</hl> inside"
    }

    @Test
    fun `searchResults carries publish key from heading property`() = runTest {
        val mockNode = OrgNode(
            id = "pub-id",
            file = "/published.org",
            level = 1,
            position = 0,
            title = "Published Node"
        )
        coEvery { searchService.searchCombined(any(), any(), any()) } returns listOf(
            SearchResult(mockNode, 1.0, SearchSource.TITLE_METADATA)
        )
        coEvery { repository.getTagsByNode("pub-id") } returns emptyList()
        coEvery {
            repository.getNodesByPropertyKey("ARCOLOGY_KEY")
        } returns listOf("pub-id" to "garden/my-page")

        val viewModel = SearchViewModel(searchService, repository, quizRepository)
        advanceUntilIdle()

        viewModel.updateQuery("anything")
        advanceTimeBy(350)
        advanceUntilIdle()

        val results = viewModel.searchResults.value
        results shouldHaveSize 1
        results.first().publishKey shouldBe "garden/my-page"
    }

    @Test
    fun `publish key falls back to file properties when heading key missing`() = runTest {
        val mockNode = OrgNode(
            id = "file-key-id",
            file = "/preamble-published.org",
            level = 1,
            position = 0,
            title = "Preamble Published Node"
        )
        coEvery { searchService.searchCombined(any(), any(), any()) } returns listOf(
            SearchResult(mockNode, 1.0, SearchSource.TITLE_METADATA)
        )
        coEvery { repository.getTagsByNode("file-key-id") } returns emptyList()
        coEvery { repository.getNodesByPropertyKey("ARCOLOGY_KEY") } returns emptyList()
        // Node lookup must succeed for the file-path fallback
        coEvery { repository.getNodeById("file-key-id") } returns mockNode
        coEvery {
            repository.getFileProperties("/preamble-published.org")
        } returns mapOf("ARCOLOGY_KEY" to "lionsrear/recipes")

        val viewModel = SearchViewModel(searchService, repository, quizRepository)
        advanceUntilIdle()

        viewModel.updateQuery("anything")
        advanceTimeBy(350)
        advanceUntilIdle()

        val results = viewModel.searchResults.value
        results shouldHaveSize 1
        results.first().publishKey shouldBe "lionsrear/recipes"
    }

    @Test
    fun `publish key is null when node and file carry no ARCOLOGY_KEY`() = runTest {
        val mockNode = OrgNode(
            id = "private-id",
            file = "/private.org",
            level = 1,
            position = 0,
            title = "Private Node"
        )
        coEvery { searchService.searchCombined(any(), any(), any()) } returns listOf(
            SearchResult(mockNode, 1.0, SearchSource.TITLE_METADATA)
        )
        coEvery { repository.getTagsByNode("private-id") } returns emptyList()
        coEvery { repository.getNodesByPropertyKey("ARCOLOGY_KEY") } returns emptyList()
        coEvery { repository.getNodeById("private-id") } returns mockNode
        coEvery { repository.getFileProperties("/private.org") } returns emptyMap()

        val viewModel = SearchViewModel(searchService, repository, quizRepository)
        advanceUntilIdle()

        viewModel.updateQuery("anything")
        advanceTimeBy(350)
        advanceUntilIdle()

        val results = viewModel.searchResults.value
        results shouldHaveSize 1
        results.first().publishKey shouldBe null
    }

    @Test
    fun `search mode cycles through all modes`() = runTest {
        val viewModel = SearchViewModel(searchService, repository, quizRepository)

        SearchMode.entries.forEach { mode ->
            viewModel.setSearchMode(mode)
            viewModel.searchMode.value shouldBe mode
        }
    }
}

Related Modules

  • App Bootstrap — Koin DI provides SearchViewModel factory with SearchService and RoamRepository

  • App Data Layer — AppPreferences for directory state check

  • Graph Screen — context menu actions (Center on Graph, Pin/Unpin) wire into GraphViewModel

  • Indexer — SearchService backed by FTS indexes created during indexing

  • Models & Repository — RoamRepository for tag/node/FTS queries consumed by SearchService