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

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 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 enriches entries with org-file node counts from RoamRepository.

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.AppPreferences
import computer.whatthefuck.arcology.database.RoamRepository
import computer.whatthefuck.arcology.indexer.AndroidFileSystem
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
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 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()
}

class FilesViewModel(
    private val preferences: AppPreferences,
    private val repository: RoamRepository,
    private val fileSystemFactory: (Uri) -> AndroidFileSystem
) : ViewModel() {

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

    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
            val entries = children.map { child ->
                val nodeCount = if (!child.isDirectory && child.displayName.endsWith(".org", ignoreCase = true)) {
                    try {
                        repository.getNodesByFile(child.uri.toString()).size
                    } catch (e: Exception) {
                        0
                    }
                } else {
                    0
                }

                FileBrowserEntry(
                    documentId = child.documentId,
                    displayName = child.displayName,
                    isDirectory = child.isDirectory,
                    lastModified = child.lastModified,
                    uri = child.uri,
                    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")
        }
    }

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

Private composables: BreadcrumbRow (horizontal scrollable path chips), FileList (LazyColumn of entries), FileEntryRow (icon + name + date + node count), 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.Refresh
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
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 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
) {
    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,
                        onDirectoryClick = { entry -> viewModel.navigateToDirectory(entry) },
                        onFileClick = { entry -> onFileSelected(entry.uri.toString()) }
                    )
                }
            }
        }
    }
}

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>,
    onDirectoryClick: (FileBrowserEntry) -> Unit,
    onFileClick: (FileBrowserEntry) -> Unit
) {
    LazyColumn(
        modifier = Modifier.fillMaxSize(),
        contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp)
    ) {
        items(entries, key = { it.documentId }) { entry ->
            FileEntryRow(
                entry = entry,
                onClick = {
                    if (entry.isDirectory) {
                        onDirectoryClick(entry)
                    } else {
                        onFileClick(entry)
                    }
                }
            )
        }
    }
}

filesscreen-entryrow

kotlin#+name: filesscreen-entryrow

@Composable
private fun FileEntryRow(
    entry: FileBrowserEntry,
    onClick: () -> Unit
) {
    val dateFormat = SimpleDateFormat("MMM d, yyyy", Locale.getDefault())

    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
            if (entry.isDirectory) {
                Icon(
                    Icons.AutoMirrored.Filled.KeyboardArrowRight,
                    contentDescription = "Open",
                    modifier = Modifier.size(20.dp),
                    tint = MaterialTheme.colorScheme.onSurfaceVariant
                )
            }
        }
    }
}

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 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 LayerAppPreferences for directory URI, AndroidFileSystem for SAF access

  • Models & RepositoryRoamRepository.getNodesByFile() for node counts

  • IndexerAndroidFileSystem defined in indexer-platform