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.
/**
* 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.
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.
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.Localefilesscreen-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
@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
@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
@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
@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
<<files-viewmodel>>FilesScreen Assembly
<<filesscreen-preamble>>
<<filesscreen-composable>>
<<filesscreen-breadcrumb>>
<<filesscreen-filelist>>
<<filesscreen-entryrow>>
<<filesscreen-states>>Related Modules
App Bootstrap — Koin DI provides
FilesViewModelwithAppPreferences, file system factoryApp Data Layer —
AppPreferencesfor directory URI,AndroidFileSystemfor SAF accessModels & Repository —
RoamRepository.getNodesByFile()for node countsIndexer —
AndroidFileSystemdefined in indexer-platform