Arcology Engine

Files Screen — File Browser

Contents

The files mode in the Find tab: a hierarchical SAF-based file browser with breadcrumb navigation, directory listing, node count badges for org files, a per-file "Force reindex" action, and an embedded mode for use within the Find tab. The screen composable and its ViewModel are documented together.

Introduction

FilesScreen provides a file-system browser for the user's org-roam directory using Android's Storage Access Framework (SAF). It supports two rendering modes: standalone (with its own Scaffold and TopAppBar) and embedded (when used inside =FindScreen='s segmented button container). The screen displays directories and org files with last-modified timestamps and node count badges.

FilesViewModel manages the browser hierarchy: a navigation stack of BreadcrumbItem objects, a lazy-initialized AndroidFileSystem constructed from the SAF tree URI, and a FileBrowserState sealed class with four states (NoDirectorySelected, Loading, Ready, Error). Directory entries are enriched with node counts for org files via RoamRepository.getNodesByFile().

Design Decisions

**SAF over direct file access

The file browser uses Android's Storage Access Framework (DocumentsContract via AndroidFileSystem) rather than java.io.File because Android 11+ scoped storage prevents direct filesystem access outside app-specific directories. SAF provides a permission-gated, URI-based API that survives across app restarts (the user grants persistent permission via ACTION_OPEN_DOCUMENT_TREE during onboarding).

**Embedded vs. standalone rendering

FilesScreen has an embedded: Boolean parameter (default false). In standalone mode, it renders its own Scaffold with a TopAppBar containing a back button and refresh action. In embedded mode, these controls are removed and the content renders directly — the parent FindScreen provides the containing scaffold. The FilesScreenContent composable adds a compact navigation row (back, breadcrumbs, refresh) in embedded mode.

**Lazy AndroidFileSystem initialization

AndroidFileSystem is constructed on first use (loadCurrentDirectory) rather than in init, because it needs both a Context and a SAF tree Uri that may not be available at ViewModel construction time (the ViewModel is created by Koin before the directory is selected). The fileSystemFactory parameter is a lambda that Koin provides at construction, but the actual construction is deferred until the directory URI is available.

**Node count enrichment

Each org file entry queries RoamRepository.getNodesByFile() to display a node count badge. This is a synchronous database call per entry — fast for small directories but could be slow for directories with thousands of files. An optimization opportunity would be a bulk query (getNodeCountsByPath) that returns counts for all paths in one query.

**Tree-relative paths are the database key

The index stores files by tree-relative path (notes/project.org), because AndroidFileSystem.listOrgFiles emits toRelativePath(uri). The file browser must therefore look up node counts and resolve file-open navigation with AndroidFileSystem.toRelativePath(child.uri.toString()) — the full content:// document URI never matches a stored files.path row, and every file would render 0 nodes and fail to open. FileBrowserEntry.relativePath carries that key so both the count query and onFileSelected stay consistent; FindScreen resolves the tapped file with the same getNodesByFile(relativePath) lookup.

**Force reindex action

Each org-file row has an overflow menu (trailing MoreVert icon button → "Force reindex") rather than a long-press context menu: the icon is discoverable, and the search-result long-press pattern is a possible later follow-up for this screen, not a current requirement. The row shows a 16dp CircularProgressIndicator while its document ID is in reindexingDocIds. The ViewModel builds a one-shot FileIndexingService for the selected tree (Koin-injected factory, same FlowFileIndexer + shared IndexerPluginFactory wiring as the editor), calls forceReindexFile(relativePath) — which bypasses the hash-based and failed-file skip logic and clears a stale failed-file record on success — emits a one-shot ReindexResult collected by =FilesScreenContent='s SnackbarHost, and reloads the current directory so node counts refresh.

File Browser Data Models

FileBrowserEntry represents a directory or file with SAF metadata (document ID, display name, URI, last modified timestamp, is-directory flag) plus a node count for org files. BreadcrumbItem is a navigation stack entry with a document ID and display name. FileBrowserState is a sealed class with four states mirroring the browser lifecycle.

kotlin#+name: files-models
/**
 * Represents a file or directory entry in the file browser.
 */
data class FileBrowserEntry(
    val documentId: String,
    val displayName: String,
    val isDirectory: Boolean,
    val lastModified: Long,
    val uri: Uri,
    val relativePath: String = "",  // Tree-relative path, the key stored in the index (files.path)
    val nodeCount: Int = 0  // Number of nodes in this file (for .org files)
)

/**
 * Represents a breadcrumb item for navigation.
 */
data class BreadcrumbItem(
    val documentId: String,
    val displayName: String
)

sealed class FileBrowserState {
    data object NoDirectorySelected : FileBrowserState()
    data object Loading : FileBrowserState()
    data class Ready(
        val entries: List<FileBrowserEntry>,
        val breadcrumbs: List<BreadcrumbItem>,
        val currentPath: String
    ) : FileBrowserState()
    data class Error(val message: String) : FileBrowserState()
}

FilesViewModel

FilesViewModel manages the file browser's state machine. It maintains a navigationStack of breadcrumbs (pushed on directory entry, popped on navigate-up or breadcrumb click) and delegates file system operations to AndroidFileSystem. Directory listing converts each child's SAF URI to a tree-relative path and enriches org-file entries with node counts from RoamRepository. It also owns the per-file force-reindex action: reindexingDocIds tracks in-flight rows, reindexEvents carries one-shot results for the snackbar, and completing a reindex reloads the directory.

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

import android.net.Uri
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import computer.whatthefuck.arcology.app.data.AppPreferencesInterface
import computer.whatthefuck.arcology.database.RoamRepository
import computer.whatthefuck.arcology.indexer.AndroidFileSystem
import computer.whatthefuck.arcology.indexer.FileIndexResult
import computer.whatthefuck.arcology.indexer.FileIndexingService
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch

/**
 * Represents a file or directory entry in the file browser.
 */
data class FileBrowserEntry(
    val documentId: String,
    val displayName: String,
    val isDirectory: Boolean,
    val lastModified: Long,
    val uri: Uri,
    val relativePath: String = "",  // Tree-relative path, the key stored in the index (files.path)
    val nodeCount: Int = 0  // Number of nodes in this file (for .org files)
)

/**
 * Represents a breadcrumb item for navigation.
 */
data class BreadcrumbItem(
    val documentId: String,
    val displayName: String
)

sealed class FileBrowserState {
    data object NoDirectorySelected : FileBrowserState()
    data object Loading : FileBrowserState()
    data class Ready(
        val entries: List<FileBrowserEntry>,
        val breadcrumbs: List<BreadcrumbItem>,
        val currentPath: String
    ) : FileBrowserState()
    data class Error(val message: String) : FileBrowserState()
}

/**
 * One-shot outcome of a per-file force reindex, surfaced to the UI as a
 * snackbar event.
 */
sealed class ReindexResult {
    data class Success(val displayName: String, val nodesCount: Int) : ReindexResult()
    data class Error(val displayName: String, val message: String) : ReindexResult()
}

class FilesViewModel(
    private val preferences: AppPreferencesInterface,
    private val repository: RoamRepository,
    private val fileSystemFactory: (Uri) -> AndroidFileSystem,
    private val indexingServiceFactory: (Uri) -> FileIndexingService,
    private val backgroundDispatcher: CoroutineDispatcher = Dispatchers.IO
) : ViewModel() {

    private val _state = MutableStateFlow<FileBrowserState>(FileBrowserState.Loading)
    val state: StateFlow<FileBrowserState> = _state.asStateFlow()

    // Document IDs of org files currently being force-reindexed; the file
    // list renders a progress spinner on those rows instead of the menu.
    private val _reindexingDocIds = MutableStateFlow<Set<String>>(emptySet())
    val reindexingDocIds: StateFlow<Set<String>> = _reindexingDocIds.asStateFlow()

    // One-shot reindex outcomes collected by the screen for snackbar display.
    private val _reindexEvents = MutableSharedFlow<ReindexResult>(extraBufferCapacity = 8)
    val reindexEvents: SharedFlow<ReindexResult> = _reindexEvents.asSharedFlow()

    private var fileSystem: AndroidFileSystem? = null
    private var navigationStack = mutableListOf<BreadcrumbItem>()

    init {
        loadCurrentDirectory()
    }

    private fun loadCurrentDirectory() {
        viewModelScope.launch {
            val uri = preferences.getSelectedDirectoryUri()
            if (uri == null) {
                _state.value = FileBrowserState.NoDirectorySelected
                return@launch
            }

            try {
                if (fileSystem == null) {
                    fileSystem = fileSystemFactory(uri)
                    val rootDocId = fileSystem!!.getRootDocumentId()
                    val rootName = preferences.getDisplayPath() ?: "org-roam"
                    navigationStack.clear()
                    navigationStack.add(BreadcrumbItem(rootDocId, rootName))
                }

                loadDirectory(navigationStack.last().documentId)
            } catch (e: Exception) {
                _state.value = FileBrowserState.Error(e.message ?: "Failed to access directory")
            }
        }
    }

    private suspend fun loadDirectory(documentId: String) {
        _state.value = FileBrowserState.Loading

        try {
            val fs = fileSystem ?: run {
                _state.value = FileBrowserState.NoDirectorySelected
                return
            }

            val children = fs.listChildren(documentId)

            // Convert to FileBrowserEntry and fetch node counts for .org files.
            // The index stores TREE-RELATIVE paths (AndroidFileSystem.listOrgFiles
            // emits toRelativePath), so the database key is the relative path,
            // NOT the SAF document URI.
            val entries = children.map { child ->
                val relativePath = fs.toRelativePath(child.uri.toString())
                val nodeCount = if (!child.isDirectory && child.displayName.endsWith(".org", ignoreCase = true)) {
                    try {
                        repository.getNodesByFile(relativePath).size
                    } catch (e: Exception) {
                        0
                    }
                } else {
                    0
                }

                FileBrowserEntry(
                    documentId = child.documentId,
                    displayName = child.displayName,
                    isDirectory = child.isDirectory,
                    lastModified = child.lastModified,
                    uri = child.uri,
                    relativePath = relativePath,
                    nodeCount = nodeCount
                )
            }

            val currentPath = navigationStack.joinToString(" / ") { it.displayName }

            _state.value = FileBrowserState.Ready(
                entries = entries,
                breadcrumbs = navigationStack.toList(),
                currentPath = currentPath
            )
        } catch (e: Exception) {
            _state.value = FileBrowserState.Error(e.message ?: "Failed to list directory")
        }
    }

    /**
     * Force reindex a single org file: parse and re-store it in the index,
     * bypassing the hash-based and failed-file skip logic. Shows a spinner
     * on the row while in flight, emits a one-shot ReindexResult for the
     * snackbar, and reloads the directory so node counts are fresh.
     */
    fun forceReindexFile(entry: FileBrowserEntry) {
        if (entry.isDirectory || !entry.displayName.endsWith(".org", ignoreCase = true)) return

        viewModelScope.launch(backgroundDispatcher) {
            _reindexingDocIds.value = _reindexingDocIds.value + entry.documentId
            try {
                val treeUri = preferences.getSelectedDirectoryUri()
                    ?: throw IllegalStateException("No directory selected for reindex")
                val service = indexingServiceFactory(treeUri)
                when (val result = service.forceReindexFile(entry.relativePath)) {
                    is FileIndexResult.Success ->
                        _reindexEvents.emit(
                            ReindexResult.Success(entry.displayName, result.nodesCount)
                        )
                    is FileIndexResult.Error ->
                        _reindexEvents.emit(
                            ReindexResult.Error(entry.displayName, result.message)
                        )
                    is FileIndexResult.Skipped ->
                        _reindexEvents.emit(
                            ReindexResult.Error(entry.displayName, "Reindex skipped: ${result.reason}")
                        )
                }
            } catch (e: Exception) {
                _reindexEvents.emit(
                    ReindexResult.Error(entry.displayName, e.message ?: "Reindex failed")
                )
            } finally {
                _reindexingDocIds.value = _reindexingDocIds.value - entry.documentId
                loadCurrentDirectory()
            }
        }
    }

    fun navigateToDirectory(entry: FileBrowserEntry) {
        if (!entry.isDirectory) return

        viewModelScope.launch {
            navigationStack.add(BreadcrumbItem(entry.documentId, entry.displayName))
            loadDirectory(entry.documentId)
        }
    }

    fun navigateUp() {
        if (navigationStack.size <= 1) return

        viewModelScope.launch {
            navigationStack.removeAt(navigationStack.lastIndex)
            loadDirectory(navigationStack.last().documentId)
        }
    }

    fun navigateToBreadcrumb(index: Int) {
        if (index < 0 || index >= navigationStack.size) return

        viewModelScope.launch {
            // Remove all items after the selected breadcrumb
            while (navigationStack.size > index + 1) {
                navigationStack.removeAt(navigationStack.lastIndex)
            }
            loadDirectory(navigationStack.last().documentId)
        }
    }

    fun refresh() {
        loadCurrentDirectory()
    }

    fun canNavigateUp(): Boolean = navigationStack.size > 1
}

FilesScreen Composable

FilesScreen is the entry point with the embedded flag. When standalone, it renders a Scaffold with TopAppBar. When embedded, it delegates directly to FilesScreenContent which adds a compact navigation row (back button, breadcrumbs, refresh).

FilesScreenContent handles all four FileBrowserState variants: NoDirectorySelected (icon + message), Loading (spinner), Error (message + retry button), and Ready (file list or empty-directory message). The content is a Box hosting a bottom SnackbarHost; one-shot ReindexResult events are collected via LaunchedEffect and surfaced as snackbars.

Private composables: BreadcrumbRow (horizontal scrollable path chips), FileList (LazyColumn of entries), FileEntryRow (icon + name + date + node count + reindex menu for org files), NoDirectorySelectedState, LoadingState, ErrorState, EmptyDirectoryState.

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

import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.filled.Description
import androidx.compose.material.icons.filled.Folder
import androidx.compose.material.icons.filled.FolderOpen
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import computer.whatthefuck.arcology.app.viewmodel.BreadcrumbItem
import computer.whatthefuck.arcology.app.viewmodel.FileBrowserEntry
import computer.whatthefuck.arcology.app.viewmodel.FileBrowserState
import computer.whatthefuck.arcology.app.viewmodel.FilesViewModel
import computer.whatthefuck.arcology.app.viewmodel.ReindexResult
import org.koin.androidx.compose.koinViewModel
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale

filesscreen-composable

kotlin#+name: filesscreen-composable

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun FilesScreen(
    viewModel: FilesViewModel = koinViewModel(),
    onFileSelected: (filePath: String) -> Unit,
    embedded: Boolean = false  // When true, skip the Scaffold/TopAppBar
) {
    val state by viewModel.state.collectAsState()

    if (embedded) {
        FilesScreenContent(
            state = state,
            viewModel = viewModel,
            onFileSelected = onFileSelected
        )
    } else {
        Scaffold(
            topBar = {
                TopAppBar(
                    title = { Text("Files") },
                    navigationIcon = {
                        if (viewModel.canNavigateUp()) {
                            IconButton(onClick = { viewModel.navigateUp() }) {
                                Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
                            }
                        }
                    },
                    actions = {
                        IconButton(onClick = { viewModel.refresh() }) {
                            Icon(Icons.Default.Refresh, contentDescription = "Refresh")
                        }
                    }
                )
            }
        ) { padding ->
            Column(
                modifier = Modifier
                    .fillMaxSize()
                    .padding(padding)
            ) {
                FilesScreenContent(
                    state = state,
                    viewModel = viewModel,
                    onFileSelected = onFileSelected
                )
            }
        }
    }
}

@Composable
private fun FilesScreenContent(
    state: FileBrowserState,
    viewModel: FilesViewModel,
    onFileSelected: (filePath: String) -> Unit
) {
    val snackbarHostState = remember { SnackbarHostState() }
    val reindexingDocIds by viewModel.reindexingDocIds.collectAsState()
    val reindexEvents = viewModel.reindexEvents

    LaunchedEffect(reindexEvents) {
        reindexEvents.collect { event ->
            snackbarHostState.showSnackbar(
                when (event) {
                    is ReindexResult.Success ->
                        "Reindexed ${event.displayName} (${event.nodesCount} nodes)"
                    is ReindexResult.Error ->
                        "Reindex failed for ${event.displayName}: ${event.message}"
                }
            )
        }
    }

    Box(modifier = Modifier.fillMaxSize()) {
        Column(modifier = Modifier.fillMaxSize()) {
            // Navigation row when embedded (back button + breadcrumbs + refresh)
            val currentState = state
            if (currentState is FileBrowserState.Ready) {
                Row(
                    modifier = Modifier
                        .fillMaxWidth()
                        .padding(horizontal = 8.dp, vertical = 4.dp),
                    verticalAlignment = Alignment.CenterVertically
                ) {
                    if (viewModel.canNavigateUp()) {
                        IconButton(
                            onClick = { viewModel.navigateUp() },
                            modifier = Modifier.size(32.dp)
                        ) {
                            Icon(
                                Icons.AutoMirrored.Filled.ArrowBack,
                                contentDescription = "Back",
                                modifier = Modifier.size(20.dp)
                            )
                        }
                    }
                    BreadcrumbRow(
                        breadcrumbs = currentState.breadcrumbs,
                        onBreadcrumbClick = { index -> viewModel.navigateToBreadcrumb(index) },
                        modifier = Modifier.weight(1f)
                    )
                    IconButton(
                        onClick = { viewModel.refresh() },
                        modifier = Modifier.size(32.dp)
                    ) {
                        Icon(
                            Icons.Default.Refresh,
                            contentDescription = "Refresh",
                            modifier = Modifier.size(20.dp)
                        )
                    }
                }
            }

            when (currentState) {
                is FileBrowserState.NoDirectorySelected -> {
                    NoDirectorySelectedState()
                }
                is FileBrowserState.Loading -> {
                    LoadingState()
                }
                is FileBrowserState.Error -> {
                    ErrorState(message = currentState.message, onRetry = { viewModel.refresh() })
                }
                is FileBrowserState.Ready -> {
                    if (currentState.entries.isEmpty()) {
                        EmptyDirectoryState()
                    } else {
                        FileList(
                            entries = currentState.entries,
                            reindexingDocIds = reindexingDocIds,
                            onDirectoryClick = { entry -> viewModel.navigateToDirectory(entry) },
                            onFileClick = { entry -> onFileSelected(entry.relativePath) },
                            onReindex = { entry -> viewModel.forceReindexFile(entry) }
                        )
                    }
                }
            }
        }

        SnackbarHost(
            hostState = snackbarHostState,
            modifier = Modifier.align(Alignment.BottomCenter)
        )
    }
}

filesscreen-breadcrumb

kotlin#+name: filesscreen-breadcrumb

@Composable
private fun BreadcrumbRow(
    breadcrumbs: List<BreadcrumbItem>,
    onBreadcrumbClick: (Int) -> Unit,
    modifier: Modifier = Modifier
) {
    Row(
        modifier = modifier
            .horizontalScroll(rememberScrollState())
            .padding(horizontal = 4.dp),
        verticalAlignment = Alignment.CenterVertically
    ) {
        breadcrumbs.forEachIndexed { index, item ->
            if (index > 0) {
                Icon(
                    Icons.AutoMirrored.Filled.KeyboardArrowRight,
                    contentDescription = null,
                    modifier = Modifier.size(16.dp),
                    tint = MaterialTheme.colorScheme.onSurfaceVariant
                )
            }
            TextButton(
                onClick = { onBreadcrumbClick(index) },
                contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp)
            ) {
                Text(
                    text = item.displayName,
                    style = MaterialTheme.typography.bodyMedium,
                    color = if (index == breadcrumbs.lastIndex) {
                        MaterialTheme.colorScheme.primary
                    } else {
                        MaterialTheme.colorScheme.onSurfaceVariant
                    }
                )
            }
        }
    }
}

filesscreen-filelist

kotlin#+name: filesscreen-filelist

@Composable
private fun FileList(
    entries: List<FileBrowserEntry>,
    reindexingDocIds: Set<String>,
    onDirectoryClick: (FileBrowserEntry) -> Unit,
    onFileClick: (FileBrowserEntry) -> Unit,
    onReindex: (FileBrowserEntry) -> Unit
) {
    LazyColumn(
        modifier = Modifier.fillMaxSize(),
        contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp)
    ) {
        items(entries, key = { it.documentId }) { entry ->
            FileEntryRow(
                entry = entry,
                isReindexing = entry.documentId in reindexingDocIds,
                onReindex = onReindex,
                onClick = {
                    if (entry.isDirectory) {
                        onDirectoryClick(entry)
                    } else {
                        onFileClick(entry)
                    }
                }
            )
        }
    }
}

filesscreen-entryrow

kotlin#+name: filesscreen-entryrow

@Composable
private fun FileEntryRow(
    entry: FileBrowserEntry,
    isReindexing: Boolean,
    onReindex: (FileBrowserEntry) -> Unit,
    onClick: () -> Unit
) {
    val dateFormat = SimpleDateFormat("MMM d, yyyy", Locale.getDefault())
    var menuExpanded by remember { mutableStateOf(false) }
    val isOrgFile = !entry.isDirectory && entry.displayName.endsWith(".org", ignoreCase = true)

    Surface(
        modifier = Modifier
            .fillMaxWidth()
            .clickable(onClick = onClick)
            .padding(vertical = 2.dp),
        tonalElevation = 1.dp
    ) {
        Row(
            modifier = Modifier
                .fillMaxWidth()
                .padding(12.dp),
            verticalAlignment = Alignment.CenterVertically
        ) {
            // Icon
            Icon(
                imageVector = if (entry.isDirectory) {
                    Icons.Default.Folder
                } else {
                    Icons.Default.Description
                },
                contentDescription = if (entry.isDirectory) "Directory" else "File",
                modifier = Modifier.size(24.dp),
                tint = if (entry.isDirectory) {
                    MaterialTheme.colorScheme.primary
                } else {
                    MaterialTheme.colorScheme.onSurfaceVariant
                }
            )

            Spacer(modifier = Modifier.width(12.dp))

            // Name and metadata
            Column(modifier = Modifier.weight(1f)) {
                Text(
                    text = entry.displayName,
                    style = MaterialTheme.typography.bodyLarge,
                    maxLines = 1,
                    overflow = TextOverflow.Ellipsis
                )
                Row(
                    horizontalArrangement = Arrangement.spacedBy(8.dp)
                ) {
                    if (entry.lastModified > 0) {
                        Text(
                            text = dateFormat.format(Date(entry.lastModified)),
                            style = MaterialTheme.typography.bodySmall,
                            color = MaterialTheme.colorScheme.onSurfaceVariant
                        )
                    }
                    if (!entry.isDirectory && entry.displayName.endsWith(".org", ignoreCase = true)) {
                        Text(
                            text = "${entry.nodeCount} nodes",
                            style = MaterialTheme.typography.bodySmall,
                            color = MaterialTheme.colorScheme.tertiary
                        )
                    }
                }
            }

            // Chevron for directories, overflow menu / reindex spinner for org files
            if (entry.isDirectory) {
                Icon(
                    Icons.AutoMirrored.Filled.KeyboardArrowRight,
                    contentDescription = "Open",
                    modifier = Modifier.size(20.dp),
                    tint = MaterialTheme.colorScheme.onSurfaceVariant
                )
            } else if (isOrgFile) {
                if (isReindexing) {
                    CircularProgressIndicator(modifier = Modifier.size(16.dp))
                } else {
                    Box {
                        IconButton(
                            onClick = { menuExpanded = true },
                            modifier = Modifier.size(32.dp)
                        ) {
                            Icon(
                                Icons.Default.MoreVert,
                                contentDescription = "More actions",
                                modifier = Modifier.size(20.dp),
                                tint = MaterialTheme.colorScheme.onSurfaceVariant
                            )
                        }
                        DropdownMenu(
                            expanded = menuExpanded,
                            onDismissRequest = { menuExpanded = false }
                        ) {
                            DropdownMenuItem(
                                text = { Text("Force reindex") },
                                onClick = {
                                    menuExpanded = false
                                    onReindex(entry)
                                }
                            )
                        }
                    }
                }
            }
        }
    }
}

filesscreen-states

kotlin#+name: filesscreen-states

@Composable
private fun NoDirectorySelectedState() {
    Box(
        modifier = Modifier.fillMaxSize(),
        contentAlignment = Alignment.Center
    ) {
        Column(
            horizontalAlignment = Alignment.CenterHorizontally,
            verticalArrangement = Arrangement.spacedBy(8.dp)
        ) {
            Icon(
                Icons.Default.FolderOpen,
                contentDescription = null,
                modifier = Modifier.size(48.dp),
                tint = MaterialTheme.colorScheme.onSurfaceVariant
            )
            Text(
                text = "No directory selected",
                style = MaterialTheme.typography.titleMedium
            )
            Text(
                text = "Go to Settings > Indexing to select your org-roam directory",
                style = MaterialTheme.typography.bodyMedium,
                color = MaterialTheme.colorScheme.onSurfaceVariant
            )
        }
    }
}

@Composable
private fun LoadingState() {
    Box(
        modifier = Modifier.fillMaxSize(),
        contentAlignment = Alignment.Center
    ) {
        CircularProgressIndicator()
    }
}

@Composable
private fun ErrorState(message: String, onRetry: () -> Unit) {
    Box(
        modifier = Modifier.fillMaxSize(),
        contentAlignment = Alignment.Center
    ) {
        Column(
            horizontalAlignment = Alignment.CenterHorizontally,
            verticalArrangement = Arrangement.spacedBy(8.dp)
        ) {
            Icon(
                Icons.Default.Warning,
                contentDescription = null,
                modifier = Modifier.size(48.dp),
                tint = MaterialTheme.colorScheme.error
            )
            Text(
                text = "Error loading files",
                style = MaterialTheme.typography.titleMedium
            )
            Text(
                text = message,
                style = MaterialTheme.typography.bodyMedium,
                color = MaterialTheme.colorScheme.onSurfaceVariant
            )
            Button(onClick = onRetry) {
                Text("Retry")
            }
        }
    }
}

@Composable
private fun EmptyDirectoryState() {
    Box(
        modifier = Modifier.fillMaxSize(),
        contentAlignment = Alignment.Center
    ) {
        Column(
            horizontalAlignment = Alignment.CenterHorizontally,
            verticalArrangement = Arrangement.spacedBy(8.dp)
        ) {
            Icon(
                Icons.Default.FolderOpen,
                contentDescription = null,
                modifier = Modifier.size(48.dp),
                tint = MaterialTheme.colorScheme.onSurfaceVariant
            )
            Text(
                text = "Empty directory",
                style = MaterialTheme.typography.titleMedium
            )
        }
    }
}

FilesViewModel Tests

App-side ViewModel tests per the repo conventions: test doubles over suspend-function mocks (RoamRepositoryTestDouble, AppPreferencesTestDouble), mocked Main dispatcher, mockkStatic(Log::class) because AndroidFileSystem logs. AndroidFileSystem itself is a relaxed MockK concrete-class mock stubbing listChildren and toRelativePath; per the test axioms the treeUri / entry Uri are mockk<Uri>(). A small in-file ReindexServiceDouble stands in for FileIndexingService, with the ViewModel's force-reindex launcher on an injectable backgroundDispatcher (the TaskListViewModel convention) so tests stay deterministic.

The load-bearing regression here: node counts (and the file-open navigation key) resolve through the tree-relative path the index stores — an entry's relativePath — not through the SAF content URI.

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

import android.net.Uri
import android.util.Log
import computer.whatthefuck.arcology.app.data.AppPreferencesInterface
import computer.whatthefuck.arcology.app.testutils.AppPreferencesTestDouble
import computer.whatthefuck.arcology.app.testutils.RoamRepositoryTestDouble
import computer.whatthefuck.arcology.domain.OrgNode
import computer.whatthefuck.arcology.indexer.AndroidFileSystem
import computer.whatthefuck.arcology.indexer.FileIndexingService
import computer.whatthefuck.arcology.indexer.FileIndexResult
import computer.whatthefuck.arcology.indexer.IndexProgress
import computer.whatthefuck.arcology.indexer.IndexResult
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.StandardTestDispatcher
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.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test

/**
 * Test double for FileIndexingService: records force-reindex calls and
 * returns a configurable result or exception.
 */
private class ReindexServiceDouble : FileIndexingService {
    val forceReindexCalls = mutableListOf<String>()
    var result: FileIndexResult = FileIndexResult.Success("forced.org", 5, 2, 1)
    var thrown: Exception? = null

    override fun indexDirectoryFlow(path: String, recursive: Boolean): Flow<IndexProgress> = emptyFlow()

    override suspend fun indexDirectory(path: String, recursive: Boolean): IndexResult =
        IndexResult(totalFiles = 0, successful = 0, failed = 0, skipped = 0)

    override suspend fun indexFile(filePath: String): FileIndexResult = result

    override suspend fun forceReindexFile(filePath: String): FileIndexResult {
        forceReindexCalls.add(filePath)
        thrown?.let { throw it }
        return result
    }

    override suspend fun removeFile(filePath: String): Boolean = true
}

@OptIn(ExperimentalCoroutinesApi::class)
class FilesViewModelTest {

    private val testDispatcher = StandardTestDispatcher()
    private val backgroundDispatcher = testDispatcher
    private lateinit var fileSystem: AndroidFileSystem

    @Before
    fun setup() {
        Dispatchers.setMain(testDispatcher)
        mockkStatic(Log::class)
        every { Log.i(any<String>(), any<String>()) } returns 0
        every { Log.e(any<String>(), any<String>()) } returns 0
        every { Log.e(any<String>(), any<String>(), any<Throwable>()) } returns 0
        every { Log.d(any<String>(), any<String>()) } returns 0
        fileSystem = mockk(relaxed = true)
        every { fileSystem.getRootDocumentId() } returns "primary:org-roam"
    }

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

    private fun node(id: String, file: String, position: Int) = OrgNode(
        id = id,
        file = file,
        level = 1,
        position = position,
        title = "Node $id"
    )

    /**
     * A SAF directory child for the given tree-relative path, e.g.
     * "notes/foo.org" or the directory "notes".
     */
    private fun childEntry(relativePath: String, isDirectory: Boolean): AndroidFileSystem.FileEntry =
        AndroidFileSystem.FileEntry(
            documentId = "primary:org-roam/$relativePath",
            displayName = relativePath.substringAfterLast('/'),
            isDirectory = isDirectory,
            lastModified = 1700000000000L,
            uri = mockk<Uri>(relaxed = true)
        )

    private fun createViewModel(
        repository: RoamRepositoryTestDouble,
        service: FileIndexingService
    ): FilesViewModel = FilesViewModel(
        preferences = AppPreferencesTestDouble(selectedDirectoryUri = mockk<Uri>(relaxed = true)),
        repository = repository,
        fileSystemFactory = { fileSystem },
        indexingServiceFactory = { service },
        backgroundDispatcher = backgroundDispatcher
    )

    /**
     * Regression test for the 0-nodes bug: the node-count lookup must use
     * the tree-relative path the index stores — the content URI never
     * matches a stored files.path row. The entry must also carry the
     * relative path that FindScreen resolves via getNodesByFile() when a
     * file is tapped for opening.
     */
    @Test
    fun `node count and file click resolve through tree-relative path`() = runTest(testDispatcher) {
        val repository = RoamRepositoryTestDouble().apply {
            addNode(node("n1", "notes/foo.org", 0))
            addNode(node("n2", "notes/foo.org", 1))
            addNode(node("n3", "notes/foo.org", 2))
            addNode(node("other", "other/bar.org", 1))
        }
        coEvery { fileSystem.listChildren("primary:org-roam") } returns listOf(
            childEntry("notes/foo.org", isDirectory = false)
        )
        every { fileSystem.toRelativePath(any()) } returns "notes/foo.org"

        val viewModel = createViewModel(repository, ReindexServiceDouble())
        advanceUntilIdle()

        val ready = viewModel.state.value as FileBrowserState.Ready
        val fileEntry = ready.entries.single { !it.isDirectory }
        assertEquals(3, fileEntry.nodeCount)
        assertEquals("notes/foo.org", fileEntry.relativePath)

        // The file-open contract: FindScreen queries by the relative path
        // it received from onFileSelected.
        assertEquals(3, repository.getNodesByFile(fileEntry.relativePath).size)
        // No node lookup may reach the repository keyed by the SAF content URI.
        assertTrue(repository.getNodesByFileCalls.none { it.startsWith("content://") })
    }

    @Test
    fun `force reindex calls service with relative path and reloads directory`() = runTest(testDispatcher) {
        val repository = RoamRepositoryTestDouble().apply {
            addNode(node("n1", "notes/foo.org", 1))
        }
        coEvery { fileSystem.listChildren("primary:org-roam") } returns listOf(
            childEntry("notes/foo.org", isDirectory = false)
        )
        every { fileSystem.toRelativePath(any()) } returns "notes/foo.org"
        val service = ReindexServiceDouble()
        val viewModel = createViewModel(repository, service)
        advanceUntilIdle()

        val events = mutableListOf<ReindexResult>()
        val eventJob = launch { viewModel.reindexEvents.collect { events.add(it) } }
        advanceUntilIdle()

        val fileEntry = (viewModel.state.value as FileBrowserState.Ready)
            .entries.single { !it.isDirectory }
        val callsBefore = repository.getNodesByFileCalls.size

        viewModel.forceReindexFile(fileEntry)
        advanceUntilIdle()
        eventJob.cancel()

        assertEquals(listOf("notes/foo.org"), service.forceReindexCalls)
        assertTrue(viewModel.reindexingDocIds.value.isEmpty())
        assertEquals(1, events.size)
        assertEquals("foo.org", (events[0] as ReindexResult.Success).displayName)
        assertEquals(5, (events[0] as ReindexResult.Success).nodesCount)
        // The directory reload after reindex refreshed the node counts.
        assertEquals(callsBefore + 1, repository.getNodesByFileCalls.size)
    }

    @Test
    fun `force reindex failure emits error event and clears spinner`() = runTest(testDispatcher) {
        val repository = RoamRepositoryTestDouble()
        coEvery { fileSystem.listChildren("primary:org-roam") } returns listOf(
            childEntry("foo.org", isDirectory = false)
        )
        every { fileSystem.toRelativePath(any()) } returns "foo.org"
        val service = ReindexServiceDouble().apply {
            thrown = RuntimeException("boom")
        }
        val viewModel = createViewModel(repository, service)
        advanceUntilIdle()

        val events = mutableListOf<ReindexResult>()
        val eventJob = launch { viewModel.reindexEvents.collect { events.add(it) } }
        advanceUntilIdle()

        val fileEntry = (viewModel.state.value as FileBrowserState.Ready)
            .entries.single { !it.isDirectory }

        viewModel.forceReindexFile(fileEntry)
        advanceUntilIdle()
        eventJob.cancel()

        assertEquals(1, service.forceReindexCalls.size)
        assertTrue(viewModel.reindexingDocIds.value.isEmpty())
        assertEquals(1, events.size)
        assertTrue(events[0] is ReindexResult.Error)
        assertEquals("boom", (events[0] as ReindexResult.Error).message)
    }

    @Test
    fun `reindex action applies only to org files`() = runTest(testDispatcher) {
        val repository = RoamRepositoryTestDouble()
        coEvery { fileSystem.listChildren("primary:org-roam") } returns listOf(
            childEntry("sub", isDirectory = true),
            childEntry("notes.txt", isDirectory = false)
        )
        every { fileSystem.toRelativePath(any()) } returns "notes.txt"
        val service = ReindexServiceDouble()
        val viewModel = createViewModel(repository, service)
        advanceUntilIdle()

        val ready = viewModel.state.value as FileBrowserState.Ready
        viewModel.forceReindexFile(ready.entries.first { it.isDirectory })
        viewModel.forceReindexFile(ready.entries.first { !it.isDirectory && !it.displayName.endsWith(".org") })
        advanceUntilIdle()

        assertTrue(service.forceReindexCalls.isEmpty())
    }
}

FilesViewModel Assembly

kotlin#+name: filesviewmodel-full:tangle ../app/src/main/kotlin/computer/whatthefuck/arcology/app/viewmodel/FilesViewModel.kt:noweb yes
<<files-viewmodel>>

FilesScreen Assembly

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

<<filesscreen-composable>>

<<filesscreen-breadcrumb>>

<<filesscreen-filelist>>

<<filesscreen-entryrow>>

<<filesscreen-states>>

Related Modules

  • App Bootstrap — Koin DI provides FilesViewModel with AppPreferences, file system factory

  • App Data Layer — AppPreferences for directory URI, AndroidFileSystem for SAF access

  • Models & Repository — RoamRepository.getNodesByFile() for node counts

  • Indexer — AndroidFileSystem defined in indexer-platform