Files
ai-app/docs/EXPLORER.md

19 KiB

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.

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.