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.
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"/**
* 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
}<<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 viasearchService.searchPrimary()CONTENT— searches body content only viasearchService.searchContent()FILENAME— searches file paths via SQL LIKETAGS— filters by tag intersection
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 snippetTagWithCount— what the filter chip row displays: tag name + frequency count from the database
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.
@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 pipelinesetSearchMode()— switches between COMBINED/TITLE/CONTENT/FILENAME/TAGStoggleTag()— adds or removes a tag from the selected setclearSelectedTags()— resets the tag filtersearchForTag()— sets mode to TAGS and selects a single tag (external API for editor tag chips)refresh()— increments the trigger and reloads available tags
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.
fun getNodesWithBacklinksFromFlashcards(): List<SearchResultItem> {
return runBlocking {
try {
val nodeIds = quizRepository.getNodesWithBacklinksFromFlashcards()
nodeIds.map { nodeId ->
val node = repository.getNodeById(nodeId)
if (node != null) {
SearchResultItem(
nodeId = node.id,
title = node.title ?: "Untitled",
filePath = node.file,
tags = repository.getTagsByNode(node.id)
)
} else {
null
}
}.filterNotNull()
} catch (e: Exception) {
emptyList()
}
}
}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 tagssearchByFilename()— usesrepository.searchNodesByFilePath()with LIKE matchingsearchByTags()— intersects node ID sets for all selected tags, optionally filtering by title queryloadRecentNodes()— loads pinned nodes (ranked highest) then recent nodes fromSearchService
private suspend fun performSearch(query: String, mode: SearchMode): List<SearchResultItem> {
return try {
// Handle filename search separately (uses LIKE, not FTS)
if (mode == SearchMode.FILENAME) {
return searchByFilename(query)
}
val results: List<SearchResult> = when (mode) {
SearchMode.TITLE -> searchService.searchPrimary(query, 50)
SearchMode.CONTENT -> searchService.searchContent(query, limit = 50)
SearchMode.COMBINED -> searchService.searchCombined(query, primaryLimit = 20, totalLimit = 50)
SearchMode.FILENAME -> emptyList() // Handled above
SearchMode.TAGS -> emptyList() // Handled separately
}
results.map { searchResult ->
val tags = try {
repository.getTagsByNode(searchResult.node.id)
} catch (_: Exception) {
emptyList()
}
SearchResultItem(
nodeId = searchResult.node.id,
title = searchResult.node.title ?: "Untitled",
filePath = searchResult.node.file,
rank = searchResult.rank,
tags = tags,
matchSource = searchResult.source,
snippet = null // TODO: extract snippet from content
)
}
} catch (e: Exception) {
emptyList()
}
}
private suspend fun searchByFilename(query: String): List<SearchResultItem> {
return try {
val nodes = repository.searchNodesByFilePath(query)
nodes.map { node ->
val tags = try {
repository.getTagsByNode(node.id)
} catch (_: Exception) {
emptyList()
}
SearchResultItem(
nodeId = node.id,
title = node.title ?: "Untitled",
filePath = node.file,
tags = tags
)
}
} catch (e: Exception) {
emptyList()
}
}
private suspend fun searchByTags(tags: Set<String>, query: String): List<SearchResultItem> {
return try {
// Get all node IDs that have ALL selected tags
val nodeIdSets = tags.map { tag ->
repository.getNodesByTag(tag).toSet()
}
val matchingNodeIds = if (nodeIdSets.isEmpty()) {
emptySet()
} else {
nodeIdSets.reduce { acc, set -> acc.intersect(set) }
}
// Get node details and optionally filter by query
matchingNodeIds.mapNotNull { nodeId ->
repository.getNodeById(nodeId)?.let { node ->
val nodeTags = try {
repository.getTagsByNode(nodeId)
} catch (_: Exception) {
emptyList()
}
// If there's a query, filter by title match
if (query.isNotBlank() &&
!(node.title?.contains(query, ignoreCase = true) ?: false)) {
return@mapNotNull null
}
SearchResultItem(
nodeId = node.id,
title = node.title ?: "Untitled",
filePath = node.file,
tags = nodeTags
)
}
}.sortedBy { it.title.lowercase() }
} catch (e: Exception) {
emptyList()
}
}
private suspend fun loadRecentNodes(): List<SearchResultItem> {
return try {
// First, get pinned nodes
val pinnedNodeIds = repository.getNodesByPropertyKey(PINNED_PROPERTY_KEY)
.filter { it.second?.lowercase() == "t" || it.second?.lowercase() == "true" }
.map { it.first }
.toSet()
val pinnedResults = pinnedNodeIds.mapNotNull { nodeId ->
repository.getNodeById(nodeId)?.let { node ->
val tags = try {
repository.getTagsByNode(node.id)
} catch (_: Exception) {
emptyList()
}
SearchResultItem(
nodeId = node.id,
title = node.title ?: "Untitled",
filePath = node.file,
rank = Double.MAX_VALUE, // Highest rank to sort first
tags = tags
)
}
}.sortedBy { it.title.lowercase() }
// Then get recent nodes, excluding pinned ones
val recentResults = searchService.getRecentNodes(limit = 100)
.filter { it.node.id !in pinnedNodeIds }
.map { searchResult ->
val tags = try {
repository.getTagsByNode(searchResult.node.id)
} catch (_: Exception) {
emptyList()
}
SearchResultItem(
nodeId = searchResult.node.id,
title = searchResult.node.title ?: "Untitled",
filePath = searchResult.node.file,
rank = searchResult.rank,
tags = tags
)
}
// Combine: pinned first, then recent
pinnedResults + recentResults
} catch (e: Exception) {
emptyList()
}
}
}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
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.koinInjectsearchscreen-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
@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
@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
@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
@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
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.runBlockingSearchViewModel Assembly
<<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
<<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.
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
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
SearchViewModelfactory withSearchServiceandRoamRepositoryApp Data Layer —
AppPreferencesfor directory state checkGraph Screen — context menu actions (Center on Graph, Pin/Unpin) wire into
GraphViewModelIndexer —
SearchServicebacked by FTS indexes created during indexingModels & Repository —
RoamRepositoryfor tag/node/FTS queries consumed bySearchService