The stretch is rendered on the box around the list, so everything in that box bent with it -- the numbers included. `Modifier.overscroll` wraps the effect's single node, so one effect cannot be rendered in two places and the numbers cannot be excluded while they live in the rows. They had to leave. They now sit beside the scrolling box: the rows leave a spacer where the numbers go and a `SubcomposeLayout` draws them there. That is the one arrangement that keeps them level with their lines, which is the thing a numbered listing may never get wrong. Which numbers exist *and* where each one goes both come from the list's own `layoutInfo`, read in the measure block -- and subcomposition happens during measurement, so it composes from the answer the list has just produced rather than from one it read a frame ago. A column translated by the scroll position could not do that: the translation would be a layout read and current while the set of numbers was a composition behind it, and during a fling the numbers would slide against their lines. Checked by sampling the screen at about 1kHz through a fling: 23,520 row observations over 552 frames, every one with its number at exactly its own top, no drift at any point. Also that the gutter holds its x while the text scrolls sideways, and that a short file and an empty one still draw. A consequence worth having: the numbers are no longer inside the `SelectionContainer`, so selecting part of a file and copying gives the code rather than the code with a number in front of every line. That follows from where they are now rather than from anything asked for, and a copy was not exercised. The stretch itself still cannot be seen from this VM, so whether the numbers now stay flat while the text bends is the thing to look at on the phone. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
505 lines
27 KiB
Markdown
505 lines
27 KiB
Markdown
# The file explorer
|
|
|
|
Asked for by Bryan on 2026-09-03: replace the session screen's debug
|
|
button with a folder icon that opens a file and directory viewer for the
|
|
machine the session runs on. Browse directories, open files with the
|
|
existing syntax highlighting, line numbers, no wrapping; edit a file behind
|
|
a pencil icon; create files through a modal like the ones the app already
|
|
has; work over ssh; open at the session's working directory.
|
|
|
|
Built on 2026-09-03. This is the design, decision by decision with the
|
|
reason and what was rejected, so that when one changes it is changed here
|
|
rather than re-argued. The operational half -- how to run it, what to press,
|
|
what to produce on purpose -- is in AGENTS.md, where the rest of this
|
|
project's working notes are.
|
|
|
|
## What it is, in one paragraph
|
|
|
|
A machine's filesystem, seen from the phone through the backend. The
|
|
explorer belongs to a **setup** (a machine), not to a session: a session
|
|
only says where to start. Every operation -- list, read, write, create --
|
|
is one shell script run through `Transport`, exactly the way the import
|
|
listing and the usage fetch already work, so the local and the ssh case
|
|
are one implementation and a machine the backend cannot reach fails with
|
|
ssh's own message. The phone draws what came back: a listing, a file with
|
|
its lines coloured by the scanner in `Highlighter.kt`, or an editor over
|
|
the same text.
|
|
|
|
## Decisions
|
|
|
|
### 1. Keyed on the machine, opened from the session
|
|
|
|
Routes live under `/setups/{id}/…`, beside `importable`, because a
|
|
filesystem is a property of a machine. The session screen's folder button
|
|
opens the explorer with the session's setup and its `cwd` as the starting
|
|
directory; a session with no `cwd` opens at the machine's home, which the
|
|
machine resolves (`cd` with no argument and `pwd -P`), never a path the
|
|
phone guessed. Nothing in the explorer knows what a session is, so a later
|
|
entry point from the setups tab is one more caller and no new code.
|
|
|
|
Rejected: routes under `/sessions/{id}/`. The session would be a detour to
|
|
find the setup, and "browse this machine" from anywhere but a session would
|
|
need a session to exist first.
|
|
|
|
### 2. One shell script per operation, over `Transport`, on both transports
|
|
|
|
Each operation is a small POSIX shell script handed to `sh -c script sh
|
|
"$path" …` through `Transport::capture` (or the stdin-carrying variant
|
|
below). The path and every other value cross as **positional arguments**,
|
|
never interpolated into the script -- the same rule `import::find` follows
|
|
with `"$1"`, and the same reason `ssh::quote` exists: a path is
|
|
attacker-adjacent input in a server whose job is running commands. A `~`
|
|
prefix is handled by the same `quote_path`/`expand_home` pair every other
|
|
path goes through; nothing new is invented for it.
|
|
|
|
The scripts assume GNU coreutils and findutils (`find -printf`, `stat -c`,
|
|
`sha256sum`, `chmod --reference`). That is already what `import.rs`
|
|
assumes (`stat -c`, `/proc`), and both machines that exist are Linux. A
|
|
machine without them fails with that tool's own message, which names what
|
|
is missing.
|
|
|
|
Rejected: `std::fs` for the local transport and scripts for ssh. Two
|
|
implementations of "list a directory" drift -- the ordering of entries,
|
|
what a symlink reports, how a permission error reads -- and the local one
|
|
is the one that gets tested, so the remote one ships broken. The transport
|
|
design exists so that a driver never learns which machine it got; the
|
|
explorer is held to the same rule. The cost is a `sh` process per
|
|
operation locally, which is under a millisecond.
|
|
|
|
Rejected: a Rust SSH or SFTP library. PLAN.md rule 23 -- the system `ssh`
|
|
inherits `~/.ssh/config`, agents and jump hosts, and there is one place to
|
|
configure a connection. SFTP would need a second one.
|
|
|
|
### 3. The token can now name a path, and that is written down
|
|
|
|
AGENTS.md says of the import route: "the phone picks an **id**, never a
|
|
path: the server resolves which file that is, so an enrolled token cannot
|
|
become 'read me an arbitrary file'." The explorer's whole purpose is the
|
|
path, so it takes one. This is recorded in PLAN.md's Security section as a
|
|
change to the threat model paragraph, in these terms: the token already
|
|
gates spawning a bypass-permissions agent in any directory on any machine
|
|
a setup names, and that agent can already read and write every file its
|
|
user can. The explorer is a shorter path to authority the token already
|
|
holds, not new authority. The import route's rule stands where it is,
|
|
because there a path was unnecessary and refusing it cost nothing.
|
|
|
|
What is *not* changed: no route accepts a command. Listing, reading and
|
|
writing are fixed scripts; the phone chooses only the path and the bytes.
|
|
|
|
### 4. Paths are absolute or `~`-prefixed, and the machine answers with the real one
|
|
|
|
Same rule as `POST /sessions/{id}/cwd`: a relative path is refused with
|
|
the same wording, because where it would be depends on where nothing the
|
|
reader can see. Every listing answers with `pwd -P` of the directory it
|
|
listed, so the phone navigates on a resolved absolute path -- the parent
|
|
of `/home/bob/repos/ai-app` is a string operation on that, and a `~` the
|
|
session was spawned with is shown as what it turned out to be. The phone
|
|
never resolves `..` itself.
|
|
|
|
### 5. A read is capped and typed, and every state it can be in has a word
|
|
|
|
`GET /setups/{id}/file` answers with one of:
|
|
|
|
- `text` -- the content, with its size, mtime and sha256.
|
|
- `binary` -- the content is not UTF-8. Size reported, nothing shown.
|
|
- `tooBig` -- over `FILE_LIMIT` (1 MiB to start; see "Numbers to
|
|
measure"). Size reported so the reader knows what they are looking at.
|
|
- an error -- no such file, permission denied, machine unreachable --
|
|
carrying the machine's message.
|
|
|
|
Four outcomes rather than content-or-error, because a binary file drawn as
|
|
text and a big file cut off silently are both wrong in ways the reader
|
|
cannot see, and "couldn't read it" must not look like "it is empty". An
|
|
empty file is `text` with empty content and is drawn as one empty line
|
|
numbered 1, which is what it is.
|
|
|
|
Not in the first cut: showing images (the phone has `isImageRef` and a
|
|
viewer already; the route would serve bytes). Listed under "later".
|
|
|
|
### 6. A write is conditional on what the reader saw
|
|
|
|
`PUT /setups/{id}/file` carries the sha256 the read reported. The script
|
|
compares it against the file as it is now and refuses with a distinct exit
|
|
code if it differs; the server answers **409** with "changed on the machine
|
|
since you opened it". Agents edit files while people read them; this is
|
|
the common case, not the exotic one, and silently overwriting an agent's
|
|
edit with a stale copy is the worst available outcome. The phone offers
|
|
three ways out and says what each costs: **Overwrite** (theirs is lost),
|
|
**Reload** (yours is lost), **Cancel** (keep editing, decide later).
|
|
|
|
The write is `cat > "$1.ai-app-tmp" && chmod --reference="$1"
|
|
"$1.ai-app-tmp" && mv -f -- "$1.ai-app-tmp" "$1"`, with the bytes on
|
|
stdin. A temp file and a rename, so a connection dropped mid-write leaves
|
|
the old file whole rather than a truncated one; `chmod --reference` keeps
|
|
the mode, which a fresh file would otherwise lose (an executable script
|
|
would stop being one). What this trades away: the inode changes, so a hard
|
|
link elsewhere stops being the same file. Accepted; editors do the same.
|
|
The check-then-write is not atomic against a writer landing between the
|
|
two -- a window of microseconds on the same machine -- and that is accepted
|
|
too, and noted at the script.
|
|
|
|
The response carries the new size, mtime and sha256, so the editor's
|
|
precondition is fresh without a second read.
|
|
|
|
### 7. Create refuses to overwrite
|
|
|
|
`POST /setups/{id}/file {path}` runs under `set -C` (noclobber) and
|
|
`: > "$1"`, so a name that exists fails with the shell's own message rather
|
|
than truncating somebody's file. `POST /setups/{id}/dir {path}` is `mkdir
|
|
--` with the same property. The modal names one thing in the current
|
|
directory and has a switch for "directory"; a created file opens straight
|
|
into edit mode, because an empty file is not something to look at.
|
|
|
|
Rejected: create-with-content in one request. The editor is the place
|
|
content is typed, and a modal with a text area is a second editor.
|
|
|
|
### 8. The viewer is a list of lines, coloured once
|
|
|
|
The file is scanned once, off the main thread, by `scan` in
|
|
`Highlighter.kt` with `rulesOf(language)`; the spans are bucketed per line
|
|
in one pass, and each line's `AnnotatedString` is built when that line is
|
|
composed. A `LazyColumn` of lines, not one `Text`: text layout is linear
|
|
in the text, and a 20,000-line file in one `Text` measures all of it to
|
|
draw a screenful. Lines are drawn with `softWrap = false` inside one
|
|
shared `horizontalScroll` state, so the whole file scrolls sideways as a
|
|
block and a line never wraps.
|
|
|
|
**Sharing that state is not enough on its own, and this is where it was
|
|
wrong.** `horizontalScroll` is a node per row, and each one coerces the
|
|
shared offset into *its own* range -- content width less viewport -- so
|
|
with rows at their natural widths a short line's range is zero and it does
|
|
not move at all while the long line beside it does. Each row also writes
|
|
`maxValue` as it measures, so how far the file could be dragged was decided
|
|
by whichever row measured last, and changed as the list scrolled. Both go
|
|
away once **every row is given the same width**: the longest line in
|
|
columns times one character's advance, which is arithmetic rather than
|
|
twenty thousand measurements because the face is monospace. A tab counts as
|
|
eight columns and deliberately upwards -- over-estimating leaves a little
|
|
empty space past the longest line, under-estimating puts the end of that
|
|
line out of reach -- and the width is capped well under what `Constraints`
|
|
can carry, so a minified file is a scroll that stops early rather than a
|
|
crash. Reported by Iris on 2026-09-04 as "it seems to affect different rows
|
|
differently", which is precisely what a per-row range looks like.
|
|
|
|
**The stretch at the ends is one effect too**, shared by every row and
|
|
rendered once on the box around the list -- `horizontalScroll` makes its
|
|
own per node otherwise, so only the line under the finger bent and the
|
|
rest of the file sat still beside it. That is the same complaint one layer
|
|
further out, and it is only fixable now that every row agrees where the
|
|
end is. It cannot be seen from this VM: the emulator's screenshots come
|
|
back with no stretch in them at all, for any scrollable, so this one is
|
|
checked on the phone.
|
|
|
|
**The numbers sit outside that box**, so they neither travel with the text
|
|
nor bend with it. The rows leave a spacer where the numbers go and a
|
|
`SubcomposeLayout` beside the list draws them. That is the one arrangement
|
|
that keeps them level: which numbers exist *and* where each goes both come
|
|
from the list's own `layoutInfo`, read in the measure block, and
|
|
subcomposition happens during measurement -- so it composes from the answer
|
|
the list has just produced rather than from one it read a frame ago. A
|
|
column translated by the scroll position could not, since the translation
|
|
would be current while the set of numbers was a composition behind, and
|
|
during a fling the numbers would slide against their lines. Checked at
|
|
about 1kHz through a fling: 23,520 row observations over 552 frames, every
|
|
one of them with its number at exactly its own top.
|
|
|
|
A consequence worth having: the numbers are no longer inside the
|
|
`SelectionContainer`, so selecting part of a file and copying it gives the
|
|
code rather than the code with a number in front of every line.
|
|
|
|
Line numbers are a gutter in each row, right-aligned, with the gutter
|
|
width taken from the digit count of the line count in the same monospace
|
|
style -- so a 9-line file and a 12,000-line file each get exactly the
|
|
width they need and nothing is measured by hand. Because nothing wraps, a
|
|
logical line is one visual line, and the gutter cannot drift from the text
|
|
it numbers. Gutter numbers take `onSurfaceVariant`; the text takes the
|
|
scanner's palette on `rawSurface`, the surface every verbatim thing in the
|
|
app already sits on.
|
|
|
|
The language comes from the file's extension through the same table
|
|
`fenceLanguage` reads (`FENCE_LANGUAGES` already keys on `kt`, `rs`,
|
|
`py`, …). One function, `fileLanguage(name)`, takes the part after the
|
|
last dot and asks that table; it is one table, not two, so a language
|
|
added for fences is added for files. A file with no entry is drawn plain,
|
|
for the reason the table's comment gives.
|
|
|
|
Selection: the lines sit inside one `SelectionContainer`, as the
|
|
transcript does, so a selection can run across lines.
|
|
|
|
### 9. The editor is the legacy text field with a highlighting transformation
|
|
|
|
Edit mode swaps the viewer for a `BasicTextField(TextFieldValue)` in the
|
|
same monospace style, inside the same horizontal scroll so it does not
|
|
wrap, with a `VisualTransformation` that returns the text unchanged and
|
|
the scanner's spans as styles (`OffsetMapping.Identity`, since no
|
|
character moves). This is the one Compose API that colours a field's text
|
|
without replacing the field; the newer `TextFieldState` API has no hook
|
|
for styles. The gutter is one `Text` of `1\n2\n…` in the same style beside
|
|
the field, aligned for the same reason as the viewer: no wrap, one line
|
|
each.
|
|
|
|
Save is a glyph in the header, **disabled** until the text differs from
|
|
what was loaded (never hidden -- a control that comes and goes makes its
|
|
own absence the signal), and a `GlyphSpinner` while the write is out.
|
|
Back with unsaved changes asks; the question says the edits will be lost.
|
|
The keyboard: the explorer draws over the session, which deliberately has
|
|
no `imePadding` (see `SessionScreen`'s layout note), so the explorer's own
|
|
box adds it.
|
|
|
|
Re-scanning on every keystroke is the cost to watch. For a file under
|
|
`FILE_LIMIT` it is expected to be a few milliseconds (the scanner replaced
|
|
a library that took 174ms on 200 lines; ours has not been measured on a
|
|
1 MiB file). Measure before deciding whether edit mode needs a size below
|
|
which highlighting is on -- see "Numbers to measure".
|
|
|
|
### 10. The explorer draws over the session, and back closes it first
|
|
|
|
`Screen.Session` in `AppRoot` gains a `files: FilesTarget?`. When set, the
|
|
`FilesScreen` is composed **on top of** the session in the same `Box`, and
|
|
the session stays composed under it: its event stream keeps flowing, its
|
|
scroll position and draft stay where they were, and returning from a file
|
|
costs nothing. Back -- the button and the platform gesture --
|
|
clears `files` when it is set and goes to the list otherwise. Inside the
|
|
explorer the same back steps one level: editor → viewer (with the unsaved
|
|
question), viewer → listing, listing → parent directory it came from, and
|
|
only from the starting directory does it close. "Back returns; it does not
|
|
exit."
|
|
|
|
Rejected: a `Screen.Files` beside `Screen.Session`. Every route back from
|
|
a leaf screen goes to Main today, and a session disposed and re-created on
|
|
each return refetches its transcript over the tunnel -- exactly the flip
|
|
between "what did it change" and "what is it saying" this feature is for.
|
|
The image viewer already made the same choice for the same reason.
|
|
|
|
### 11. The listing is drawn as it came, sorted at display time
|
|
|
|
Entries carry name, kind (`directory`, `file`, `other`), size, mtime, and
|
|
whether the entry is a symlink (with the kind being the *target's*, from
|
|
`find -printf '%Y'`, so a link to a directory navigates). Sorted on the
|
|
phone, stably: directories first, then case-insensitive name. Dotfiles are
|
|
shown -- in a repository they are half of what matters. A row is the
|
|
glyph, the name, and the size for a file; tapping a directory descends,
|
|
tapping a file opens it. Each directory's entries are kept for as long as
|
|
the explorer is open, keyed by path, so returning to one does not refetch
|
|
it; the header's refresh glyph refetches the current one on purpose, and a
|
|
create refetches the directory it created into, since that is what the
|
|
operation changed.
|
|
|
|
An empty directory says "Nothing here". A listing that failed says why,
|
|
in the machine's words, where the rows would be -- never an empty list.
|
|
|
|
Entries are separated by `\0` in the script's output and by `\t` within a
|
|
line (`find -printf '%y\t%Y\t%s\t%T@\t%f\0'`), so a filename with a
|
|
newline or a tab in it survives; `parse_entries` is a unit test with
|
|
exactly those names in it.
|
|
|
|
### 12. Icons
|
|
|
|
Added to `NerdIcons.kt` **and** `build-icon-font.sh`, then the script
|
|
rerun and its output committed (it needs network):
|
|
|
|
- `md-folder` U+F024B -- the header button, and directory rows. The same
|
|
codepoint dev-updater uses, and it must not drift from it, as the cog
|
|
and the refresh arrow already must not.
|
|
- `md-plus` U+F0415 -- create. Also dev-updater's.
|
|
- `md-pencil` U+F03EB -- edit.
|
|
- `md-content_save` U+F0193 -- save.
|
|
- `md-file_outline` U+F0224 -- file rows.
|
|
|
|
All five were looked up in Nerd Fonts' own `glyphnames.json` rather than
|
|
copied from memory, which is the check that a codepoint means the glyph its
|
|
comment names.
|
|
|
|
**Where the folder button sits**: between the usage chart and the cog, so
|
|
the header reads widest scope to narrowest and the cog stays at the end
|
|
where every other screen in this app keeps it. Asked for in that order by
|
|
Iris on 2026-09-03.
|
|
|
|
### 13. The render report moves, and the benches move with it
|
|
|
|
The speedometer goes. The report it copies is the standard measurement
|
|
`transcript-bench.sh` and `stream-bench.sh` read from logcat, so it stays
|
|
reachable: a "Copy render timings" row in `SessionSettingsDialog`, which
|
|
is where the session's other about-the-session controls already are.
|
|
|
|
**No script that drives the UI taps by coordinate, and moving this
|
|
button is where that rule gets enforced** (Bryan, 2026-09-03). Both bench
|
|
scripts press the button today as `ui-trace record --do 'tap 723 205'`, a
|
|
position measured once by hand. Anything that moves the header -- this
|
|
change, a font size, a density, another emulator -- makes that tap land on
|
|
whatever now sits there, and the script then reports a number that was
|
|
never measured, which reads exactly like a result. A control is found by
|
|
the name it already carries for assistive technology (`GlyphButton`'s
|
|
`label`, a row's text) and pressed at the bounds the screen reports at
|
|
that moment.
|
|
|
|
That belongs in the tool, not in each script: `ui-trace` in
|
|
`~/repos/emulator-tools` gains a tap-by-label action (`tap 'Session
|
|
settings'`, resolving the element's box from the same uiautomator tree
|
|
`elements` already reads, at the moment of the gesture), and both benches
|
|
move onto it in the same commit as the button -- cog, then "Copy render
|
|
timings" -- so the measurement is never unavailable and never wrong
|
|
quietly. `grep -n "tap [0-9]" app/*.sh` is the check that no coordinate
|
|
tap is left, and it goes in the emulator-tools README beside the action.
|
|
Once the action exists, this rule applies to every script that presses
|
|
something on an Android screen, not only these two.
|
|
|
|
## HTTP surface
|
|
|
|
Added to the table in `routes.rs`'s module doc:
|
|
|
|
```text
|
|
GET /setups/{id}/dir?path=P entries of directory P, and P resolved
|
|
GET /setups/{id}/file?path=P content of file P, or why not
|
|
PUT /setups/{id}/file {path, content, ifSha256} -> new size/mtime/sha256
|
|
(409 when the file no longer matches ifSha256)
|
|
POST /setups/{id}/file {path} create empty; refused if it exists
|
|
POST /setups/{id}/dir {path} create; refused if it exists
|
|
```
|
|
|
|
Bodies use `deny_unknown_fields` like every other body here. Paths in the
|
|
query string are URL-encoded by `Api.kt`'s existing helper.
|
|
|
|
```json
|
|
GET dir -> {"path":"/home/bob/repos/ai-app",
|
|
"entries":[{"name":"app","kind":"directory","size":4096,"modified":1756900000,"link":false},
|
|
{"name":"README.md","kind":"file","size":1234,"modified":1756900000,"link":false}]}
|
|
GET file -> {"path":"/…/x.rs","kind":"text","size":1234,"modified":…,"sha256":"…","content":"…"}
|
|
| {"path":"/…/a.png","kind":"binary","size":45678,"modified":…}
|
|
| {"path":"/…/big.log","kind":"tooBig","size":12345678,"modified":…}
|
|
PUT file -> {"size":1240,"modified":…,"sha256":"…"}
|
|
```
|
|
|
|
Errors: `BadRequest` with the machine's message for a path that is not
|
|
there, not allowed or not absolute; the existing 409 variant for the
|
|
precondition; `Internal` only for the server's own faults. The message is
|
|
what the phone shows, in place, so it is written to be read there.
|
|
|
|
## Server work (`server/src/files.rs`)
|
|
|
|
One module, with the same shape as `setups.rs`: the scripts as constants,
|
|
one `pub async fn` per operation taking `&Transport`, and the parsing as
|
|
pure functions with tests.
|
|
|
|
1. `Transport::capture_with_input(launch, stdin)` -- `capture` with bytes
|
|
on stdin. `ship_attachment` in `routes.rs` builds this by hand today
|
|
(an `ssh::command`, a `File` on stdin, `output().await`); it moves onto
|
|
the new helper in the same change, so there is one description of
|
|
"run this there with this on stdin" rather than two.
|
|
2. `list(transport, path) -> Listing`: `cd -- "$1" && pwd -P && find .
|
|
-mindepth 1 -maxdepth 1 -printf '%y\t%Y\t%s\t%T@\t%f\0'`. First line is
|
|
the resolved path; the rest is entries. `parse_entries` tested with
|
|
names containing a tab, a newline, a leading dash and a `'`.
|
|
3. `read(transport, path) -> Read`: `stat -c '%s %Y' -- "$1"`, refuse
|
|
above `FILE_LIMIT` before `cat` so a 2 GB log never crosses the
|
|
tunnel, then `sha256sum -- "$1"` and `cat -- "$1"`, header lines then
|
|
bytes; the server splits at the header and decides `text`/`binary` by
|
|
`String::from_utf8`.
|
|
4. `write(transport, path, expected_sha256, bytes) -> Written`: the
|
|
script in decision 6, with a distinct exit code for the precondition
|
|
(`exit 3`) that the route maps to 409; anything else is the machine's
|
|
stderr.
|
|
5. `create_file`, `create_dir`: decision 7.
|
|
6. Routes in `routes.rs`, each resolving the setup with `setup_by_id` and
|
|
`Transport::for_setup` as `set_cwd` does. The path check (absolute or
|
|
`~`) is one function shared with `set_cwd`, which has it inline today.
|
|
7. Tests: the parsers; the quoting (a path that tries to close the quote
|
|
ends up as one absurd argument -- `ssh.rs` has the pattern); and an
|
|
integration test running each script through `Transport::Here`
|
|
against a `tempfile` tree, which is cheap because `sh` is there
|
|
wherever `cargo test` runs. The precondition test writes the file
|
|
between the read and the write and asserts the 409 path.
|
|
8. PLAN.md: the Security paragraph from decision 3, and an "Explorer"
|
|
section pointing here. AGENTS.md: the layout bullet for `files.rs`.
|
|
|
|
## App work
|
|
|
|
1. `Api.kt`: `fetchDir`, `fetchFile`, `writeFile`, `createFile`,
|
|
`createDir`, and the three data classes (`DirEntry`, `FileContent`
|
|
as a sealed class with the four kinds, `Written`).
|
|
2. `NerdIcons.kt` + `build-icon-font.sh`: decision 12.
|
|
3. `Languages.kt` (or `CodeFence.kt`, wherever `FENCE_LANGUAGES` sits):
|
|
`fileLanguage(name)`.
|
|
4. `FileLines.kt`: the pure half of the viewer -- spans bucketed per line,
|
|
`lineOf(index) -> AnnotatedString` -- so it has a JVM unit test beside
|
|
`HighlighterTest`, the app's one existing test suite, covering a block
|
|
comment that spans lines and a file with no trailing newline.
|
|
5. `FilesScreen.kt`: the listing, the navigation stack, the per-directory
|
|
cache, the create dialog (modelled on `AddSetupDialog`: fields, a busy
|
|
state, the failure shown inside the dialog beside the button that
|
|
caused it), and the header. `LoadState` for the listing.
|
|
6. `FileViewer.kt`: decision 8. `FileEditor.kt`: decision 9, including
|
|
the conflict dialog.
|
|
7. `AppRoot.kt`: decision 10. `SessionScreen.kt`: the folder glyph where
|
|
the speedometer was, `onFiles(setup, cwd)` out to the root.
|
|
8. `SessionSettingsDialog.kt`: the render-report row. In
|
|
`~/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`.
|
|
|
|
## What the measurements said (2026-09-04)
|
|
|
|
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
|
|
|
|
- Delete, rename and move. Destructive controls belong here eventually,
|
|
shown and confirmed rather than hidden, but none of them is needed to
|
|
read or change a file.
|
|
- Images in the viewer, through the existing `SessionImageViewer`.
|
|
- Following an agent's edits live: a file open in the viewer refreshing
|
|
when a `Write`/`Edit` tool call on the same path lands in the
|
|
transcript. The transcript already knows the path.
|
|
- Remembering the last directory per session.
|
|
- 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.
|