EXPLORER.md's decision 8 said "one shared horizontalScroll state", which is what was built and is not sufficient on its own -- the reason is worth having beside the decision rather than only in the code that now works. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
479 lines
26 KiB
Markdown
479 lines
26 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.
|
|
|
|
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.
|