364 lines
20 KiB
Markdown
364 lines
20 KiB
Markdown
# 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 — returns from an
|
|
open file to its containing directory, then clears `files` and returns to the
|
|
session. Directory navigation stays in the listing: `..` is an explicit row
|
|
rather than a hidden second meaning for Back. An editor with unsaved changes
|
|
asks before returning to the directory. "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".
|
|
|
|
### 14. File links in a session open in the explorer
|
|
|
|
A markdown destination that is an absolute path or a local `file:` URI opens that document in the
|
|
session's explorer, on the session's setup. A trailing editor line and optional column are removed;
|
|
the viewer opens the file but does not yet scroll to a line. Web links, relative links and `file:`
|
|
URIs naming another host keep their ordinary external behaviour. The distinction is deliberately
|
|
narrow: a relative link might be a web reference, and the phone must not silently reinterpret it as
|
|
a path on another machine.
|
|
|
|
The markdown link handler is provided around the session rather than taught about setups. That
|
|
keeps the renderer reusable and makes the explorer's existing setup target the one navigation path.
|
|
|
|
## 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.
|