Plan the file explorer

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
irisandClaude Fable 5.1 committed 2026-09-03 21:07:07 -04:00
1 parent 359649bc73
commit 32c57a47a5
1 file changed
+453
+453
View File
@@ -0,0 +1,453 @@
# 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.
This is the plan. Like PLAN.md it records each decision with the reason and
what was rejected, so that when one changes it is changed here rather than
re-argued. Once built, the operational notes (how to test it, what bit)
move to AGENTS.md and this file keeps only the design.
## 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.
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, the platform gesture and `swipeBack` --
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` -- edit.
- `md-content_save` -- save.
- `md-file_outline` -- file rows.
The last three are verified against the Nerd Fonts cheat sheet when they
are added, not copied from memory.
### 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`.
## Testing
- **Server**: `./run-tests.sh`, `cargo clippy --all-targets`, `cargo fmt`.
- **Local transport, by hand**: `./ui-sandbox.sh api
"/setups/<id>/dir?path=~"` against the sandbox, whose `$HOME` is a
throwaway tree it is fine to write into. The sandbox gets a small
fixture directory with the states worth seeing: an empty directory, a
file with a tab in its name, a binary file, one over `FILE_LIMIT`, an
unreadable one (`chmod 000`), a symlink to a directory, and a source
file in each of a few languages.
- **Remote transport**: the ssh-to-this-VM recipe in AGENTS.md ("How to
test SSH here"). The point of the exercise is the quoting and the
stdin path: write a file whose name has a `'` in it, and read it back.
- **Phone**: `ui-trace`, not screenshots, for the things this feature is
made of -- that the gutter's number and its line share a baseline at
the first and the last row, that a long line's row is wider than the
viewport and does not grow the row height, that the editor's gutter
stays put while the text scrolls sideways. Screenshots for colour and
contrast on `rawSurface`.
- **States to produce on purpose**, since the default state is the one
everybody looks at: a directory that fails to list (permission),
an unreachable machine (a setup pointing at a dead address), `binary`,
`tooBig`, the 409 conflict (edit the file with `sed -i` on the machine
between opening and saving), creating a name that exists, back with
unsaved edits, and the keyboard up over the editor.
## Numbers to measure, before deciding
- 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.
## Order of work
Each step leaves the app working and is one commit.
1. Server: `files.rs` with `list` and `read`, routes, tests. Half a day.
2. App: icons, `Api.kt`, `FilesScreen` listing, `FileViewer`, the root
and session wiring, the render-report move with the benches. A day.
3. Server: `write`, `create_file`, `create_dir`, the stdin helper and
`ship_attachment` onto it. Half a day.
4. App: `FileEditor`, the create dialog, the conflict dialog. Half a day.
5. Measurements above, the sandbox fixture, PLAN.md and AGENTS.md. Half a
day.
## 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.