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 row shows title, file path, match source badge (T/C), tags, and an optional snippet. 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.

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 snippets.

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")

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

    /**
     * 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
                )
            }
    }

    /**
     * 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
 */
data class SearchResult(
    val node: OrgNode,
    val rank: Double, // BM25 rank (lower is better)
    val source: SearchSource
)

/**
 * 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 row: nodeId, title, filePath, rank, tags, optional matchSource (T/C badge), optional snippet

  • 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
)

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.

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 SearchResultRow 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, and SearchResultRow.

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.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.TextStyle
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)
        ) {
            items(results, key = { it.nodeId }) { result ->
                SearchResultRow(
                    result = result,
                    actions = effectiveActions,
                    onTagClick = { tag ->
                        viewModel.setSearchMode(SearchMode.TAGS)
                        viewModel.toggleTag(tag)
                    }
                )
            }
        }
    }
}

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-resultrow

kotlin#+name: searchscreen-resultrow

@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun SearchResultRow(
    result: SearchResultItem,
    actions: SearchResultActions,
    onTagClick: (String) -> Unit
) {
    var showContextMenu by remember { mutableStateOf(false) }
    val isPinned = actions.isPinned?.invoke(result.nodeId) ?: false

    Box {
        Surface(
            modifier = Modifier
                .fillMaxWidth()
                .combinedClickable(
                    onClick = { actions.onOpen(result.nodeId) },
                    onLongClick = { showContextMenu = true }
                )
                .padding(vertical = 2.dp),
            color = MaterialTheme.colorScheme.surface,
            tonalElevation = 1.dp
        ) {
            Column(
                modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp)
            ) {
                // Title with source badge inline
                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)
                    )
                    // Show pin indicator if pinned
                    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 ->
                        Text(
                            text = when (source) {
                                SearchSource.TITLE_METADATA -> "T"
                                SearchSource.CONTENT -> "C"
                            },
                            style = MaterialTheme.typography.labelSmall,
                            color = MaterialTheme.colorScheme.primary,
                            modifier = Modifier.padding(start = 4.dp)
                        )
                    }
                }

                // File + tags on same line
                Row(
                    modifier = Modifier.fillMaxWidth(),
                    verticalAlignment = Alignment.CenterVertically
                ) {
                    Text(
                        text = result.filePath.substringAfterLast("/"),
                        style = MaterialTheme.typography.bodySmall,
                        color = MaterialTheme.colorScheme.onSurfaceVariant,
                        maxLines = 1,
                        overflow = TextOverflow.Ellipsis,
                        modifier = Modifier.weight(1f, fill = false)
                    )
                    if (result.tags.isNotEmpty()) {
                        Text(
                            text = " \u00b7 " + result.tags.take(3).joinToString(" "),
                            style = MaterialTheme.typography.bodySmall,
                            color = MaterialTheme.colorScheme.tertiary,
                            maxLines = 1,
                            overflow = TextOverflow.Ellipsis,
                            modifier = Modifier.weight(1f, fill = false)
                        )
                    }
                }

                // Snippet if available
                result.snippet?.let { snippet ->
                    Text(
                        text = snippet,
                        style = MaterialTheme.typography.bodySmall,
                        color = MaterialTheme.colorScheme.onSurfaceVariant,
                        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)
                        }
                    )
                }
            }
        }
    }
}

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.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

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-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-resultrow>>

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 ""

            // 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 `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 LayerAppPreferences for directory state check

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

  • IndexerSearchService backed by FTS indexes created during indexing

  • Models & RepositoryRoamRepository for tag/node/FTS queries consumed by SearchService