From 2c122742851cfc3c25dde2420f993f842be1bedf Mon Sep 17 00:00:00 2001 From: iris <2+iris@noreply.localhost> Date: Fri, 4 Sep 2026 00:16:57 -0400 Subject: [PATCH] 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 --- AGENTS.md | 7 +++ EXPLORER.md | 62 +++++++++++++++---- .../kotlin/com/example/aiapp/FileEditor.kt | 24 +++++++ .../kotlin/com/example/aiapp/FileLines.kt | 19 +++--- .../kotlin/com/example/aiapp/FileViewer.kt | 33 ++++++++++ .../kotlin/com/example/aiapp/FilesScreen.kt | 26 ++++++-- 6 files changed, 147 insertions(+), 24 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 3248ba3..fbcebbe 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -149,6 +149,13 @@ repo is in PLAN.md's "Backend layout" section. `./ui-sandbox.sh api /sessions//cwd -X POST -H 'content-type: application/json' -d '{"cwd":"~/files"}'`. The 409 is produced by editing the file on the machine (`printf … > file`) between pressing the pencil and pressing save. + **Reading is cheap and editing is not**, and the sizes are measured + rather than guessed -- see EXPLORER.md's "What the measurements said". + The viewer handles a 1 MiB, 28,000-line file because it draws one row per + line; the editor is one `BasicTextField`, which costs two seconds a frame + at 128 kB and stops the app at 1 MiB, so `EDIT_LIMIT` caps it at 32 kB + with the reason said on screen. If you make the editor faster, that + number is what to move. - `.dev-updater.ron` — what Dev Updater is asked to do with this checkout: the server (built in `server/`, run as `service: Managed(...)`) and the APK (built in `app/`), built in parallel. The project it serves is the diff --git a/EXPLORER.md b/EXPLORER.md index 8f7bb2d..85ece9e 100644 --- a/EXPLORER.md +++ b/EXPLORER.md @@ -393,18 +393,51 @@ pure functions with tests. `~/repos/emulator-tools`, `ui-trace`'s tap-by-label action; then the two bench scripts onto it, with no coordinate tap left in `app/*.sh`. -## Numbers to measure, before deciding +## What the measurements said (2026-09-04) -- Scan time for a 1 MiB source file on the emulator, and on the phone - through the render report. That decides whether `FILE_LIMIT` is right - and whether edit mode highlights every keystroke or only below a size. -- Time to first line for a 1 MiB file over the tunnel: the read, the - transfer, the scan, the first composition. If the transfer dominates, - the route gains nothing from streaming; if the scan does, it moves to - a worker with the plain text drawn first. -- The `BasicTextField` at 20,000 lines: whether typing stays responsive. - If not, edit mode gets a lower cap than the viewer, stated in the - editor rather than discovered by a stuck keyboard. +Taken on the emulator in a **debug** build, which runs Compose at a +fraction of release speed and renders in software -- so these rank +correctly against each other and are pessimistic in absolute terms. +Generated Rust, through the app's own render report. + +| file | lines | scan + cut | scan per keystroke | worst frame record | +|--------|--------|------------|--------------------|--------------------| +| 32 kB | 917 | 11ms | 10ms | 183ms | +| 128 kB | 3,633 | -- | 40ms | 2,027ms | +| 1 MB | 28,660 | 460ms | -- | -- | + +Three things followed. + +**The viewer's scan had to leave the main thread.** Decision 8 said "off +the main thread" and the first version did it in a `remember` inside the +composition, which is not that: 460ms of frozen screen at the size the +server is willing to send, long enough that the accessibility tree cannot +be read -- which is exactly what "the app has stopped" looks like from +outside. It now runs on `Dispatchers.Default` with a spinner where the file +will be. + +**`FILE_LIMIT` at 1 MiB is right for reading.** Time to first line for a +1 MiB file, tap to text on screen, was **2.4s** against the sandbox -- +1.2s of which is that server's deliberate `--delay`, and 460ms the scan. +The transfer is not what dominates, so the route gains nothing from +streaming. + +**Edit mode needed a cap, and not the one that was expected.** The plan +expected to be deciding a size below which highlighting stays on. That is +not the cost that matters: highlighting 128 kB costs 40ms a keystroke, +which is survivable, while laying the same text out in one +`BasicTextField` costs two seconds -- characters typed into it were +dropped, and a 1 MiB file stopped the app responding altogether. Since +every arrangement of a single text field pays that, switching highlighting +off would have saved nothing. So `EDIT_LIMIT` is **32 kB**, the largest +size measured as usable, and above it the pencil is disabled with the +reason said 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. + +Reading is unaffected: the viewer opens and scrolls the 1 MiB file fine, +because it is a `LazyColumn` of lines rather than one text object. That +difference is the whole of decision 8. ## Later, deliberately not now @@ -419,3 +452,10 @@ pure functions with tests. - Uploading from the phone into a directory. Attachments already do the upload half; this would be the same route with a chosen destination. - Search within a file, and find-in-files. +- **A line-by-line editor**, which is the way past `EDIT_LIMIT`. The + viewer already draws a file as rows and stays fast on a megabyte; an + editor built the same way -- a field per line, or a field over the lines + on screen -- would not pay Compose's cost of laying out one enormous + text. It is a good deal more than this feature needed, and 32 kB covers + the config files, notes and ordinary source files anybody edits from a + phone. diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/FileEditor.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/FileEditor.kt index be9d365..ddbc11f 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/FileEditor.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/FileEditor.kt @@ -23,6 +23,30 @@ import androidx.compose.ui.text.input.TransformedText import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.text.style.TextAlign +/** + * The largest file this app will open in the editor, in bytes. + * + * Measured on the emulator on 2026-09-04, in a debug build, on generated Rust: + * + * | file | lines | scan per keystroke | worst frame record | typing | + * |--------|--------|--------------------|--------------------|-------------------| + * | 32 kB | 917 | 10ms | 183ms | sluggish, correct | + * | 128 kB | 3,633 | 40ms | 2,027ms | characters lost | + * | 1 MB | 28,660 | -- | -- | stops responding | + * + * The number that decides this is the **frame record**, not the scan: highlighting a 128 kB file + * costs 40ms a keystroke, which is noticeable and survivable, while laying the same text out in one + * `BasicTextField` costs two seconds. So switching highlighting off above a size -- which is what + * EXPLORER.md expected to have to decide -- would not have saved it; the cost is Compose laying out + * one enormous text, and every arrangement of a single text field pays it. A line-by-line editor is + * the way past this and is a good deal more than this feature needed. + * + * 32 kB rather than something between it and 128 kB, because 32 kB is the largest size that was + * actually measured as usable. The viewer's own limit stays the server's `FILE_LIMIT` of 1 MiB: + * reading a big file is fine, and it is only editing one that is not. + */ +const val EDIT_LIMIT = 32L * 1024 + /** * The same file, editable, in the same face and colours it was being read in. * diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/FileLines.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/FileLines.kt index 6d6ac63..27aa622 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/FileLines.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/FileLines.kt @@ -56,13 +56,18 @@ private constructor( * file is one empty line numbered 1, which is what it is: a file with nothing in it still * has somewhere for a cursor to go. */ - fun of(text: String, language: Language?): FileLines { - val body = text.removeSuffix("\n") - val lines = body.split('\n') - val rules = language?.let { rulesOf(it) } - val scanned = if (rules == null) emptyList() else scan(body, rules) - return FileLines(lines, bucket(lines, scanned)) - } + fun of(text: String, language: Language?): FileLines = + // Timed, and always, for the same reason everything else here is: the cost of opening + // a large file is the number that decides whether the server's size limit is right, + // and an instrument that is only in the build nobody is running answers nothing. It + // lands in the render report beside the transcript's own figures. + DebugStats.timed("file scanned and cut into lines") { + val body = text.removeSuffix("\n") + val lines = body.split('\n') + val rules = language?.let { rulesOf(it) } + val scanned = if (rules == null) emptyList() else scan(body, rules) + FileLines(lines, bucket(lines, scanned)) + } /** * The scanner's spans, in file offsets, as spans per line in line offsets. diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/FileViewer.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/FileViewer.kt index 7b4601a..808c36d 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/FileViewer.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/FileViewer.kt @@ -4,15 +4,21 @@ import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +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.platform.LocalDensity @@ -23,12 +29,39 @@ import androidx.compose.ui.text.rememberTextMeasurer import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext /** The face every verbatim thing in this app is drawn in, and the one the gutter has to match. */ @Composable fun codeStyle(): TextStyle = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace) +/** + * [content] scanned off the main thread, then drawn. + * + * Measured on the emulator on 2026-09-04: [FileLines.of] takes **460ms** on a 1 MiB Rust file + * (28,660 lines) and 11ms on 32 kB. Called from a `remember` inside the composition, as it was + * first written, that is 460ms of frozen screen at the size the server is willing to send -- long + * enough that the accessibility tree cannot be read, which is what "the app has stopped" looks like + * from outside. So it runs on [Dispatchers.Default] and the spinner is what the reader sees + * meanwhile, in the place the file will appear. + * + * Keyed on the text and the language, so re-reading the same file does not rescan it and a file + * that changed does. + */ +@Composable +fun ScannedFile(content: String, language: Language?, modifier: Modifier = Modifier) { + var lines by remember(content, language) { mutableStateOf(null) } + LaunchedEffect(content, language) { + lines = withContext(Dispatchers.Default) { FileLines.of(content, language) } + } + when (val ready = lines) { + null -> CircularProgressIndicator(Modifier.padding(8.dp)) + else -> FileViewer(ready, modifier) + } +} + /** * A file, one line per row, coloured by the same scanner that colours a reply's code fences. * diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/FilesScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/FilesScreen.kt index d629988..e2ebac4 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/FilesScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/FilesScreen.kt @@ -375,6 +375,10 @@ private fun ColumnScope.DocPane( 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 @@ -450,7 +454,7 @@ private fun ColumnScope.DocPane( { scope.launch { fetch() } }, enabled = state !is LoadState.Loading, ) - GlyphButton(EDIT_GLYPH, "Edit", { onEditing(true) }, enabled = loaded != null) + GlyphButton(EDIT_GLYPH, "Edit", { onEditing(true) }, enabled = editable) } } @@ -463,6 +467,20 @@ private fun ColumnScope.DocPane( ) } + // 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)) @@ -484,11 +502,7 @@ private fun ColumnScope.DocPane( Modifier.verticalScroll(editScroll), ) } else { - val lines = - remember(file.content, language) { - FileLines.of(file.content, language) - } - FileViewer(lines) + 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.