Files
ai-app/app/androidApp/src/main/kotlin/com/example/aiapp/FilesScreen.kt
T
irisandClaude Opus 5 edc39c7371 Thin the app's comments
The same pass the server had, on the Kotlin side: comments restating what
the code says are gone, and the ones recording a measurement, a constraint
or an incident are kept but cut to a few lines each. 6540 comment lines to
5674, and 920 lines off the app.

Two doc comments had drifted onto the item above the one they describe --
`contextAfter`'s onto `sessionWorking` in Events.kt, and `UsageMonitor`'s
equivalent on the server was fixed in the previous commit. Each is back on
its own item, which is the only non-comment line this diff moves.

The comments are reflowed to the column limit at their own indentation:
several were written wide, and ktfmt re-wrapped them into lines holding a
single orphan word. `/tmp` script, not kept -- ktfmt is idempotent over the
result, which is the check.

Left alone deliberately: this codebase's remaining comment density is high
because the comments carry things the code cannot say -- what a null means,
what a number was measured against, which bug a guard exists for. Of the
238 one-line doc comments in the app, five were pure restatement of the
name and were removed; the rest each say something the signature does not.

ktfmtFormat, compileDebugKotlin, lintDebug and testDebugUnitTest pass;
cargo test (127), clippy --all-targets and fmt still clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 16:20:16 -04:00

689 lines
26 KiB
Kotlin

package com.example.aiapp
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* Which machine's files to show, and where to start.
*
* A **setup**, not a session: a filesystem is a property of a machine, and a session only says
* where it was working. That is what makes a second way in -- from the setups tab -- one more
* caller rather than any new code here.
*/
data class FilesTarget(val setup: String, val setupName: String, val start: String)
/** Where the explorer is: in a directory, or in one file. */
private sealed class Spot(val path: String) {
class Dir(path: String) : Spot(path)
class Doc(path: String) : Spot(path)
}
/**
* The files on the machine a session runs on: browse them, read one, change one.
*
* Drawn **over** the session rather than instead of it (see [AppRoot]), so its event stream keeps
* flowing and coming back from a file costs nothing. Back steps one level inside here -- editor to
* viewer, viewer to the directory it came from, directory to the one above -- and only closes from
* where it opened.
*
* Every directory that has been visited is kept for as long as this is open; the refresh glyph is
* how one gets asked again on purpose, and creating something refetches the directory it was
* created in.
*/
@Composable
fun FilesScreen(settings: ServerSettings, target: FilesTarget, onClose: () -> Unit) {
val scope = rememberCoroutineScope()
var stack by remember { mutableStateOf(listOf<Spot>(Spot.Dir(target.start))) }
val listings = remember { mutableStateMapOf<String, LoadState<Listing>>() }
var creating by remember { mutableStateOf(false) }
// Edit mode and whether anything has been typed live here rather than in the pane below,
// because they are what back has to know about -- and back arrives from two places, the arrow
// and the platform's own gesture, which must mean the same thing.
var editing by remember { mutableStateOf(false) }
var dirty by remember { mutableStateOf(false) }
var askUnsaved by remember { mutableStateOf(false) }
val here = stack.last()
fun go(spot: Spot) {
editing = false
dirty = false
stack = stack + spot
}
fun back() {
when {
editing && dirty -> askUnsaved = true
editing -> editing = false
stack.size > 1 -> {
stack = stack.dropLast(1)
editing = false
dirty = false
}
else -> onClose()
}
}
suspend fun load(path: String, again: Boolean) {
if (!again && listings[path] is LoadState.Loaded) return
listings[path] = LoadState.Loading
listings[path] =
try {
withContext(Dispatchers.IO) {
LoadState.Loaded(fetchDir(settings, target.setup, path))
}
} catch (e: ApiException) {
LoadState.failed(e)
}
}
BackHandler(onBack = ::back)
Box(
Modifier.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
// The session under this deliberately takes no keyboard inset, so the explorer adds its
// own -- otherwise the editor types under the keyboard.
.imePadding()
) {
Column(Modifier.fillMaxSize()) {
when (val spot = here) {
is Spot.Dir -> {
val state = listings[spot.path] ?: LoadState.Loading
// The resolved path once there is one: a directory opened as `~` is called what
// it turned out to be, not what it was asked for.
val at = (state as? LoadState.Loaded)?.value?.path ?: spot.path
FilesHeader(
title = baseName(at),
path = at,
machine = target.setupName,
onBack = ::back,
) {
GlyphButton(
REFRESH_GLYPH,
"Refresh this directory",
{ scope.launch { load(spot.path, again = true) } },
enabled = state !is LoadState.Loading,
)
GlyphButton(
PLUS_GLYPH,
"Create here",
{ creating = true },
enabled = state is LoadState.Loaded,
)
}
LaunchedEffect(spot.path) { load(spot.path, again = false) }
DirectoryBody(state, onOpen = ::go)
}
is Spot.Doc ->
DocPane(
settings = settings,
target = target,
path = spot.path,
name = baseName(spot.path),
editing = editing,
onEditing = { editing = it },
onDirty = { dirty = it },
onBack = ::back,
)
}
}
}
if (askUnsaved) {
UnsavedDialog(
onDiscard = {
askUnsaved = false
editing = false
dirty = false
},
onCancel = { askUnsaved = false },
)
}
val dir = here as? Spot.Dir
val listing = (listings[dir?.path] as? LoadState.Loaded)?.value
if (creating && dir != null && listing != null) {
CreateDialog(
settings = settings,
setup = target.setup,
directory = listing.path,
onDismiss = { creating = false },
onCreated = { path, isDirectory ->
creating = false
scope.launch {
// The directory it was created in is the one thing that changed, so that is
// what gets asked again -- not the whole stack.
load(dir.path, again = true)
// A new file has nothing to look at, so it opens where it can be filled in.
if (!isDirectory) {
go(Spot.Doc(path))
editing = true
}
}
},
)
}
}
/**
* The row every view in here has at the top: back, what this is, and what acts on it.
*
* The path is truncated in the middle when it will not fit, because both ends carry something the
* reader needs -- the machine and the top of the tree at one end, the file at the other -- and it
* is the longest paths, the ones being read most closely, that get cut.
*/
@Composable
private fun FilesHeader(
title: String,
path: String,
machine: String,
onBack: () -> Unit,
actions: @Composable () -> Unit,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
) {
GlyphButton(BACK_GLYPH, "Back", onBack)
Spacer(Modifier.width(GLYPH_BUTTON_MARGIN))
Column(Modifier.weight(1f)) {
Text(title, style = MaterialTheme.typography.titleMedium, maxLines = 1)
Text(
"$machine · $path",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.MiddleEllipsis,
)
}
Row { actions() }
}
}
/**
* What is in a directory.
*
* A listing that failed says why, in the machine's own words, where the rows would be -- never an
* empty list, which is what "there is nothing here" looks like and is the one wrong answer that
* looks like a right one.
*/
@Composable
private fun ColumnScope.DirectoryBody(state: LoadState<Listing>, onOpen: (Spot) -> Unit) {
when (state) {
is LoadState.Loading -> CircularProgressIndicator(Modifier.padding(16.dp))
is LoadState.Error ->
Text(
state.message,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(16.dp),
)
is LoadState.Loaded -> {
val listing = state.value
val sorted = remember(listing) { sortForDisplay(listing.entries) }
LazyColumn(Modifier.weight(1f).fillMaxWidth()) {
parentOf(listing.path)?.let { parent ->
item("..") {
EntryRow(
glyph = FOLDER_GLYPH,
name = "..",
trailing = null,
onClick = { onOpen(Spot.Dir(parent)) },
)
}
}
if (sorted.isEmpty()) {
item("empty") {
Text(
"Nothing here",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(16.dp),
)
}
}
uniqueItems(sorted, key = { it.name }) { entry ->
val path = join(listing.path, entry.name)
EntryRow(
glyph = if (entry.isDirectory) FOLDER_GLYPH else FILE_GLYPH,
name = entry.name,
trailing = trailingOf(entry),
onClick = {
onOpen(if (entry.isDirectory) Spot.Dir(path) else Spot.Doc(path))
},
)
}
}
}
}
}
/**
* What a row says after the name, or nothing.
*
* A symlink says so instead of giving a size, because the size a listing reports for one is the
* length of the path it points at -- a number that looks exactly like a file size and is about
* something else. `other` covers a fifo, a device, and a link whose target is gone: the row still
* appears, because a directory that hid what it held would be lying about being empty.
*/
private fun trailingOf(entry: DirEntry): String? =
when {
entry.link -> "link"
entry.isDirectory -> null
entry.kind == "file" -> humanSize(entry.size) ?: "0 B"
else -> "other"
}
@Composable
private fun EntryRow(glyph: String, name: String, trailing: String?, onClick: () -> Unit) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier =
Modifier.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 10.dp),
) {
Glyph(glyph, colour = MaterialTheme.colorScheme.onSurfaceVariant)
Spacer(Modifier.width(12.dp))
Text(
name,
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.MiddleEllipsis,
modifier = Modifier.weight(1f),
)
trailing?.let {
Spacer(Modifier.width(8.dp))
Text(
it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
/**
* One file: read, and edited behind the pencil.
*
* Its own composable so that everything about one file -- what came back, what has been typed, and
* whether a save is out -- is remembered under that file's path and thrown away when the reader
* moves to another. What is *not* here is edit mode itself: back has to know about it.
*/
@Composable
private fun ColumnScope.DocPane(
settings: ServerSettings,
target: FilesTarget,
path: String,
name: String,
editing: Boolean,
onEditing: (Boolean) -> Unit,
onDirty: (Boolean) -> Unit,
onBack: () -> Unit,
) {
val scope = rememberCoroutineScope()
var state by remember(path) { mutableStateOf<LoadState<FileContent>>(LoadState.Loading) }
var draft by remember(path) { mutableStateOf(TextFieldValue()) }
var saving by remember(path) { mutableStateOf(false) }
var saveError by remember(path) { mutableStateOf<String?>(null) }
var conflict by remember(path) { mutableStateOf<String?>(null) }
// The editor's own vertical scroll, hoisted so the gutter and the text move together: they are
// two composables in one row, and a scroll inside either would leave the other behind.
val editScroll = rememberScrollState()
val language = remember(name) { fileLanguage(name) }
val loaded = (state as? LoadState.Loaded)?.value as? FileContent.Text
// Readable but not editable: see [EDIT_LIMIT]. The size is the one the machine reported, so
// this is decided before anything is typed rather than discovered by a keyboard that stops
// answering.
val editable = loaded != null && loaded.size <= EDIT_LIMIT
suspend fun fetch() {
state = LoadState.Loading
state =
try {
val got = withContext(Dispatchers.IO) { fetchFile(settings, target.setup, path) }
if (got is FileContent.Text) draft = TextFieldValue(got.content)
LoadState.Loaded(got)
} catch (e: ApiException) {
LoadState.failed(e)
}
onDirty(false)
}
LaunchedEffect(path) { fetch() }
val changed = loaded != null && draft.text != loaded.content
LaunchedEffect(changed) { onDirty(changed) }
/** Writes the draft back, [against] being the digest it is allowed to replace. */
fun save(against: String) {
if (saving) return
saving = true
saveError = null
scope.launch {
try {
val written =
withContext(Dispatchers.IO) {
writeFile(settings, target.setup, path, draft.text, against)
}
state =
LoadState.Loaded(
FileContent.Text(
path,
written.size,
written.modified,
written.sha256,
draft.text,
)
)
conflict = null
onDirty(false)
onEditing(false)
} catch (e: ApiException) {
// The one refusal that is a question rather than a message: somebody else's edit is
// on the machine, and which of the two survives is not this app's to decide.
if (e.status == 409) conflict = e.message ?: "It changed on the machine."
else saveError = e.message
} finally {
saving = false
}
}
}
FilesHeader(title = name, path = path, machine = target.setupName, onBack = onBack) {
if (editing) {
if (saving) {
GlyphSpinner("Saving")
} else {
GlyphButton(
SAVE_GLYPH,
"Save",
{ loaded?.let { save(it.sha256) } },
// Disabled rather than hidden while there is nothing to write: a button that
// comes and goes makes its own absence the signal.
enabled = changed,
)
}
} else {
GlyphButton(
REFRESH_GLYPH,
"Read this file again",
{ scope.launch { fetch() } },
enabled = state !is LoadState.Loading,
)
GlyphButton(EDIT_GLYPH, "Edit", { onEditing(true) }, enabled = editable)
}
}
saveError?.let {
Text(
it,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
)
}
// Why the pencil is off. A disabled control teaches what the thing can do but cannot say why it
// is disabled -- and a reader who cannot edit a file they can plainly read will otherwise
// conclude the app is broken. Said once, here, rather than waiting for a tap a disabled button
// never gets.
if (loaded != null && !editable) {
Text(
"Too big to edit here (${humanSize(loaded.size)}; the limit is " +
"${humanSize(EDIT_LIMIT)}). A text field this large stops answering the keyboard.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
)
}
Box(Modifier.weight(1f).fillMaxWidth().background(rawSurface).padding(horizontal = 8.dp)) {
when (val current = state) {
is LoadState.Loading -> CircularProgressIndicator(Modifier.padding(8.dp))
is LoadState.Error ->
Text(
current.message,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(8.dp),
)
is LoadState.Loaded ->
when (val file = current.value) {
is FileContent.Text ->
if (editing) {
FileEditor(
draft,
{ draft = it },
language,
Modifier.verticalScroll(editScroll),
)
} else {
ScannedFile(file.content, language)
}
// Said in words, with the measurement that makes it make sense. Neither of
// these is an empty file and neither is an error, so neither may look like one.
is FileContent.Binary ->
Note(
"This is not text (${humanSize(file.size) ?: "0 B"}), so there is nothing to show."
)
is FileContent.TooBig ->
Note(
"This file is ${humanSize(file.size)}, which is more than the server will " +
"send. Nothing was read, so nothing here is a sample of it."
)
}
}
}
conflict?.let { message ->
ConflictDialog(
message = message,
busy = saving,
onOverwrite = {
// Re-read only to learn what it hashes to *now*, which is the digest an overwrite
// has to be allowed against. The content is deliberately thrown away: overwriting
// is the choice to lose it.
scope.launch {
val fresh =
try {
withContext(Dispatchers.IO) { fetchFile(settings, target.setup, path) }
} catch (e: ApiException) {
saveError = e.message
conflict = null
return@launch
}
if (fresh is FileContent.Text) save(fresh.sha256)
else {
saveError =
"It is no longer a text file, so this app will not write over it."
conflict = null
}
}
},
onReload = {
conflict = null
scope.launch { fetch() }
},
onCancel = { conflict = null },
)
}
}
/** A sentence where the file's content would be, for the two states that have no content. */
@Composable
private fun Note(text: String) {
Text(
text,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(8.dp),
)
}
/**
* Naming one thing in the directory that is open.
*
* A name and a switch, not a name and a body: the editor is where content is typed, and a modal
* with a text area in it is a second editor to keep in step with the first. A created file opens
* straight into edit mode, because an empty file is not something to look at.
*/
@Composable
private fun CreateDialog(
settings: ServerSettings,
setup: String,
directory: String,
onDismiss: () -> Unit,
onCreated: (String, Boolean) -> Unit,
) {
val scope = rememberCoroutineScope()
var name by remember { mutableStateOf("") }
var isDirectory by remember { mutableStateOf(false) }
var busy by remember { mutableStateOf(false) }
var error by remember { mutableStateOf<String?>(null) }
fun create() {
val chosen = name.trim()
if (busy || chosen.isEmpty()) return
busy = true
error = null
val path = join(directory, chosen)
scope.launch {
try {
withContext(Dispatchers.IO) {
if (isDirectory) createDir(settings, setup, path)
else createFile(settings, setup, path)
}
onCreated(path, isDirectory)
} catch (e: ApiException) {
// Beside the button that caused it: this dialog is the only thing on screen that
// knows something was being created, and the reason is usually the name itself.
error = e.message
busy = false
}
}
}
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Create in ${baseName(directory)}") },
text = {
Column {
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text("Name") },
singleLine = true,
enabled = !busy,
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(8.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Text("Directory", modifier = Modifier.weight(1f))
Switch(
checked = isDirectory,
onCheckedChange = { isDirectory = it },
enabled = !busy,
)
}
Text(
"A name that is already taken is refused rather than replaced.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
error?.let {
Spacer(Modifier.height(8.dp))
Text(
it,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
)
}
}
},
confirmButton = {
TextButton(onClick = { create() }, enabled = !busy && name.isNotBlank()) {
Text(if (busy) "Creating..." else "Create")
}
},
dismissButton = { TextButton(onClick = onDismiss, enabled = !busy) { Text("Cancel") } },
)
}
/**
* Directories first, then by name ignoring case, and stably.
*
* Sorted here rather than by the machine: presentation order is a display decision, and `find`
* answers in whatever order the directory happens to be stored in. Dotfiles are not hidden -- in a
* repository they are half of what matters.
*/
internal fun sortForDisplay(entries: List<DirEntry>): List<DirEntry> =
entries.sortedWith(compareBy({ !it.isDirectory }, { it.name.lowercase() }))
/**
* The directory above [path], or null at the root.
*
* A string operation on a path the *machine* resolved, which is what makes it safe: every listing
* answers with its own `pwd -P`, so there is never a `..` or a symlink left in here to reason
* about, and this app never has to resolve one.
*/
internal fun parentOf(path: String): String? {
val trimmed = path.trimEnd('/')
if (trimmed.isEmpty()) return null
val cut = trimmed.lastIndexOf('/')
return when {
cut < 0 -> null
cut == 0 -> "/"
else -> trimmed.substring(0, cut)
}
}
/** What a path names: its last segment, with `/` naming itself. */
internal fun baseName(path: String): String {
val trimmed = path.trimEnd('/')
return if (trimmed.isEmpty()) "/" else trimmed.substringAfterLast('/')
}
internal fun join(directory: String, name: String): String =
if (directory.endsWith("/")) "$directory$name" else "$directory/$name"