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>
This commit is contained in:
irisandClaude Opus 5 committed 2026-09-04 00:16:57 -04:00
1 parent 9c4d33273b
commit 2c12274285
6 files changed
+142 -19

No files matched your search

+7
View File
@@ -149,6 +149,13 @@ repo is in PLAN.md's "Backend layout" section.
`./ui-sandbox.sh api /sessions/<id>/cwd -X POST -H 'content-type: application/json' -d '{"cwd":"~/files"}'`. `./ui-sandbox.sh api /sessions/<id>/cwd -X POST -H 'content-type: application/json' -d '{"cwd":"~/files"}'`.
The 409 is produced by editing the file on the machine (`printf … > file`) The 409 is produced by editing the file on the machine (`printf … > file`)
between pressing the pencil and pressing save. 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: - `.dev-updater.ron` — what Dev Updater is asked to do with this checkout:
the server (built in `server/`, run as `service: Managed(...)`) and the the server (built in `server/`, run as `service: Managed(...)`) and the
APK (built in `app/`), built in parallel. The project it serves is the APK (built in `app/`), built in parallel. The project it serves is the
+51 -11
View File
@@ -393,18 +393,51 @@ pure functions with tests.
`~/repos/emulator-tools`, `ui-trace`'s tap-by-label action; then the `~/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`. 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 Taken on the emulator in a **debug** build, which runs Compose at a
through the render report. That decides whether `FILE_LIMIT` is right fraction of release speed and renders in software -- so these rank
and whether edit mode highlights every keystroke or only below a size. correctly against each other and are pessimistic in absolute terms.
- Time to first line for a 1 MiB file over the tunnel: the read, the Generated Rust, through the app's own render report.
transfer, the scan, the first composition. If the transfer dominates,
the route gains nothing from streaming; if the scan does, it moves to | file | lines | scan + cut | scan per keystroke | worst frame record |
a worker with the plain text drawn first. |--------|--------|------------|--------------------|--------------------|
- The `BasicTextField` at 20,000 lines: whether typing stays responsive. | 32 kB | 917 | 11ms | 10ms | 183ms |
If not, edit mode gets a lower cap than the viewer, stated in the | 128 kB | 3,633 | -- | 40ms | 2,027ms |
editor rather than discovered by a stuck keyboard. | 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 ## Later, deliberately not now
@@ -419,3 +452,10 @@ pure functions with tests.
- Uploading from the phone into a directory. Attachments already do the - Uploading from the phone into a directory. Attachments already do the
upload half; this would be the same route with a chosen destination. upload half; this would be the same route with a chosen destination.
- Search within a file, and find-in-files. - 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.
@@ -23,6 +23,30 @@ import androidx.compose.ui.text.input.TransformedText
import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.text.style.TextAlign 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. * The same file, editable, in the same face and colours it was being read in.
* *
@@ -56,12 +56,17 @@ private constructor(
* file is one empty line numbered 1, which is what it is: a file with nothing in it still * 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. * has somewhere for a cursor to go.
*/ */
fun of(text: String, language: Language?): FileLines { 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 body = text.removeSuffix("\n")
val lines = body.split('\n') val lines = body.split('\n')
val rules = language?.let { rulesOf(it) } val rules = language?.let { rulesOf(it) }
val scanned = if (rules == null) emptyList() else scan(body, rules) val scanned = if (rules == null) emptyList() else scan(body, rules)
return FileLines(lines, bucket(lines, scanned)) FileLines(lines, bucket(lines, scanned))
} }
/** /**
@@ -4,15 +4,21 @@ import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable 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.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity 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.text.style.TextAlign
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
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. */ /** The face every verbatim thing in this app is drawn in, and the one the gutter has to match. */
@Composable @Composable
fun codeStyle(): TextStyle = fun codeStyle(): TextStyle =
MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace) 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<FileLines?>(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. * A file, one line per row, coloured by the same scanner that colours a reply's code fences.
* *
@@ -375,6 +375,10 @@ private fun ColumnScope.DocPane(
val editScroll = rememberScrollState() val editScroll = rememberScrollState()
val language = remember(name) { fileLanguage(name) } val language = remember(name) { fileLanguage(name) }
val loaded = (state as? LoadState.Loaded)?.value as? FileContent.Text 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() { suspend fun fetch() {
state = LoadState.Loading state = LoadState.Loading
@@ -450,7 +454,7 @@ private fun ColumnScope.DocPane(
{ scope.launch { fetch() } }, { scope.launch { fetch() } },
enabled = state !is LoadState.Loading, 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)) { Box(Modifier.weight(1f).fillMaxWidth().background(rawSurface).padding(horizontal = 8.dp)) {
when (val current = state) { when (val current = state) {
is LoadState.Loading -> CircularProgressIndicator(Modifier.padding(8.dp)) is LoadState.Loading -> CircularProgressIndicator(Modifier.padding(8.dp))
@@ -484,11 +502,7 @@ private fun ColumnScope.DocPane(
Modifier.verticalScroll(editScroll), Modifier.verticalScroll(editScroll),
) )
} else { } else {
val lines = ScannedFile(file.content, language)
remember(file.content, language) {
FileLines.of(file.content, language)
}
FileViewer(lines)
} }
// Said in words, with the measurement that makes it make sense. Neither of // 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. // these is an empty file and neither is an error, so neither may look like one.