docs/: move the design and working documents out of the repo root (CLAUDE.md and AGENTS.md stay, harnesses read them there)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
45ced405f3
commit
9a33cb5384
15 files changed
+30
-20
No files matched your search
@@ -0,0 +1,351 @@
|
||||
# The file explorer
|
||||
|
||||
Asked for by Bryan on 2026-09-03 and built the same day: browse a machine's
|
||||
directories, open files with the existing syntax highlighting and line
|
||||
numbers, edit behind a pencil, create through a modal, work over ssh, and
|
||||
open at the session's working directory.
|
||||
|
||||
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 and what to produce on purpose — is in
|
||||
AGENTS.md. `server/src/files.rs` is the backend and `FilesScreen.kt` /
|
||||
`FileViewer.kt` / `FileEditor.kt` / `FileLines.kt` are the app.
|
||||
|
||||
## 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.
|
||||
|
||||
## 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`; 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 else would need a
|
||||
session to exist first.
|
||||
|
||||
### 2. One shell script per operation, over `Transport`, on both transports
|
||||
|
||||
Each operation is a small POSIX script handed to `sh -c script sh "$path" …`
|
||||
through `Transport::capture` (or `capture_with_input`). The path and every
|
||||
other value cross as **positional arguments**, never interpolated into the
|
||||
script — the same rule `import::find` follows and the same reason
|
||||
`ssh::quote` exists: a path is attacker-adjacent input in a server whose job
|
||||
is running commands. `PATH_PRELUDE` is the one line that gives a leading `~`
|
||||
its meaning, since a shell expands a tilde in text and not in an argument.
|
||||
|
||||
The scripts assume GNU coreutils and findutils (`find -printf`, `stat -c`,
|
||||
`sha256sum`, `chmod --reference`) — already what `import.rs` assumes, 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 cost is an `sh` process
|
||||
per operation locally, which is under a millisecond.
|
||||
|
||||
Rejected: a Rust SSH or SFTP library. The system `ssh` inherits
|
||||
`~/.ssh/config`, agents and jump hosts, and there is one place to configure a
|
||||
connection; SFTP would need a second.
|
||||
|
||||
### 3. The token can now name a path, and that is written down
|
||||
|
||||
Elsewhere the phone picks an **id** and the server resolves which file it
|
||||
names, so an enrolled token cannot become "read me an arbitrary file". The
|
||||
explorer's whole purpose is the path, so it takes one. Recorded in PLAN.md's
|
||||
Security section 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 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`, with the same wording, because where
|
||||
a relative path would be depends on something the reader cannot see. Every
|
||||
listing answers with `pwd -P` of the directory it listed, so the phone
|
||||
navigates on a resolved absolute path — the parent 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` (content, size, mtime,
|
||||
sha256), `binary` (not UTF-8; size reported, nothing shown), `tooBig` (over
|
||||
`FILE_LIMIT`, 1 MiB; size reported so the reader knows what they are looking
|
||||
at), or the machine's own error.
|
||||
|
||||
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, drawn as one empty line numbered 1, which is
|
||||
what it is.
|
||||
|
||||
### 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 exits distinctly if it differs;
|
||||
the server answers **409**. 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).
|
||||
|
||||
The write is `cat > "$1.ai-app-tmp" && chmod --reference="$1" … && mv -f`,
|
||||
with the bytes on stdin: a temp file and a rename, so a connection dropped
|
||||
mid-write leaves the old file whole rather than truncated, and
|
||||
`chmod --reference` keeps the mode a fresh file would lose (an executable
|
||||
script would stop being one). What this trades away is the inode, 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; accepted, 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` 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` 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 where 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`; 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, so a 20,000-line
|
||||
file in one `Text` measures all of it to draw a screenful.
|
||||
|
||||
**Every row is given the same width**, and that is what makes the shared
|
||||
horizontal scroll work. `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. The width is
|
||||
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 while the rest of
|
||||
the file sat still. It cannot be seen from this VM: the emulator's
|
||||
screenshots come back with no stretch in them at all, for any scrollable, so
|
||||
that 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 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 with its number at
|
||||
exactly its own top. A consequence worth having: the numbers are outside the
|
||||
`SelectionContainer`, so copying part of a file gives the code rather than
|
||||
the code with a number in front of every line.
|
||||
|
||||
The gutter is right-aligned, its 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. 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 — one table, not two, so a language added for fences is
|
||||
added for files. A file with no entry is drawn plain.
|
||||
|
||||
### 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…` beside the field, aligned for the same reason as the
|
||||
viewer.
|
||||
|
||||
Save is a glyph in the header, **disabled** until the text differs from what
|
||||
was loaded — never hidden, since a control that comes and goes makes its own
|
||||
absence the signal. Back with unsaved changes asks, and says the edits will
|
||||
be lost. The explorer draws over the session, which deliberately has no
|
||||
`imePadding`, so the explorer's own box adds it.
|
||||
|
||||
### 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 set and goes to the list otherwise. Inside the explorer the same back
|
||||
steps one level: editor → viewer (with the unsaved question) → listing →
|
||||
parent directory, 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. 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, 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: `md-folder` U+F024B (the header button and
|
||||
directory rows), `md-plus` U+F0415, `md-pencil` U+F03EB,
|
||||
`md-content_save` U+F0193, `md-file_outline` U+F0224. The folder and the plus
|
||||
are the same codepoints dev-updater uses and must not drift from it, as the
|
||||
cog and the refresh arrow already must not. 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.
|
||||
|
||||
**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 keeps it. Asked for in that order by Iris on 2026-09-03.
|
||||
|
||||
### 13. The render report moved, and the benches moved with it
|
||||
|
||||
The speedometer went; the report is a "Copy render timings" row in
|
||||
`SessionSettingsDialog`, where the session's other about-the-session controls
|
||||
already are. **Moving it is where the no-coordinate-taps rule got enforced**
|
||||
(Bryan, 2026-09-03) — see AGENTS.md's "Driving the UI".
|
||||
|
||||
## HTTP surface
|
||||
|
||||
In `routes.rs`'s module doc with the rest. 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}]}
|
||||
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; 409 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.
|
||||
|
||||
## 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.
|
||||
|
||||
**`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 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.
|
||||
- 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.
|
||||
Reference in new issue
Block a user