Files
ai-app/app/androidApp/src/main/kotlin/com/example/aiapp/FilesScreen.kt
T
irisandClaude Opus 5 2c12274285 Measure the explorer, and cap edit mode at what it can carry
Three numbers, taken on the emulator through the app's own render report
and written into EXPLORER.md; the fixture tree the sandbox now builds is
what they were taken against.

The viewer's scan was on the main thread. Decision 8 said off it, and the
first version did it in a `remember` inside the composition, which is not
that -- 460ms of frozen screen on a 1 MiB file, long enough that the
accessibility tree cannot be read, which is exactly what "the app has
stopped" looks like from outside. It runs on Dispatchers.Default now, with
a spinner where the file will be. Reading a megabyte is otherwise fine:
the viewer is a row per line, and it opens and scrolls 28,660 of them.

Edit mode needed a cap, and not the one the plan expected. The cost that
matters is not the highlighting -- 40ms a keystroke at 128 kB, which is
survivable -- it is Compose laying out one enormous text in the field:
2,027ms per frame at 128 kB, with typed characters dropped, and no
response at all at 1 MiB. Switching highlighting off would have saved
nothing, since every arrangement of a single text field pays it. So
EDIT_LIMIT is 32 kB, the largest size actually measured as usable, and
above it the pencil is disabled with the reason in words beside it: a
disabled control teaches what the thing can do but cannot say why it is
off, and a reader who cannot edit a file they can plainly read would
otherwise conclude the app is broken.

`FileLines.of` is timed like everything else here, so the figure lands in
the render report rather than needing a harness to ask for it.

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

693 lines
27 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, say -- 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, its draft and scroll position stay where they were, 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 it -- and only closes from where it opened.
*
* Every directory that has been visited is kept for as long as this is open, so stepping back is
* instant; the refresh glyph is how a directory gets asked again on purpose, and creating something
* refetches the directory it was created in, since that is the one thing that changed.
*/
@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 (see SessionScreen's
// layout note), 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 entirely. `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,
* and the word is there because a colour cannot say "this is a different kind of thing".
*/
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, and back
* belongs to the screen.
*/
@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 it 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 that a
// disabled button never receives.
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('/')
}
/** A resolved directory and a name in it, as one path. */
internal fun join(directory: String, name: String): String =
if (directory.endsWith("/")) "$directory$name" else "$directory/$name"