Merge remote-tracking branch 'origin/main'
# Conflicts: # app/androidApp/src/main/kotlin/com/example/aiapp/Sizes.kt
This commit is contained in:
commit
e3e02d55f7
29 files changed
+2737
-479
No files matched your search
@@ -5,11 +5,6 @@ replacing the Claude app for daily use. Rust/Axum backend on the desktop,
|
|||||||
Kotlin/Compose Android app, WireGuard + pinned self-signed TLS + bearer token
|
Kotlin/Compose Android app, WireGuard + pinned self-signed TLS + bearer token
|
||||||
between them.
|
between them.
|
||||||
|
|
||||||
**`TRANSCRIPT_RENDERING.md` is the record of the transcript work** --
|
|
||||||
measurements, techniques, the harness, and the ordered list of what is
|
|
||||||
next. Read it before touching anything under `Markdown*.kt`,
|
|
||||||
`Transcript*.kt` or `SessionScreen.kt`'s list.
|
|
||||||
|
|
||||||
**`PLAN.md` is the design source of truth.** Read it before building or
|
**`PLAN.md` is the design source of truth.** Read it before building or
|
||||||
changing anything structural. It records every decision with its date, its
|
changing anything structural. It records every decision with its date, its
|
||||||
rationale, and the alternatives that were rejected and why — keep that habit
|
rationale, and the alternatives that were rejected and why — keep that habit
|
||||||
@@ -129,6 +124,35 @@ repo is in PLAN.md's "Backend layout" section.
|
|||||||
what makes two icon buttons the same width without either being given
|
what makes two icon buttons the same width without either being given
|
||||||
one, and it is why `GLYPH_SIZE` is smaller than it looks like it should
|
one, and it is why `GLYPH_SIZE` is smaller than it looks like it should
|
||||||
be.
|
be.
|
||||||
|
- **The file explorer** — `FilesScreen.kt` (the navigation stack, the
|
||||||
|
per-directory cache, the create dialog), `FileViewer.kt` (a `LazyColumn`
|
||||||
|
of lines, each with its own colours from `FileLines.kt`, sharing one
|
||||||
|
horizontal scroll so nothing wraps), `FileEditor.kt` (a
|
||||||
|
`BasicTextField` with a `VisualTransformation` carrying the scanner's
|
||||||
|
spans, which is the one Compose API that colours a field's own text).
|
||||||
|
It draws **over** the session in `AppRoot`'s `Screen.Session`, so the
|
||||||
|
session under it stays composed and coming back from a file costs
|
||||||
|
nothing; back steps editor → viewer → directory → parent and only closes
|
||||||
|
from where it opened. `EXPLORER.md` is the design and `server/src/files.rs`
|
||||||
|
is the other half.
|
||||||
|
To exercise it, `./ui-sandbox.sh` builds a fixture tree at the sandbox
|
||||||
|
home's `~/files` holding the states that are otherwise only reachable by
|
||||||
|
finding a real machine in one: an empty directory, a name with a tab in
|
||||||
|
it and one with an apostrophe, a binary file, one over `FILE_LIMIT`, one
|
||||||
|
`chmod 000`, a symlink to a directory and a broken one, a source file per
|
||||||
|
language, and the three sizes the limits were measured against
|
||||||
|
(`edit-32k.rs`, `edit-128k.rs`, `big-source.rs`), so those figures can be
|
||||||
|
taken again rather than re-derived. Point a session at it with
|
||||||
|
`./ui-sandbox.sh api /sessions/<id>/cwd -X POST -H 'content-type: application/json' -d '{"cwd":"~/files"}'`.
|
||||||
|
The 409 is produced by editing the file on the machine (`printf … > file`)
|
||||||
|
between pressing the pencil and pressing save.
|
||||||
|
**Reading is cheap and editing is not**, and the sizes are measured
|
||||||
|
rather than guessed -- see EXPLORER.md's "What the measurements said".
|
||||||
|
The viewer handles a 1 MiB, 28,000-line file because it draws one row per
|
||||||
|
line; the editor is one `BasicTextField`, which costs two seconds a frame
|
||||||
|
at 128 kB and stops the app at 1 MiB, so `EDIT_LIMIT` caps it at 32 kB
|
||||||
|
with the reason said on screen. If you make the editor faster, that
|
||||||
|
number is what to move.
|
||||||
- `.dev-updater.ron` — what Dev Updater is asked to do with this checkout:
|
- `.dev-updater.ron` — what Dev Updater is asked to do with this checkout:
|
||||||
the server (built in `server/`, run as `service: Managed(...)`) and the
|
the server (built in `server/`, run as `service: Managed(...)`) and the
|
||||||
APK (built in `app/`), built in parallel. The project it serves is the
|
APK (built in `app/`), built in parallel. The project it serves is the
|
||||||
@@ -212,6 +236,23 @@ IQ2_XXS of that model produces fluent nonsense, which reads exactly like a
|
|||||||
broken driver — `llama-cli` produces the same from the file directly, which
|
broken driver — `llama-cli` produces the same from the file directly, which
|
||||||
is how to tell the two apart in a hurry.
|
is how to tell the two apart in a hurry.
|
||||||
|
|
||||||
|
**No script that drives this app's UI presses a coordinate.** Every control
|
||||||
|
is found by the name it already carries for assistive technology --
|
||||||
|
`ui-trace record --do "tap 'Session settings'"`, which resolves the label
|
||||||
|
against the screen at the moment of the gesture and fails the whole run
|
||||||
|
when it is not there. `app/bench-lib.sh` is what `transcript-bench.sh` and
|
||||||
|
`stream-bench.sh` share for it. A coordinate is a position measured once by
|
||||||
|
hand, and anything that moves the control makes the tap land on whatever
|
||||||
|
now sits there -- the bench then reports a number that was never measured,
|
||||||
|
which reads exactly like a result. Both scripts pressed the render report
|
||||||
|
at `tap 723 205` until that button moved into the session settings dialog
|
||||||
|
on 2026-09-03. The check that none has crept back:
|
||||||
|
|
||||||
|
grep -n "tap [0-9]" app/*.sh
|
||||||
|
|
||||||
|
Swipes are still coordinates, deliberately: a gesture across a scrolling
|
||||||
|
area is a distance rather than a control.
|
||||||
|
|
||||||
**How to test SSH here, since there is no second machine:** ssh this VM to
|
**How to test SSH here, since there is no second machine:** ssh this VM to
|
||||||
itself. Generate a throwaway key, append the public half to
|
itself. Generate a throwaway key, append the public half to
|
||||||
`~/.ssh/authorized_keys`, and configure a host of `bob@127.0.0.1` with
|
`~/.ssh/authorized_keys`, and configure a host of `bob@127.0.0.1` with
|
||||||
@@ -324,7 +365,7 @@ first if a remote spawn ever mangles an argument.
|
|||||||
**An inline code chip is drawn behind the text** rather than as the
|
**An inline code chip is drawn behind the text** rather than as the
|
||||||
renderer's span background, because a span background is part of the
|
renderer's span background, because a span background is part of the
|
||||||
text's own drawing and hid the selection under it -- see
|
text's own drawing and hid the selection under it -- see
|
||||||
`appendCodeChip` in `MarkdownLinks.kt` and TRANSCRIPT_RENDERING.md.
|
`appendCodeChip` in `MarkdownLinks.kt`.
|
||||||
- **A session can be moved to another directory** from the settings dialog
|
- **A session can be moved to another directory** from the settings dialog
|
||||||
(`POST /sessions/{id}/cwd`). It stops the process, because a working
|
(`POST /sessions/{id}/cwd`). It stops the process, because a working
|
||||||
directory is settled at spawn; the next message starts it in the new one.
|
directory is settled at spawn; the next message starts it in the new one.
|
||||||
@@ -477,7 +518,48 @@ first if a remote spawn ever mangles an argument.
|
|||||||
the in-app copy button produces, whose `on screen:` line names what the
|
the in-app copy button produces, whose `on screen:` line names what the
|
||||||
viewport was actually holding. Compare two runs of it with the same
|
viewport was actually holding. Compare two runs of it with the same
|
||||||
gestures; the emulator's absolute frame times transfer nothing, the
|
gestures; the emulator's absolute frame times transfer nothing, the
|
||||||
report's accounting does.
|
report's accounting does. Run it either side of any change under
|
||||||
|
`Markdown*.kt`, `Transcript*.kt` or `SessionScreen.kt`'s list, and put the
|
||||||
|
report in the commit; the numbers that move first are the worst
|
||||||
|
`record: one block`, the reparse mean while streaming, and the draw
|
||||||
|
phase's accounting line.
|
||||||
|
- **`app/stream-bench.sh [-k] FILE` is that measurement for a reply still
|
||||||
|
arriving.** It opens the first session, taps "Jump to latest" so the list
|
||||||
|
is pinned to the newest end, resets the report, sends FILE, waits for the
|
||||||
|
transcript to stop growing, and prints. Both of those are corrections to a
|
||||||
|
first version that measured nothing: a transcript parked further back never
|
||||||
|
redraws while a reply streams into it, and a session is idle at *both* ends
|
||||||
|
of a turn, so polling for idle answers before the turn has started.
|
||||||
|
- **`app/trace-draw.sh` names what a scrolling frame spends inside the
|
||||||
|
framework**, from `atrace` text output with no trace processor needed. It
|
||||||
|
is how the cost of a layout node per link was attributed to the framework
|
||||||
|
rather than guessed at.
|
||||||
|
- **Two traps in the emulator bench loop**, each of which cost a run.
|
||||||
|
`adb shell pm clear` removes the enrolment and the notification permission
|
||||||
|
along with the saved anchors, so the next run measures a permission dialog
|
||||||
|
-- re-enrol with the command `ui-sandbox.sh` prints, and
|
||||||
|
`pm grant ... POST_NOTIFICATIONS`. And a saved scroll anchor is per session
|
||||||
|
id, so the only way two builds start a scroll from the same place is a
|
||||||
|
*fresh session for each*.
|
||||||
|
- **A phone that falls behind the stream is answered with `reset`, and
|
||||||
|
`RUST_LOG=ai_server=debug` says when.** Every SSE subscriber logs the
|
||||||
|
cursor it arrived with and whether it was continued or reset
|
||||||
|
(`stream backlog:` in `send_backlog`), which is the only place that
|
||||||
|
question is answerable: the app sees a window arrive and cannot tell how
|
||||||
|
far it had fallen, and a reset is the one thing that makes its screen jump
|
||||||
|
to the newest end. Measured 2026-09-04 against a session streaming at 20
|
||||||
|
events a second: reopening one with an anchor 1,800 events back connects
|
||||||
|
**87-119 events behind**, well under `CATCH_UP_LIMIT`'s 200, because the
|
||||||
|
restore is two requests -- the opening page, then one span covering the
|
||||||
|
whole distance to the anchor. So the reset path is not reachable by
|
||||||
|
reopening a session, and **to exercise it at all you have to lower
|
||||||
|
`CATCH_UP_LIMIT`** in a throwaway server build; at 5 the app takes the
|
||||||
|
reset on a live connection, clears, refills and carries on without
|
||||||
|
reconnecting. Worth knowing alongside it: **the session screen's stream
|
||||||
|
survives backgrounding here** -- 20 seconds at the launcher while 415
|
||||||
|
events were produced brought no reconnect at all -- which is not what the
|
||||||
|
comment above that loop expects, and is most likely this emulator being
|
||||||
|
headless rather than the phone's behaviour.
|
||||||
- **`ai-server --delay MS` holds every response back.** Over the tunnel a
|
- **`ai-server --delay MS` holds every response back.** Over the tunnel a
|
||||||
phone's requests take tens to hundreds of milliseconds, and several
|
phone's requests take tens to hundreds of milliseconds, and several
|
||||||
faults live entirely in what the app does *while* one is outstanding. On
|
faults live entirely in what the app does *while* one is outstanding. On
|
||||||
|
|||||||
+105
-54
@@ -7,10 +7,11 @@ 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
|
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.
|
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
|
Built on 2026-09-03. This is the design, decision by decision with the
|
||||||
what was rejected, so that when one changes it is changed here rather than
|
reason and what was rejected, so that when one changes it is changed here
|
||||||
re-argued. Once built, the operational notes (how to test it, what bit)
|
rather than re-argued. The operational half -- how to run it, what to press,
|
||||||
move to AGENTS.md and this file keeps only the design.
|
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
|
## What it is, in one paragraph
|
||||||
|
|
||||||
@@ -163,6 +164,49 @@ draw a screenful. Lines are drawn with `softWrap = false` inside one
|
|||||||
shared `horizontalScroll` state, so the whole file scrolls sideways as a
|
shared `horizontalScroll` state, so the whole file scrolls sideways as a
|
||||||
block and a line never wraps.
|
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.
|
||||||
|
|
||||||
|
**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 and the
|
||||||
|
rest of the file sat still beside it. That is the same complaint one layer
|
||||||
|
further out, and it is only fixable now that every row agrees where the
|
||||||
|
end is. It cannot be seen from this VM: the emulator's screenshots come
|
||||||
|
back with no stretch in them at all, for any scrollable, so this 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 from 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 of them with its number at exactly its own top.
|
||||||
|
|
||||||
|
A consequence worth having: the numbers are no longer inside the
|
||||||
|
`SelectionContainer`, so selecting part of a file and copying it gives the
|
||||||
|
code rather than the code with a number in front of every line.
|
||||||
|
|
||||||
Line numbers are a gutter in each row, right-aligned, with the gutter
|
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
|
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
|
style -- so a 9-line file and a 12,000-line file each get exactly the
|
||||||
@@ -258,12 +302,18 @@ rerun and its output committed (it needs network):
|
|||||||
codepoint dev-updater uses, and it must not drift from it, as the cog
|
codepoint dev-updater uses, and it must not drift from it, as the cog
|
||||||
and the refresh arrow already must not.
|
and the refresh arrow already must not.
|
||||||
- `md-plus` U+F0415 -- create. Also dev-updater's.
|
- `md-plus` U+F0415 -- create. Also dev-updater's.
|
||||||
- `md-pencil` -- edit.
|
- `md-pencil` U+F03EB -- edit.
|
||||||
- `md-content_save` -- save.
|
- `md-content_save` U+F0193 -- save.
|
||||||
- `md-file_outline` -- file rows.
|
- `md-file_outline` U+F0224 -- file rows.
|
||||||
|
|
||||||
The last three are verified against the Nerd Fonts cheat sheet when they
|
All five were looked up in Nerd Fonts' own `glyphnames.json` rather than
|
||||||
are added, not copied from memory.
|
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
|
### 13. The render report moves, and the benches move with it
|
||||||
|
|
||||||
@@ -386,57 +436,51 @@ pure functions with tests.
|
|||||||
`~/repos/emulator-tools`, `ui-trace`'s tap-by-label action; then the
|
`~/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`.
|
two bench scripts onto it, with no coordinate tap left in `app/*.sh`.
|
||||||
|
|
||||||
## Testing
|
## What the measurements said (2026-09-04)
|
||||||
|
|
||||||
- **Server**: `./run-tests.sh`, `cargo clippy --all-targets`, `cargo fmt`.
|
Taken on the emulator in a **debug** build, which runs Compose at a
|
||||||
- **Local transport, by hand**: `./ui-sandbox.sh api
|
fraction of release speed and renders in software -- so these rank
|
||||||
"/setups/<id>/dir?path=~"` against the sandbox, whose `$HOME` is a
|
correctly against each other and are pessimistic in absolute terms.
|
||||||
throwaway tree it is fine to write into. The sandbox gets a small
|
Generated Rust, through the app's own render report.
|
||||||
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
|
| 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 | -- | -- |
|
||||||
|
|
||||||
- Scan time for a 1 MiB source file on the emulator, and on the phone
|
Three things followed.
|
||||||
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
|
**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.
|
||||||
|
|
||||||
Each step leaves the app working and is one commit.
|
**`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.
|
||||||
|
|
||||||
1. Server: `files.rs` with `list` and `read`, routes, tests. Half a day.
|
**Edit mode needed a cap, and not the one that was expected.** The plan
|
||||||
2. App: icons, `Api.kt`, `FilesScreen` listing, `FileViewer`, the root
|
expected to be deciding a size below which highlighting stays on. That is
|
||||||
and session wiring, the render-report move with the benches. A day.
|
not the cost that matters: highlighting 128 kB costs 40ms a keystroke,
|
||||||
3. Server: `write`, `create_file`, `create_dir`, the stdin helper and
|
which is survivable, while laying the same text out in one
|
||||||
`ship_attachment` onto it. Half a day.
|
`BasicTextField` costs two seconds -- characters typed into it were
|
||||||
4. App: `FileEditor`, the create dialog, the conflict dialog. Half a day.
|
dropped, and a 1 MiB file stopped the app responding altogether. Since
|
||||||
5. Measurements above, the sandbox fixture, PLAN.md and AGENTS.md. Half a
|
every arrangement of a single text field pays that, switching highlighting
|
||||||
day.
|
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
|
## Later, deliberately not now
|
||||||
|
|
||||||
@@ -451,3 +495,10 @@ Each step leaves the app working and is one commit.
|
|||||||
- Uploading from the phone into a directory. Attachments already do the
|
- Uploading from the phone into a directory. Attachments already do the
|
||||||
upload half; this would be the same route with a chosen destination.
|
upload half; this would be the same route with a chosen destination.
|
||||||
- Search within a file, and find-in-files.
|
- 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.
|
||||||
+6
-4
@@ -500,10 +500,12 @@ what a reader waits on.
|
|||||||
today's behaviour and not made worse, but the server-side check it
|
today's behaviour and not made worse, but the server-side check it
|
||||||
rejects would close it. Decide after measuring how often the probe's
|
rejects would close it. Decide after measuring how often the probe's
|
||||||
round trip is what the reader waits on.
|
round trip is what the reader waits on.
|
||||||
- **TRANSCRIPT_RENDERING.md's item 1** (a reset arriving during an anchor
|
- **A reset arriving during an anchor restore** was an open worry when this
|
||||||
restore left the screen reconnecting). The cache makes the restore
|
was written, and was measured and closed on 2026-09-04 (see "The reconnect
|
||||||
cheaper and therefore shorter, which narrows the window without closing
|
loop does not reproduce") before this landed. The cache makes the restore
|
||||||
it; the fix there is still owed and is unrelated to this.
|
cheaper again -- a warm one is now the probe and nothing else -- so it can
|
||||||
|
only have narrowed the window further. Worth re-measuring here only if a
|
||||||
|
reader reports the screen reconnecting on reopen.
|
||||||
- **Images.** `SessionImage` fetches bytes from the files route on draw;
|
- **Images.** `SessionImage` fetches bytes from the files route on draw;
|
||||||
they are not part of this cache and are re-downloaded per view. A
|
they are not part of this cache and are re-downloaded per view. A
|
||||||
separate, simpler cache (a directory of refs, no ordering) if the
|
separate, simpler cache (a directory of refs, no ordering) if the
|
||||||
|
|||||||
@@ -1,285 +0,0 @@
|
|||||||
# Transcript rendering: what was learned, and what is next
|
|
||||||
|
|
||||||
Written 2026-09-03 at the end of a week of work on the session screen's
|
|
||||||
transcript, so the next session can start from here rather than from a
|
|
||||||
compacted context. Work that is finished lives in "the architecture, as
|
|
||||||
built"; the running log of how each piece got there has been dropped. `AGENTS.md` holds the one-paragraph conventions; this is
|
|
||||||
the longer record: the measurements that drove each decision, the
|
|
||||||
techniques that worked, the ones that did not, and the order to do the rest
|
|
||||||
in. `PLAN.md` remains the design source of truth; nothing here contradicts
|
|
||||||
it.
|
|
||||||
|
|
||||||
## The goal, and where it stands
|
|
||||||
|
|
||||||
A reply of any length must scroll at the phone's 120Hz without a bump, and
|
|
||||||
must keep doing so while the reply is still streaming in. Measured on the
|
|
||||||
Pixel 9 Pro XL by Bryan, the transcript went from visible stalls at long
|
|
||||||
replies and at lists of links to "I have to actually try to feel any
|
|
||||||
bumps". The remaining work is finish and extensibility rather than
|
|
||||||
performance.
|
|
||||||
|
|
||||||
## The architecture, as built
|
|
||||||
|
|
||||||
Everything below lives under `app/androidApp/src/main/kotlin/com/example/aiapp/`.
|
|
||||||
|
|
||||||
**Rows become units, and units are bounded.** `TranscriptUnits.kt` turns a
|
|
||||||
transcript row into the things the lazy list actually holds. An assistant
|
|
||||||
reply is not one unit: it is one unit per piece of its markdown, so the
|
|
||||||
list composes and draws a paragraph, a fence, a table or one bullet at a
|
|
||||||
time. The reason is the draw phase: a row's display list holds every glyph
|
|
||||||
of it and is re-recorded whenever drawing is invalidated, and the lazy list
|
|
||||||
composes an item whole in the frame it scrolls into. The tallest single
|
|
||||||
row still being drawn before this was 36,982px, twenty-five screens in one
|
|
||||||
message. Long user messages are sliced the same way (`UserChunk`), through
|
|
||||||
the shared `cardPiece` modifier that draws one card in lazy-list pieces.
|
|
||||||
|
|
||||||
**One parse per message, addressed by piece.** `MarkdownPieces.kt`'s
|
|
||||||
`Piece(block, item)` is an address into the message's single parse tree,
|
|
||||||
not a substring: `block` indexes the root's children and `item` one
|
|
||||||
`LIST_ITEM` of a top-level list. Cutting was originally done by
|
|
||||||
re-parsing substrings, which cost a parse per piece and broke reference
|
|
||||||
links defined at the foot of a message. `ParsedReplies` caches the parse
|
|
||||||
and the piece list per text (`of`, `piecesOf`), warmed off the composing
|
|
||||||
thread by `TranscriptItems.warm`. The parser is still intellij-markdown via
|
|
||||||
the mikepenz renderer, but its `Markdown()` composable is not called at all:
|
|
||||||
`MarkdownRoot` in `Markdown.kt` provides the `Local*` environment itself --
|
|
||||||
reference links from the parse, padding, dimens, colours, typography, a
|
|
||||||
no-op image transformer, animations, components -- and `MarkdownElement`
|
|
||||||
dispatches a whole block through our component table. Nothing between a
|
|
||||||
piece and the screen is the library's now except the leaf composables that
|
|
||||||
table names.
|
|
||||||
|
|
||||||
**Lists are drawn an item at a time, by us.** The renderer has no element
|
|
||||||
for a single list item, so `MarkdownListItem` draws one: marker, then the
|
|
||||||
item's children, nested lists recursing through `MarkdownList`. The
|
|
||||||
marker is drawn in one place on purpose; styled bullets per depth go
|
|
||||||
there.
|
|
||||||
|
|
||||||
**Links are spans, not nodes.** `MarkdownLinks.kt`. Compose turns every
|
|
||||||
`LinkAnnotation` into a layout node (clipped, focusable, hoverable,
|
|
||||||
clickable, outline recomputed from the text layout). A paragraph of eight
|
|
||||||
links was nine nodes, and measured against the same paragraphs with each
|
|
||||||
link replaced by plain words it cost 26.3ms worst measure against 5.2ms,
|
|
||||||
1.7x the place time. That was the bump at a reply's list of sources.
|
|
||||||
`LinkedText` builds the annotated string with the renderer's own inline
|
|
||||||
builder but answers links itself: colour, underline, a string annotation
|
|
||||||
carrying the URL, and one tap detector for the whole text that asks the
|
|
||||||
layout which glyph is under the finger. Hit-testing must check the glyph
|
|
||||||
on either side of the returned caret, because `getOffsetForPosition`
|
|
||||||
returns the nearest boundary; taps on the right half of a glyph otherwise
|
|
||||||
open nothing. Headings need the `ATX_CONTENT`/`SETEXT_CONTENT` child, since
|
|
||||||
the inline builder draws nothing for a node type it does not know (a week
|
|
||||||
of blank headings). Tables go through `LinkedTable`/`LinkedTableRow` so
|
|
||||||
cells get the same treatment.
|
|
||||||
|
|
||||||
**An inline code chip is drawn behind the text, not as a span
|
|
||||||
background.** A `SpanStyle` background is part of the text's own drawing
|
|
||||||
and the text node draws the selection *under* the glyphs, so an opaque
|
|
||||||
chip hid the selection: selecting a sentence highlighted every word of it
|
|
||||||
except the ones in backticks, and there is no way to reorder that -- the
|
|
||||||
order is the node's. `appendCodeChip` therefore takes the code span from
|
|
||||||
the renderer's builder, keeps its style and its space of padding either
|
|
||||||
side but drops the background, and marks the range; `LinkedText` draws
|
|
||||||
those ranges in a `drawBehind`, which is under both the selection and the
|
|
||||||
glyphs -- the same place a fenced block's box already was, which is why
|
|
||||||
one of those always looked right. Geometry is one box per line, from the
|
|
||||||
bounding boxes of the run's first and last characters, taken as far as the
|
|
||||||
line's `visibleEnd`: `getPathForRange` is a *selection* shape and runs to
|
|
||||||
the right edge of every line but the last, which left a full-width empty
|
|
||||||
chip behind whenever the code wrapped, and `visibleEnd` is what makes the
|
|
||||||
chip and the selection rectangle stop in the same place. Measured against
|
|
||||||
the same build without it, streaming 60 paragraphs of three chips each:
|
|
||||||
measure 755ms against 776ms, record 327ms against 321ms, transcript draw
|
|
||||||
0.22ms in both -- noise.
|
|
||||||
|
|
||||||
**Text draws on the platform directly.** A paragraph without an image
|
|
||||||
skips the renderer's `MarkdownText`, which charges every paragraph for the
|
|
||||||
possibility of inline images (placement callback, derived inline-content
|
|
||||||
map, semantics group, size animation). Paragraphs that contain an image
|
|
||||||
still take the renderer's path.
|
|
||||||
|
|
||||||
**Tables spread or scroll without subcomposition.** The renderer used
|
|
||||||
`BoxWithConstraints` to decide; `LinkedTable` uses
|
|
||||||
`fillMaxWidth().horizontalScroll().layout { }` -- `horizontalScroll`
|
|
||||||
passes `minWidth` through and lifts `maxWidth` to infinity, so the inner
|
|
||||||
layout reads `minWidth` as the room available and takes
|
|
||||||
`max(minWidth, columns * cellWidth)`.
|
|
||||||
|
|
||||||
**A streaming reply is reparsed one block at a time.** `LiveParse` in
|
|
||||||
`Markdown.kt` freezes every finished top-level block with its parse and
|
|
||||||
reparses only the tail block per delta. Markdown's block rules make later
|
|
||||||
text unable to alter an earlier block, with the single exception of a
|
|
||||||
late reference definition, which is accepted. Measured on a 58-word stream
|
|
||||||
of list, fence, table and quote: 47 tail reparses at 1.7ms mean. A
|
|
||||||
single-list stream would reparse the whole list per delta, since it is one
|
|
||||||
tail block; that is what the rule below cuts.
|
|
||||||
|
|
||||||
**A streaming list becomes a unit per item.** `LiveParse.advanceTo` cuts at
|
|
||||||
the last item of a multi-item list (`openPiece`), provided that item has
|
|
||||||
content beyond its marker -- a bare `-` is an empty item now and the first
|
|
||||||
character of a paragraph line once `-x` arrives, so cutting on it would draw
|
|
||||||
that line as a new item. The cut is at the start of the item's line, so the
|
|
||||||
indentation the reparse reads its nesting from survives. `Segment.continues`
|
|
||||||
marks a tail that carries on a list, and `MarkdownPiece`'s
|
|
||||||
`continuesList`/`listContinues` keep an inner item's padding at the seam, so
|
|
||||||
nothing moves when the seam does. Forty linked bullets streamed a word at a
|
|
||||||
time went from 2412ms of reparsing to 674ms, and `record: one block` from
|
|
||||||
1.8ms worst to 0.7ms.
|
|
||||||
|
|
||||||
**Fences are highlighted off the drawing thread, and a fence still being
|
|
||||||
written is drawn plain.** `Highlighter.kt` holds `highlight` and the scanner
|
|
||||||
behind it (shared with a tool call's input, so the same code is the same
|
|
||||||
colours wherever it appears); `CodeFence.kt` holds the `fenceLanguage` alias
|
|
||||||
table and `fenceContent`. A word not in the table stays plain, because a
|
|
||||||
fence coloured by the wrong language's rules looks highlighted and is wrong
|
|
||||||
in a way the reader cannot see. Highlighting is warmed and cached exactly as
|
|
||||||
parsing is (`ParsedReplies.highlighted`, filled by `warm` from
|
|
||||||
`fences(parse)`), and `highlight` takes no colour from the theme, which is
|
|
||||||
what lets it run off the drawing thread: a two-hundred-line Kotlin fence
|
|
||||||
costs 15ms to scan on the emulator's debug build -- it cost 102ms through
|
|
||||||
the library that used to do this -- and a `remember` inside the fence was
|
|
||||||
charged that again every time the block scrolled back into composition. Because the warming has to ask for the same
|
|
||||||
string the drawing does, `fenceContent` extracts the code and the language
|
|
||||||
word itself -- two extractions would be two keys, and the warmed answer
|
|
||||||
would be missed at every fence with nothing saying so. A fence still
|
|
||||||
arriving is the same stall in a second place, and warming cannot reach it:
|
|
||||||
the tail was re-lexed at every delta, on the composing thread, for colours
|
|
||||||
on text being replaced as fast as they were computed -- 211 lexes and 13.7
|
|
||||||
seconds across one turn. So `MarkdownRoot`'s `streaming`, true only for a
|
|
||||||
live reply's last segment, draws the block plain until it freezes; a
|
|
||||||
finished fence colours as soon as the next block starts, and the settling
|
|
||||||
lex happens once, in `warm`.
|
|
||||||
|
|
||||||
**Markers, and images.** `MarkdownListItem`'s `Marker` draws the bullet by
|
|
||||||
depth, cycling past the third, in `listMarkerColor` (Theme.kt). The colour
|
|
||||||
is the same at every depth on purpose: depth is said by the glyph and the
|
|
||||||
indent, and a colour per depth would make a difference in degree look like
|
|
||||||
one in kind. The app has no image loader and the renderer's transformer was
|
|
||||||
the no-op one, so an image in a reply drew as *nothing at all*; an `IMAGE`
|
|
||||||
node is now appended by `appendPlainLink` as a link carrying its alt text
|
|
||||||
(the address when there is none), which says what was there and opens it.
|
|
||||||
|
|
||||||
**Expansion anchors the edge that was tapped, and the list never moves
|
|
||||||
under the reader** except when pinned to the bottom with new content
|
|
||||||
arriving. Those two rules are in `ScrollAnchor.kt` and `TranscriptList.kt`
|
|
||||||
and are the reason several tempting simplifications were rejected.
|
|
||||||
|
|
||||||
## Techniques and harness
|
|
||||||
|
|
||||||
- **`app/ui-sandbox.sh`** starts a second `ai-server` against a sandbox
|
|
||||||
home with the echo driver, so nothing touches real sessions.
|
|
||||||
`spawn [title]` makes an echo session and prints its id; `send SID text`
|
|
||||||
or `send SID @file` sends into it; `api /path [curl args]` is an
|
|
||||||
authenticated request. Restarting it regenerates the config but keeps
|
|
||||||
enrolled tokens.
|
|
||||||
- **The echo driver is the test rig** (`server/src/session/echo.rs`, the
|
|
||||||
list at the top of the file). `/stream N`, `/mixed N`, `/table N`,
|
|
||||||
`/tools N gap`, `/ask`, `/peer`, `/compact`, `/slow`, `/bash command`
|
|
||||||
each produce a shape the real CLI produces only when it feels like it.
|
|
||||||
Build what a UI test needs into it rather than spending model turns.
|
|
||||||
- **`app/transcript-bench.sh`** is the standard measurement: restart, open
|
|
||||||
the first session, scroll, print the render report. The report is what
|
|
||||||
the "Copy render timings" button copies and also logs
|
|
||||||
(`adb logcat -d -s ai-app:I`), and it includes the last crash's stack
|
|
||||||
(`CrashLog.kt`), which is how a crash on the phone reaches a session
|
|
||||||
here.
|
|
||||||
- **`app/stream-bench.sh [-k] FILE`** is `transcript-bench.sh` for a reply
|
|
||||||
still arriving: opens the first session, taps "Jump to latest" so the list
|
|
||||||
is pinned to the newest end, resets the report, sends FILE, waits for the
|
|
||||||
transcript to stop growing, prints the report. Both of those last two are
|
|
||||||
corrections to a first version that measured nothing -- a transcript parked
|
|
||||||
further back never redraws while a reply streams into it, and a session is
|
|
||||||
idle at *both* ends of a turn, so polling for idle answers before the turn
|
|
||||||
has started. Fixtures live in `/tmp` and are regenerated from the shapes
|
|
||||||
named here: `fixture.md` (lists four deep, ordered and nested, fences in
|
|
||||||
kotlin/rust/sh/none, a table with a link, a quote with a list, an inline
|
|
||||||
and a standalone image, a reference link), `longfence.md` (200-line Kotlin
|
|
||||||
fence), `longlist.md` (40 linked items).
|
|
||||||
- **Two traps in the emulator loop**, each of which cost a bench run.
|
|
||||||
`adb shell pm clear` removes the enrolment and the notification permission
|
|
||||||
along with the saved anchors, so the next run measures a permission
|
|
||||||
dialog; re-enrol with the command `ui-sandbox.sh` prints and
|
|
||||||
`pm grant ... POST_NOTIFICATIONS`. And a saved anchor is per session id,
|
|
||||||
so the only way two builds start a scroll from the same place is a *fresh
|
|
||||||
session for each*.
|
|
||||||
- **`DebugStats`/`FrameStats`** time our own phases (`record: one block`,
|
|
||||||
`measure: the app root`) and count events (`markdown reparsed while
|
|
||||||
streaming`, `markdown cut into pieces`). Add a counter before guessing.
|
|
||||||
- **`app/trace-draw.sh`** names what a scrolling frame spends inside the
|
|
||||||
framework, via `atrace` text output, no trace processor needed. It is
|
|
||||||
how the link-node cost was attributed.
|
|
||||||
- **`app/debug-transcript.sh`** loads a real Claude Code conversation onto
|
|
||||||
the emulator; two faults were invisible on fixtures and obvious on it.
|
|
||||||
Real transcripts are private: fixtures stay in `/tmp`, never in the repo.
|
|
||||||
- **`ui-trace`** reads the screen as text. Bounds print as
|
|
||||||
`x1,y1..x2,y2`; unanchored `-m` patterns match labels, anchored ones do
|
|
||||||
not. A row taller than the viewport reports clipped bounds, so compare
|
|
||||||
screenshots for that case.
|
|
||||||
- **Emulator frame times are not app measurements.** Software rendering
|
|
||||||
puts the stock Settings app at 60ms of UI-thread traversal per frame.
|
|
||||||
Costs of operations in milliseconds rank correctly; smoothness itself is
|
|
||||||
judged on the phone.
|
|
||||||
- **System Tracing on the phone does not work on GrapheneOS.** Its
|
|
||||||
Categories list is empty because the tracing daemon builds it by running
|
|
||||||
`atrace --list_categories`, which returns nothing there, and a recorded
|
|
||||||
trace contains zero ftrace events: no app sections, no frames, no
|
|
||||||
scheduling. Callstack sampling records, but the app's profiler config
|
|
||||||
unwinds one process shard in four. GrapheneOS issues 2206 and 6094 are
|
|
||||||
open on exactly this. Until they close, phone numbers come from the
|
|
||||||
render report and from Bryan noticing.
|
|
||||||
- **Compose `DropdownMenu` in an edge-to-edge activity** needs
|
|
||||||
`PopupProperties(clippingEnabled = false)` or it opens a status bar's
|
|
||||||
height away from its anchor (`~/.claude/TOOLCHAIN.md`).
|
|
||||||
- **The syntax highlighter is ours: `Highlighter.kt` and `Languages.kt`.**
|
|
||||||
One left-to-right scanner with a small state -- in a line comment, in a
|
|
||||||
block comment, in a string, or in ordinary code -- and a `Rules` row per
|
|
||||||
language, so a new language is a table entry rather than code. Every span
|
|
||||||
is emitted by advancing an index, so spans cannot overlap, arrive out of
|
|
||||||
order or run backwards, and an unterminated string or comment simply runs
|
|
||||||
to the end of the code. `HighlighterTest.kt` is the JVM unit test
|
|
||||||
(`./gradlew :androidApp:testDebugUnitTest`); the cases in it are the
|
|
||||||
library's mistakes, kept as regressions.
|
|
||||||
It replaced dev.snipme:highlights 1.1.0 on 2026-09-03, which found
|
|
||||||
comments before it knew the language and paired `/*` with `*/` by
|
|
||||||
ordinal. That library used one set of delimiters for every language, so
|
|
||||||
`//` in any URL commented out the rest of its line (in `curl
|
|
||||||
https://example.com/x && echo done` the comment ran to the end and took
|
|
||||||
`echo` with it, and in Kotlin `val url = "https://..."` the string
|
|
||||||
disappeared inside it), every Rust `#[derive(...)]` greyed out as a
|
|
||||||
comment, a `#` inside a Kotlin string swallowed the line, and `x '*/a/*'`
|
|
||||||
in shell yielded `start=6, end=5` -- a range `AnnotatedString` rejects,
|
|
||||||
which crashed a card holding `-path '*/.git/*'`. Comments were located
|
|
||||||
before strings and won over them, so post-processing could not recover
|
|
||||||
what a wrong comment range had already suppressed. The scanner is also
|
|
||||||
about seven times faster on the same fixture, and it colours RON, TOML,
|
|
||||||
fish and JSON, which the library did not know at all.
|
|
||||||
|
|
||||||
## Rejected, and why
|
|
||||||
|
|
||||||
- **Writing our own markdown renderer.** Rejected in favour of keeping
|
|
||||||
the intellij-markdown parser and the library's inline builder while
|
|
||||||
owning block dispatch and the leaf composables. The parser is the hard
|
|
||||||
part and is not the slow part; everything that was slow lived in the
|
|
||||||
composables, which are now ours.
|
|
||||||
- **Re-parsing substrings per piece.** Cost a parse per piece and broke
|
|
||||||
foot-of-message reference links. Replaced by addressed pieces of one
|
|
||||||
parse.
|
|
||||||
- **Animated or timing-dependent corrections.** Anything the reader could
|
|
||||||
catch at 120Hz is a bug; corrections must be structurally impossible to
|
|
||||||
see.
|
|
||||||
|
|
||||||
## What is next, in order
|
|
||||||
|
|
||||||
1. **The reconnect loop.** Restarting the app onto a session with a saved
|
|
||||||
anchor while a long reply was streaming left it reconnecting every 1.5s
|
|
||||||
(`RECONNECT_DELAY_MS`), spinner up, until the server was restarted.
|
|
||||||
`events?after=N` more than `CATCH_UP_LIMIT` (200) behind answers `reset`
|
|
||||||
plus the newest 200 *raw* deltas -- a window starting mid-message -- and
|
|
||||||
the reset clears `items`, which is the state the restore loop then pages
|
|
||||||
against. The restore's one-event-per-request bug was part of what made it
|
|
||||||
so visible and has been fixed; whether this survives that fix is the
|
|
||||||
first thing to find out.
|
|
||||||
2. **Regression runs.** `transcript-bench.sh` and `stream-bench.sh` before
|
|
||||||
and after any change to the files above, with the report in the commit.
|
|
||||||
The numbers to watch are the worst `record: one block`, the reparse mean
|
|
||||||
while streaming, and the draw phase's accounting line.
|
|
||||||
@@ -17,7 +17,18 @@ import org.json.JSONObject
|
|||||||
const val CONNECT_TIMEOUT_MS = 5000
|
const val CONNECT_TIMEOUT_MS = 5000
|
||||||
private const val READ_TIMEOUT_MS = 5000
|
private const val READ_TIMEOUT_MS = 5000
|
||||||
|
|
||||||
class ApiException(message: String, cause: Throwable? = null) : Exception(message, cause)
|
/**
|
||||||
|
* A request that did not produce what it asked for, carrying the server's own wording where it sent
|
||||||
|
* some -- those messages are written to be read on the screen that made the call.
|
||||||
|
*
|
||||||
|
* [status] is the HTTP status where there was a response at all, and null where the server was
|
||||||
|
* never reached. Callers that need it need it because the *same* failure is two different things to
|
||||||
|
* do: a 409 from a write is "somebody else changed this, here are three ways out", where every
|
||||||
|
* other refusal is a message to show. Nothing should branch on it to decide what to *say* -- the
|
||||||
|
* message is what says that.
|
||||||
|
*/
|
||||||
|
class ApiException(message: String, val status: Int? = null, cause: Throwable? = null) :
|
||||||
|
Exception(message, cause)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Runs one request against the backend, with the pinned TLS setup, the bearer token, and the
|
* Runs one request against the backend, with the pinned TLS setup, the bearer token, and the
|
||||||
@@ -68,7 +79,8 @@ fun <T> requestFromServer(
|
|||||||
detail.isNullOrEmpty() ->
|
detail.isNullOrEmpty() ->
|
||||||
"Server returned HTTP ${connection.responseCode} for $path"
|
"Server returned HTTP ${connection.responseCode} for $path"
|
||||||
else -> detail
|
else -> detail
|
||||||
}
|
},
|
||||||
|
status = connection.responseCode,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return readBody(connection)
|
return readBody(connection)
|
||||||
@@ -82,13 +94,13 @@ fun <T> requestFromServer(
|
|||||||
"Couldn't reach the server at ${settings.baseUrl} " +
|
"Couldn't reach the server at ${settings.baseUrl} " +
|
||||||
"(${e::class.simpleName}: ${e.message}) -- is ai-server running, and is " +
|
"(${e::class.simpleName}: ${e.message}) -- is ai-server running, and is " +
|
||||||
"this device able to reach that address (WireGuard up)?",
|
"this device able to reach that address (WireGuard up)?",
|
||||||
e,
|
cause = e,
|
||||||
)
|
)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
throw ApiException(
|
throw ApiException(
|
||||||
"Reached ${settings.baseUrl}$path but couldn't read its response " +
|
"Reached ${settings.baseUrl}$path but couldn't read its response " +
|
||||||
"(${e::class.simpleName}: ${e.message})",
|
"(${e::class.simpleName}: ${e.message})",
|
||||||
e,
|
cause = e,
|
||||||
)
|
)
|
||||||
} finally {
|
} finally {
|
||||||
connection.disconnect()
|
connection.disconnect()
|
||||||
@@ -589,6 +601,175 @@ fun uploadAttachment(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One entry of a directory on the machine a setup names.
|
||||||
|
*
|
||||||
|
* [kind] is the *target's* where the entry is a symlink, so a link to a directory descends; [link]
|
||||||
|
* still says it is one. Neither is worked out here -- the machine answers both, because it is the
|
||||||
|
* only thing that can.
|
||||||
|
*/
|
||||||
|
data class DirEntry(
|
||||||
|
val name: String,
|
||||||
|
val kind: String,
|
||||||
|
val size: Long,
|
||||||
|
val modified: Long,
|
||||||
|
val link: Boolean,
|
||||||
|
) {
|
||||||
|
val isDirectory: Boolean
|
||||||
|
get() = kind == "directory"
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A directory's entries, and the path the machine resolved the request to. */
|
||||||
|
data class Listing(val path: String, val entries: List<DirEntry>)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What reading a file produced.
|
||||||
|
*
|
||||||
|
* Four cases, because they are four different things to draw and none of them is an error the
|
||||||
|
* screen can shrug off: content, something that is not text, something too big to have sent, and
|
||||||
|
* (as [ApiException], not a case here) the machine's own refusal. A file with nothing in it is
|
||||||
|
* [FileContent.Text] with an empty string -- which is what it is, and not the same as any of these.
|
||||||
|
*/
|
||||||
|
sealed class FileContent {
|
||||||
|
abstract val path: String
|
||||||
|
abstract val size: Long
|
||||||
|
abstract val modified: Long
|
||||||
|
|
||||||
|
data class Text(
|
||||||
|
override val path: String,
|
||||||
|
override val size: Long,
|
||||||
|
override val modified: Long,
|
||||||
|
/** What a write is given back, to prove the file is still the one that was read. */
|
||||||
|
val sha256: String,
|
||||||
|
val content: String,
|
||||||
|
) : FileContent()
|
||||||
|
|
||||||
|
data class Binary(
|
||||||
|
override val path: String,
|
||||||
|
override val size: Long,
|
||||||
|
override val modified: Long,
|
||||||
|
) : FileContent()
|
||||||
|
|
||||||
|
data class TooBig(
|
||||||
|
override val path: String,
|
||||||
|
override val size: Long,
|
||||||
|
override val modified: Long,
|
||||||
|
) : FileContent()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What a file is after a write, so the editor's precondition is fresh without a second read. */
|
||||||
|
data class Written(val size: Long, val modified: Long, val sha256: String)
|
||||||
|
|
||||||
|
/** Everything in [path] on the machine [setup] names, and what [path] resolved to. */
|
||||||
|
fun fetchDir(settings: ServerSettings, setup: String, path: String): Listing =
|
||||||
|
requestFromServer(
|
||||||
|
settings,
|
||||||
|
"/setups/${setup.urlEncoded()}/dir?path=${path.urlEncoded()}",
|
||||||
|
readTimeoutMs = 30000,
|
||||||
|
) { connection ->
|
||||||
|
val body = connection.jsonObject()
|
||||||
|
Listing(
|
||||||
|
path = body.getString("path"),
|
||||||
|
entries =
|
||||||
|
body.getJSONArray("entries").mapObjects { entry ->
|
||||||
|
DirEntry(
|
||||||
|
name = entry.getString("name"),
|
||||||
|
kind = entry.getString("kind"),
|
||||||
|
size = entry.optLong("size"),
|
||||||
|
modified = entry.optLong("modified"),
|
||||||
|
link = entry.optBoolean("link", false),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One file's content, or which of the reasons there is none to show. */
|
||||||
|
fun fetchFile(settings: ServerSettings, setup: String, path: String): FileContent =
|
||||||
|
requestFromServer(
|
||||||
|
settings,
|
||||||
|
"/setups/${setup.urlEncoded()}/file?path=${path.urlEncoded()}",
|
||||||
|
// A megabyte over the tunnel, and a `stat` plus a `sha256sum` on the far machine before
|
||||||
|
// any of it moves. Well clear of that rather than just above it -- a timeout is for a
|
||||||
|
// server that has stopped answering.
|
||||||
|
readTimeoutMs = 60000,
|
||||||
|
) { connection ->
|
||||||
|
val body = connection.jsonObject()
|
||||||
|
val at = body.getString("path")
|
||||||
|
val size = body.optLong("size")
|
||||||
|
val modified = body.optLong("modified")
|
||||||
|
when (val kind = body.getString("kind")) {
|
||||||
|
"text" ->
|
||||||
|
FileContent.Text(
|
||||||
|
at,
|
||||||
|
size,
|
||||||
|
modified,
|
||||||
|
body.getString("sha256"),
|
||||||
|
body.getString("content"),
|
||||||
|
)
|
||||||
|
"binary" -> FileContent.Binary(at, size, modified)
|
||||||
|
"tooBig" -> FileContent.TooBig(at, size, modified)
|
||||||
|
// A backend that has learned a fifth answer. Reported rather than guessed at: picking
|
||||||
|
// the nearest of the four would draw something confident about a state this app has
|
||||||
|
// never seen.
|
||||||
|
else ->
|
||||||
|
throw ApiException(
|
||||||
|
"The server described this file as \"$kind\", which this app does not know how to show."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replaces a file's contents, but only while it still hashes to [ifSha256].
|
||||||
|
*
|
||||||
|
* The refusal is a 409 and arrives as an [ApiException] carrying the server's wording, which is
|
||||||
|
* what the conflict dialog shows -- an agent editing the same file while somebody reads it is the
|
||||||
|
* ordinary case here, not the exotic one.
|
||||||
|
*/
|
||||||
|
fun writeFile(
|
||||||
|
settings: ServerSettings,
|
||||||
|
setup: String,
|
||||||
|
path: String,
|
||||||
|
content: String,
|
||||||
|
ifSha256: String,
|
||||||
|
): Written =
|
||||||
|
requestFromServer(
|
||||||
|
settings,
|
||||||
|
"/setups/${setup.urlEncoded()}/file",
|
||||||
|
method = "PUT",
|
||||||
|
jsonBody =
|
||||||
|
JSONObject()
|
||||||
|
.put("path", path)
|
||||||
|
.put("content", content)
|
||||||
|
.put("ifSha256", ifSha256)
|
||||||
|
.toString(),
|
||||||
|
readTimeoutMs = 60000,
|
||||||
|
) { connection ->
|
||||||
|
val body = connection.jsonObject()
|
||||||
|
Written(body.optLong("size"), body.optLong("modified"), body.getString("sha256"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Creates an empty file. Refused, with the machine's own words, if the name is already taken. */
|
||||||
|
fun createFile(settings: ServerSettings, setup: String, path: String) {
|
||||||
|
requestFromServer(
|
||||||
|
settings,
|
||||||
|
"/setups/${setup.urlEncoded()}/file",
|
||||||
|
method = "POST",
|
||||||
|
jsonBody = JSONObject().put("path", path).toString(),
|
||||||
|
readTimeoutMs = 30000,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Creates a directory, with the same refusal as [createFile]. */
|
||||||
|
fun createDir(settings: ServerSettings, setup: String, path: String) {
|
||||||
|
requestFromServer(
|
||||||
|
settings,
|
||||||
|
"/setups/${setup.urlEncoded()}/dir",
|
||||||
|
method = "POST",
|
||||||
|
jsonBody = JSONObject().put("path", path).toString(),
|
||||||
|
readTimeoutMs = 30000,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
|
||||||
/** Fetches an image the transcript references (produced or uploaded). */
|
/** Fetches an image the transcript references (produced or uploaded). */
|
||||||
fun fetchSessionFile(settings: ServerSettings, sessionId: String, name: String): ByteArray =
|
fun fetchSessionFile(settings: ServerSettings, sessionId: String, name: String): ByteArray =
|
||||||
requestFromServer(settings, "/sessions/$sessionId/files/$name", readTimeoutMs = 30000) {
|
requestFromServer(settings, "/sessions/$sessionId/files/$name", readTimeoutMs = 30000) {
|
||||||
|
|||||||
@@ -38,7 +38,17 @@ import kotlinx.coroutines.withContext
|
|||||||
private sealed class Screen {
|
private sealed class Screen {
|
||||||
data object Main : Screen()
|
data object Main : Screen()
|
||||||
|
|
||||||
data class Session(val summary: SessionSummary) : Screen()
|
/**
|
||||||
|
* One session, with the file explorer over it when [files] is set.
|
||||||
|
*
|
||||||
|
* The explorer is a layer on this screen rather than a screen of its own, so the session under
|
||||||
|
* it stays composed: its event stream keeps flowing, its scroll position and draft stay put,
|
||||||
|
* and coming back from a file costs nothing. As a sibling `Screen` it would be disposed and
|
||||||
|
* re-created on every return, refetching the transcript over the tunnel -- which is exactly the
|
||||||
|
* flip between "what did it change" and "what is it saying" that this feature exists for. The
|
||||||
|
* image viewer already made the same choice for the same reason.
|
||||||
|
*/
|
||||||
|
data class Session(val summary: SessionSummary, val files: FilesTarget? = null) : Screen()
|
||||||
|
|
||||||
data object Spawn : Screen()
|
data object Spawn : Screen()
|
||||||
|
|
||||||
@@ -198,14 +208,28 @@ fun AppRoot(
|
|||||||
// row key. Only reachable since a notification can move straight from one session to
|
// row key. Only reachable since a notification can move straight from one session to
|
||||||
// another; every other way here passes through [Screen.Main], which disposes it anyway.
|
// another; every other way here passes through [Screen.Main], which disposes it anyway.
|
||||||
key(here.summary.id) {
|
key(here.summary.id) {
|
||||||
// No imePadding here, for the reason above.
|
// A Box so the explorer can be drawn *over* the session rather than instead of
|
||||||
SessionScreen(
|
// it; the session stays composed underneath. No imePadding here, for the reason
|
||||||
settings = current,
|
// above -- the explorer adds its own, since it has a text field.
|
||||||
summary = here.summary,
|
Box {
|
||||||
onBack = goToMain,
|
SessionScreen(
|
||||||
share = share,
|
settings = current,
|
||||||
onShareTaken = { share = null },
|
summary = here.summary,
|
||||||
)
|
onBack = goToMain,
|
||||||
|
onFiles = { screen = here.copy(files = it) },
|
||||||
|
share = share,
|
||||||
|
onShareTaken = { share = null },
|
||||||
|
)
|
||||||
|
// Its own back handler is registered after this screen's, so it is the one the
|
||||||
|
// platform asks first, and it steps back inside itself before closing.
|
||||||
|
here.files?.let { target ->
|
||||||
|
FilesScreen(
|
||||||
|
settings = current,
|
||||||
|
target = target,
|
||||||
|
onClose = { screen = here.copy(files = null) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
is Screen.Spawn ->
|
is Screen.Spawn ->
|
||||||
Box(Modifier.imePadding()) {
|
Box(Modifier.imePadding()) {
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ suspend fun uploadPicked(
|
|||||||
} catch (e: java.io.IOException) {
|
} catch (e: java.io.IOException) {
|
||||||
// Either side of the copy can fail; the message names the file, which is the
|
// Either side of the copy can fail; the message names the file, which is the
|
||||||
// part the reader can do something about.
|
// part the reader can do something about.
|
||||||
throw ApiException("couldn't send $name: ${e.message}", e)
|
throw ApiException("couldn't send $name: ${e.message}", cause = e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -137,6 +137,25 @@ private fun CodeBlockText(
|
|||||||
fun fenceLanguage(name: String?): Language? =
|
fun fenceLanguage(name: String?): Language? =
|
||||||
FENCE_LANGUAGES[name?.trim()?.lowercase() ?: return null]
|
FENCE_LANGUAGES[name?.trim()?.lowercase() ?: return null]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The highlighter's language for a *file*, from its name.
|
||||||
|
*
|
||||||
|
* The same table [fenceLanguage] reads, deliberately: it already keys on the extensions people
|
||||||
|
* write after the backticks -- `kt`, `rs`, `py` -- because the extension is as often what gets
|
||||||
|
* written there as the language's name. One table rather than two, so a language added for fences
|
||||||
|
* is a language added for files and neither can be the one somebody forgot.
|
||||||
|
*
|
||||||
|
* The extension is the part after the *last* dot, which is what makes `build.gradle.kts` Kotlin and
|
||||||
|
* `Cargo.toml` TOML. A leading dot is not one: `.bashrc` has no extension, it has a name that
|
||||||
|
* starts with a dot, and reading `bashrc` as an extension would look up a word no table has. A name
|
||||||
|
* with no dot at all -- `Makefile`, `LICENSE` -- is likewise null, and null is drawn plain.
|
||||||
|
*/
|
||||||
|
fun fileLanguage(name: String): Language? {
|
||||||
|
val dot = name.lastIndexOf('.')
|
||||||
|
if (dot < 1) return null
|
||||||
|
return fenceLanguage(name.substring(dot + 1))
|
||||||
|
}
|
||||||
|
|
||||||
private val FENCE_LANGUAGES: Map<String, Language> =
|
private val FENCE_LANGUAGES: Map<String, Language> =
|
||||||
mapOf(
|
mapOf(
|
||||||
"kotlin" to Language.KOTLIN,
|
"kotlin" to Language.KOTLIN,
|
||||||
@@ -182,6 +201,8 @@ private val FENCE_LANGUAGES: Map<String, Language> =
|
|||||||
"toml" to Language.TOML,
|
"toml" to Language.TOML,
|
||||||
"fish" to Language.FISH,
|
"fish" to Language.FISH,
|
||||||
"json" to Language.JSON,
|
"json" to Language.JSON,
|
||||||
|
"markdown" to Language.MARKDOWN,
|
||||||
|
"md" to Language.MARKDOWN,
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,167 @@
|
|||||||
|
package com.example.aiapp
|
||||||
|
|
||||||
|
import androidx.compose.foundation.horizontalScroll
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.text.BasicTextField
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.SolidColor
|
||||||
|
import androidx.compose.ui.text.input.OffsetMapping
|
||||||
|
import androidx.compose.ui.text.input.TextFieldValue
|
||||||
|
import androidx.compose.ui.text.input.TransformedText
|
||||||
|
import androidx.compose.ui.text.input.VisualTransformation
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The largest file this app will open in the editor, in bytes.
|
||||||
|
*
|
||||||
|
* Measured on the emulator on 2026-09-04, in a debug build, on generated Rust:
|
||||||
|
*
|
||||||
|
* | file | lines | scan per keystroke | worst frame record | typing |
|
||||||
|
* |--------|--------|--------------------|--------------------|-------------------|
|
||||||
|
* | 32 kB | 917 | 10ms | 183ms | sluggish, correct |
|
||||||
|
* | 128 kB | 3,633 | 40ms | 2,027ms | characters lost |
|
||||||
|
* | 1 MB | 28,660 | -- | -- | stops responding |
|
||||||
|
*
|
||||||
|
* The number that decides this is the **frame record**, not the scan: highlighting a 128 kB file
|
||||||
|
* costs 40ms a keystroke, which is noticeable and survivable, while laying the same text out in one
|
||||||
|
* `BasicTextField` costs two seconds. So switching highlighting off above a size -- which is what
|
||||||
|
* EXPLORER.md expected to have to decide -- would not have saved it; the cost is Compose laying out
|
||||||
|
* one enormous text, and every arrangement of a single text field pays it. A line-by-line editor is
|
||||||
|
* the way past this and is a good deal more than this feature needed.
|
||||||
|
*
|
||||||
|
* 32 kB rather than something between it and 128 kB, because 32 kB is the largest size that was
|
||||||
|
* actually measured as usable. The viewer's own limit stays the server's `FILE_LIMIT` of 1 MiB:
|
||||||
|
* reading a big file is fine, and it is only editing one that is not.
|
||||||
|
*/
|
||||||
|
const val EDIT_LIMIT = 32L * 1024
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The same file, editable, in the same face and colours it was being read in.
|
||||||
|
*
|
||||||
|
* `BasicTextField(TextFieldValue)` with a [VisualTransformation] is the one Compose arrangement
|
||||||
|
* that colours a field's own text rather than replacing the field with something that only looks
|
||||||
|
* like one: the transformation returns the text unchanged and the scanner's spans as styles, so
|
||||||
|
* [OffsetMapping.Identity] is correct by construction -- no character moves, so no offset does. The
|
||||||
|
* newer `TextFieldState` API has no hook for styles at all, which is why this is the older one.
|
||||||
|
*
|
||||||
|
* The cost is that the whole file is re-scanned on every keystroke. For a file under the server's
|
||||||
|
* limit that is expected to be a few milliseconds; see EXPLORER.md's "Numbers to measure", which is
|
||||||
|
* where a size below which highlighting is switched off would be decided if it turns out to be
|
||||||
|
* needed.
|
||||||
|
*
|
||||||
|
* The gutter is one `Text` of `1\n2\n…` beside the field rather than a number per row, because
|
||||||
|
* there are no rows here -- the field is one text object. It stays put while the text scrolls
|
||||||
|
* sideways, and it lines up for the same reason the viewer's does: nothing wraps, so a logical line
|
||||||
|
* is a visual line.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun FileEditor(
|
||||||
|
value: TextFieldValue,
|
||||||
|
onValueChange: (TextFieldValue) -> Unit,
|
||||||
|
language: Language?,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val style = codeStyle().copy(color = MaterialTheme.colorScheme.onSurface)
|
||||||
|
val scroll = rememberScrollState()
|
||||||
|
val count = value.text.removeSuffix("\n").count { it == '\n' } + 1
|
||||||
|
val gutter = gutterWidth(count, style)
|
||||||
|
val numbers = remember(count) { (1..count).joinToString("\n") }
|
||||||
|
val transformation =
|
||||||
|
remember(language) {
|
||||||
|
VisualTransformation { text ->
|
||||||
|
TransformedText(highlight(text.text, language), OffsetMapping.Identity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Row(verticalAlignment = Alignment.Top, modifier = modifier.fillMaxWidth()) {
|
||||||
|
Text(
|
||||||
|
numbers,
|
||||||
|
style = style,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
textAlign = TextAlign.End,
|
||||||
|
softWrap = false,
|
||||||
|
modifier = Modifier.width(gutter),
|
||||||
|
)
|
||||||
|
// The same gap the viewer puts between its numbers and its code, so switching between
|
||||||
|
// reading and editing does not move the text sideways under the reader.
|
||||||
|
Spacer(Modifier.width(GUTTER_GAP))
|
||||||
|
Box(Modifier.horizontalScroll(scroll)) {
|
||||||
|
BasicTextField(
|
||||||
|
value = value,
|
||||||
|
onValueChange = onValueChange,
|
||||||
|
textStyle = style,
|
||||||
|
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
|
||||||
|
visualTransformation = transformation,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What to do about a file that changed on the machine while it was open here.
|
||||||
|
*
|
||||||
|
* Three ways out rather than one, and each says what it costs, because there is no answer this app
|
||||||
|
* can pick on somebody's behalf: an agent editing the same file is the ordinary case here, and both
|
||||||
|
* versions are somebody's work.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun ConflictDialog(
|
||||||
|
message: String,
|
||||||
|
busy: Boolean,
|
||||||
|
onOverwrite: () -> Unit,
|
||||||
|
onReload: () -> Unit,
|
||||||
|
onCancel: () -> Unit,
|
||||||
|
) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onCancel,
|
||||||
|
// The server's own sentence as the title, rather than a heading of this app's above it
|
||||||
|
// saying the same thing twice: there is one statement of what happened and it comes from
|
||||||
|
// the side that found out.
|
||||||
|
title = { Text(message.replaceFirstChar { it.uppercase() }) },
|
||||||
|
text = {
|
||||||
|
Text(
|
||||||
|
"Overwrite keeps what you typed and loses the other change. " +
|
||||||
|
"Reload keeps the other change and loses what you typed. " +
|
||||||
|
"Cancel leaves both alone and keeps you here."
|
||||||
|
)
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = onOverwrite, enabled = !busy) {
|
||||||
|
Text(if (busy) "Saving..." else "Overwrite")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
Row {
|
||||||
|
TextButton(onClick = onReload, enabled = !busy) { Text("Reload") }
|
||||||
|
TextButton(onClick = onCancel, enabled = !busy) { Text("Cancel") }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Leaving an editor with edits in it, which is the one way to lose them by accident. */
|
||||||
|
@Composable
|
||||||
|
fun UnsavedDialog(onDiscard: () -> Unit, onCancel: () -> Unit) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onCancel,
|
||||||
|
title = { Text("Leave without saving?") },
|
||||||
|
text = {
|
||||||
|
Text(
|
||||||
|
"The edits you have made here will be lost. They have not been written to the machine."
|
||||||
|
)
|
||||||
|
},
|
||||||
|
confirmButton = { TextButton(onClick = onDiscard) { Text("Discard") } },
|
||||||
|
dismissButton = { TextButton(onClick = onCancel) { Text("Keep editing") } },
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
package com.example.aiapp
|
||||||
|
|
||||||
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
|
import androidx.compose.ui.text.SpanStyle
|
||||||
|
import androidx.compose.ui.text.buildAnnotatedString
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A file split into lines, with the highlighter's colours already worked out for each one.
|
||||||
|
*
|
||||||
|
* The pure half of the viewer, so it has a JVM unit test and so [of] can run off the main thread:
|
||||||
|
* scanning a megabyte is work, and doing it inside a composable would do it on the drawing thread
|
||||||
|
* and again on every recomposition.
|
||||||
|
*
|
||||||
|
* Why per line at all: the viewer is a `LazyColumn` of lines rather than one `Text`, because text
|
||||||
|
* layout is linear in the text and a twenty-thousand-line file in one `Text` measures all of it to
|
||||||
|
* draw a screenful. That means each row needs *its* colours, and the scanner answers in offsets
|
||||||
|
* into the whole file -- so the spans are bucketed here, once, in one pass over an already-ordered
|
||||||
|
* list, rather than each row searching the whole list for the part that is its.
|
||||||
|
*/
|
||||||
|
class FileLines
|
||||||
|
private constructor(
|
||||||
|
/** The text of each line, without its newline. */
|
||||||
|
val lines: List<String>,
|
||||||
|
/** Per line, the spans that fall in it, with offsets relative to that line's start. */
|
||||||
|
private val spans: List<List<Span>>,
|
||||||
|
/**
|
||||||
|
* The longest line, in character columns -- what the viewer sizes every row to.
|
||||||
|
*
|
||||||
|
* Every row has to be the *same* width or they scroll sideways by different amounts; see
|
||||||
|
* [FileViewer]. Columns rather than measured pixels because the face is monospace, so one
|
||||||
|
* number and one character's advance give the width of the widest line without measuring twenty
|
||||||
|
* thousand strings.
|
||||||
|
*/
|
||||||
|
val columns: Int,
|
||||||
|
) {
|
||||||
|
val size: Int
|
||||||
|
get() = lines.size
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One line, coloured.
|
||||||
|
*
|
||||||
|
* Built when the row is composed rather than up front: a file has far more lines than a screen
|
||||||
|
* shows, and an `AnnotatedString` per line for all of them is the cost the lazy list exists to
|
||||||
|
* avoid.
|
||||||
|
*/
|
||||||
|
fun line(index: Int): AnnotatedString {
|
||||||
|
val text = lines[index]
|
||||||
|
val here = spans[index]
|
||||||
|
if (here.isEmpty()) return AnnotatedString(text)
|
||||||
|
val palette = catppuccinSyntax()
|
||||||
|
return buildAnnotatedString {
|
||||||
|
append(text)
|
||||||
|
here.forEach { addStyle(SpanStyle(color = palette.of(it.kind)), it.start, it.end) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/**
|
||||||
|
* [text] scanned as [language] and cut into lines.
|
||||||
|
*
|
||||||
|
* Exactly one trailing newline is dropped before splitting, so a file that ends the way
|
||||||
|
* text files are supposed to end has the number of lines its author would count -- `wc -l`
|
||||||
|
* agrees, and so does every editor. Without that, every well-formed file gained a phantom
|
||||||
|
* empty last line, which is a wrong line number on every file in the repository. An empty
|
||||||
|
* file is one empty line numbered 1, which is what it is: a file with nothing in it still
|
||||||
|
* has somewhere for a cursor to go.
|
||||||
|
*/
|
||||||
|
fun of(text: String, language: Language?): FileLines =
|
||||||
|
// Timed, and always, for the same reason everything else here is: the cost of opening
|
||||||
|
// a large file is the number that decides whether the server's size limit is right,
|
||||||
|
// and an instrument that is only in the build nobody is running answers nothing. It
|
||||||
|
// lands in the render report beside the transcript's own figures.
|
||||||
|
DebugStats.timed("file scanned and cut into lines") {
|
||||||
|
val body = text.removeSuffix("\n")
|
||||||
|
val lines = body.split('\n')
|
||||||
|
val scanned = if (language == null) emptyList() else spansOf(body, language)
|
||||||
|
FileLines(lines, bucket(lines, scanned), lines.maxOf(::columnsOf))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How many columns a line occupies.
|
||||||
|
*
|
||||||
|
* A tab counts as eight rather than as one, and deliberately upwards: this decides how far
|
||||||
|
* the viewer can scroll, and over-estimating leaves a little empty space past the longest
|
||||||
|
* line where under-estimating makes the end of that line unreachable. Compose draws a tab
|
||||||
|
* as a single advance, so eight is the generous reading rather than the accurate one.
|
||||||
|
*/
|
||||||
|
private fun columnsOf(line: String): Int {
|
||||||
|
var count = 0
|
||||||
|
for (character in line) count += if (character == '\t') 8 else 1
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The scanner's spans, in file offsets, as spans per line in line offsets.
|
||||||
|
*
|
||||||
|
* One walk down both lists, which is what the scanner's guarantee buys: its spans come out
|
||||||
|
* ordered, non-overlapping and inside the text, so a span can only belong to the line the
|
||||||
|
* walk has reached or to ones after it. A span crossing a line break -- a block comment, a
|
||||||
|
* multi-line string -- is cut at each break and appears in each line it covers, because a
|
||||||
|
* row is drawn on its own and cannot inherit a colour from the row above.
|
||||||
|
*/
|
||||||
|
private fun bucket(lines: List<String>, spans: List<Span>): List<List<Span>> {
|
||||||
|
val out = ArrayList<List<Span>>(lines.size)
|
||||||
|
var lineStart = 0
|
||||||
|
var next = 0
|
||||||
|
for (line in lines) {
|
||||||
|
val lineEnd = lineStart + line.length
|
||||||
|
var here: ArrayList<Span>? = null
|
||||||
|
// Spans that ended before this line begins are behind the walk for good.
|
||||||
|
while (next < spans.size && spans[next].end <= lineStart) next++
|
||||||
|
var at = next
|
||||||
|
while (at < spans.size && spans[at].start < lineEnd) {
|
||||||
|
val span = spans[at]
|
||||||
|
val start = maxOf(span.start, lineStart) - lineStart
|
||||||
|
val end = minOf(span.end, lineEnd) - lineStart
|
||||||
|
if (end > start) {
|
||||||
|
(here ?: ArrayList<Span>().also { here = it }).add(
|
||||||
|
Span(start, end, span.kind)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
at++
|
||||||
|
}
|
||||||
|
out.add(here ?: emptyList())
|
||||||
|
// The newline itself, which is in the text and not in any line.
|
||||||
|
lineStart = lineEnd + 1
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
package com.example.aiapp
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.horizontalScroll
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.LazyListState
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
|
import androidx.compose.foundation.overscroll
|
||||||
|
import androidx.compose.foundation.rememberOverscrollEffect
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clipToBounds
|
||||||
|
import androidx.compose.ui.layout.SubcomposeLayout
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.rememberTextMeasurer
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.Constraints
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
/** The face every verbatim thing in this app is drawn in, and the one the gutter has to match. */
|
||||||
|
@Composable
|
||||||
|
fun codeStyle(): TextStyle =
|
||||||
|
MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [content] scanned off the main thread, then drawn.
|
||||||
|
*
|
||||||
|
* Measured on the emulator on 2026-09-04: [FileLines.of] takes **460ms** on a 1 MiB Rust file
|
||||||
|
* (28,660 lines) and 11ms on 32 kB. Called from a `remember` inside the composition, as it was
|
||||||
|
* first written, that is 460ms of frozen screen at the size the server is willing to send -- long
|
||||||
|
* enough that the accessibility tree cannot be read, which is what "the app has stopped" looks like
|
||||||
|
* from outside. So it runs on [Dispatchers.Default] and the spinner is what the reader sees
|
||||||
|
* meanwhile, in the place the file will appear.
|
||||||
|
*
|
||||||
|
* Keyed on the text and the language, so re-reading the same file does not rescan it and a file
|
||||||
|
* that changed does.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun ScannedFile(content: String, language: Language?, modifier: Modifier = Modifier) {
|
||||||
|
var lines by remember(content, language) { mutableStateOf<FileLines?>(null) }
|
||||||
|
LaunchedEffect(content, language) {
|
||||||
|
lines = withContext(Dispatchers.Default) { FileLines.of(content, language) }
|
||||||
|
}
|
||||||
|
when (val ready = lines) {
|
||||||
|
null -> CircularProgressIndicator(Modifier.padding(8.dp))
|
||||||
|
else -> FileViewer(ready, modifier)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A file, one line per row, coloured by the same scanner that colours a reply's code fences.
|
||||||
|
*
|
||||||
|
* A `LazyColumn` of lines rather than one `Text`, because text layout is linear in the text: a
|
||||||
|
* twenty-thousand-line file in a single `Text` measures all of it to draw a screenful, and the
|
||||||
|
* scroll never recovers. The cost of the choice is that each row needs its own colours, which is
|
||||||
|
* what [FileLines] works out once and off this thread.
|
||||||
|
*
|
||||||
|
* Lines do not wrap. They share one horizontal scroll state, so the whole file moves sideways as a
|
||||||
|
* block and a long line does not silently become three -- which would put the gutter's numbers
|
||||||
|
* against the wrong text, the one thing a numbered listing must never do. Because nothing wraps, a
|
||||||
|
* logical line is one visual line and the two cannot drift.
|
||||||
|
*
|
||||||
|
* **Every row is given the same content width**, and that is what makes the shared scroll state
|
||||||
|
* behave. `Modifier.horizontalScroll` is a node per row, and each one coerces the shared offset
|
||||||
|
* into *its own* range -- `content width - viewport` -- so with rows of their natural widths a
|
||||||
|
* short line's range is zero and it never moves at all while a long one beside it does. Each row
|
||||||
|
* also writes `maxValue` on the shared state as it measures, so how far the file could be dragged
|
||||||
|
* was decided by whichever row happened to measure last and changed as the list scrolled. Both
|
||||||
|
* disappear once every row is [FileLines.columns] wide: one range, one maximum, and the file moves
|
||||||
|
* as the block this comment always claimed it was. Reported by Iris on 2026-09-04 as "it seems to
|
||||||
|
* affect different rows differently", which is exactly what a per-row range looks like.
|
||||||
|
*
|
||||||
|
* The stretch at the ends of the travel is **one** effect for the whole file, rendered on the box
|
||||||
|
* around the list rather than by each row. `horizontalScroll` makes its own per node otherwise, so
|
||||||
|
* only the line under the finger stretched and the rest of the file sat still beside it -- the same
|
||||||
|
* complaint as the offsets above, one layer further out. Handing every row the same effect and
|
||||||
|
* rendering it once is what makes the file bend as the block it scrolls as. Only possible because
|
||||||
|
* every row now has the same range: rows that disagreed about where the end was would disagree
|
||||||
|
* about when to stretch.
|
||||||
|
*
|
||||||
|
* The gutter is **beside** the scrolling box rather than inside its rows, which is what keeps the
|
||||||
|
* numbers out of both effects: they do not travel with the text and they do not bend with it. The
|
||||||
|
* rows leave a spacer where the numbers will go and [LineGutter] draws them there. Its width is
|
||||||
|
* measured from the digit count of the line count in the very style it is drawn in, so a nine-line
|
||||||
|
* file and a twelve-thousand-line file each get exactly what they need and nothing is nudged by
|
||||||
|
* hand.
|
||||||
|
*
|
||||||
|
* Moving them out also takes them out of the [SelectionContainer], so selecting part of a file and
|
||||||
|
* copying it gives the code rather than the code with a number in front of every line.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun FileViewer(lines: FileLines, modifier: Modifier = Modifier) {
|
||||||
|
val style = codeStyle()
|
||||||
|
val scroll = rememberScrollState()
|
||||||
|
val overscroll = rememberOverscrollEffect()
|
||||||
|
val rows = rememberLazyListState()
|
||||||
|
val gutter = gutterWidth(lines.size, style)
|
||||||
|
val content = contentWidth(lines.columns, style)
|
||||||
|
Box(modifier.fillMaxSize()) {
|
||||||
|
// One container around the whole file rather than one per line, so a selection can run
|
||||||
|
// across lines -- the same arrangement the transcript uses.
|
||||||
|
SelectionContainer {
|
||||||
|
// The stretch is drawn here, once, over everything this box holds; the rows below only
|
||||||
|
// feed it. `clipToBounds` because a stretch draws outside the box it came from.
|
||||||
|
Box(Modifier.fillMaxSize().clipToBounds().overscroll(overscroll)) {
|
||||||
|
LazyColumn(state = rows, modifier = Modifier.fillMaxSize()) {
|
||||||
|
items(lines.size) { index ->
|
||||||
|
Row(verticalAlignment = Alignment.Top) {
|
||||||
|
// Where the numbers go, drawn from outside this box.
|
||||||
|
Spacer(Modifier.width(gutter + GUTTER_GAP))
|
||||||
|
Text(
|
||||||
|
lines.line(index),
|
||||||
|
style = style,
|
||||||
|
softWrap = false,
|
||||||
|
// The scroll outside the width: the scrolling node's viewport is
|
||||||
|
// what the row has room for, and its content is the whole file's
|
||||||
|
// widest line. The shared effect is given to every row and
|
||||||
|
// rendered by none of them -- see the box above.
|
||||||
|
modifier =
|
||||||
|
Modifier.horizontalScroll(scroll, overscroll).width(content),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LineGutter(rows, gutter, style)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The line numbers, drawn beside the file rather than in it.
|
||||||
|
*
|
||||||
|
* They have to be outside the box the stretch is rendered on, or they bend with the text; and they
|
||||||
|
* have to stay exactly level with the lines they number, which is the one thing a numbered listing
|
||||||
|
* may never get wrong. Those two pull in opposite directions -- out of the list, but pinned to it.
|
||||||
|
*
|
||||||
|
* A [SubcomposeLayout] is what settles it. *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 this is not composing from a value it read a frame ago, it is composing from the
|
||||||
|
* answer the list has just produced. A `Column` translated by the scroll position could not do
|
||||||
|
* that: the translation would be a layout read and current while the set of numbers would be a
|
||||||
|
* composition behind it, so during a fling the numbers would slide against their lines.
|
||||||
|
*
|
||||||
|
* The list is measured before this is -- they are siblings in a `Box` and it is declared first --
|
||||||
|
* and a scroll that remeasures the list on its own does so synchronously, ahead of the layout pass,
|
||||||
|
* which is the same reason a lazy list does not lag its own content.
|
||||||
|
*
|
||||||
|
* `onSurfaceVariant`, because a number is not part of the file: it is this app numbering it, and
|
||||||
|
* the text's own colour would put it in the same voice as the code. The background is painted
|
||||||
|
* because the stretch can carry the text sideways under this column, and a digit with a smear of
|
||||||
|
* code behind it reads as a rendering fault.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun LineGutter(rows: LazyListState, width: Dp, style: TextStyle) {
|
||||||
|
val colour = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
val surface = rawSurface
|
||||||
|
SubcomposeLayout(Modifier.fillMaxHeight().width(width).background(surface).clipToBounds()) {
|
||||||
|
constraints ->
|
||||||
|
val visible = rows.layoutInfo.visibleItemsInfo
|
||||||
|
val numbers = visible.map { item ->
|
||||||
|
subcompose(item.index) {
|
||||||
|
Text(
|
||||||
|
(item.index + 1).toString(),
|
||||||
|
style = style,
|
||||||
|
color = colour,
|
||||||
|
textAlign = TextAlign.End,
|
||||||
|
maxLines = 1,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.first()
|
||||||
|
.measure(Constraints.fixedWidth(constraints.maxWidth))
|
||||||
|
}
|
||||||
|
layout(constraints.maxWidth, constraints.maxHeight) {
|
||||||
|
numbers.forEachIndexed { index, number -> number.place(0, visible[index].offset) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How wide the widest line number is, measured rather than guessed.
|
||||||
|
*
|
||||||
|
* `9` repeated, because digits in a monospace face are all one width and the count's own digits
|
||||||
|
* would measure the same -- what matters is how many there are. Measuring in the style the numbers
|
||||||
|
* are drawn in is what makes this survive a font size, a density or a display scale nobody here
|
||||||
|
* chose.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun gutterWidth(lineCount: Int, style: TextStyle): Dp {
|
||||||
|
val measurer = rememberTextMeasurer()
|
||||||
|
val density = LocalDensity.current
|
||||||
|
val digits = maxOf(1, lineCount.toString().length)
|
||||||
|
return remember(digits, style, density) {
|
||||||
|
with(density) {
|
||||||
|
measurer.measure(AnnotatedString("9".repeat(digits)), style).size.width.toDp()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How wide to make every row: the widest line in the file, in this style.
|
||||||
|
*
|
||||||
|
* One character measured rather than the line itself, because the face is monospace -- every
|
||||||
|
* advance is the same -- and measuring the actual widest line of a twenty-thousand-line file is
|
||||||
|
* work for an answer arithmetic already has. Sixty-four of them, divided, so the answer does not
|
||||||
|
* carry a whole character's worth of rounding.
|
||||||
|
*
|
||||||
|
* Capped, because this becomes a fixed width in a layout and Compose cannot represent an arbitrary
|
||||||
|
* one: a minified file is a single line of a hundred thousand characters, and asking to lay that
|
||||||
|
* out as one row is a crash rather than a slow scroll. Past the cap the far end of such a line
|
||||||
|
* cannot be reached, which is the tolerable half of that trade.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun contentWidth(columns: Int, style: TextStyle): Dp {
|
||||||
|
val measurer = rememberTextMeasurer()
|
||||||
|
val density = LocalDensity.current
|
||||||
|
return remember(columns, style, density) {
|
||||||
|
val advance = measurer.measure(AnnotatedString("0".repeat(64)), style).size.width / 64f
|
||||||
|
with(density) { (columns * advance).coerceAtMost(MAX_CONTENT_PX).toDp() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The widest a row may be laid out, in pixels. Well under what `Constraints` can carry, and far
|
||||||
|
* past any line anybody reads.
|
||||||
|
*/
|
||||||
|
private const val MAX_CONTENT_PX = 100_000f
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The space between the numbers and the code.
|
||||||
|
*
|
||||||
|
* A gap, not an alignment: the two are already aligned by the row, and this is only so the digits
|
||||||
|
* and the first character of the line are not touching.
|
||||||
|
*/
|
||||||
|
val GUTTER_GAP = 8.dp
|
||||||
@@ -0,0 +1,692 @@
|
|||||||
|
package com.example.aiapp
|
||||||
|
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.ColumnScope
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.imePadding
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Switch
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateMapOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.input.TextFieldValue
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which machine's files to show, and where to start.
|
||||||
|
*
|
||||||
|
* A **setup**, not a session: a filesystem is a property of a machine, and a session only says
|
||||||
|
* where it was working. That is what makes a second way in -- from the setups tab, say -- one more
|
||||||
|
* caller rather than any new code here.
|
||||||
|
*/
|
||||||
|
data class FilesTarget(val setup: String, val setupName: String, val start: String)
|
||||||
|
|
||||||
|
/** Where the explorer is: in a directory, or in one file. */
|
||||||
|
private sealed class Spot(val path: String) {
|
||||||
|
class Dir(path: String) : Spot(path)
|
||||||
|
|
||||||
|
class Doc(path: String) : Spot(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The files on the machine a session runs on: browse them, read one, change one.
|
||||||
|
*
|
||||||
|
* Drawn **over** the session rather than instead of it (see [AppRoot]), so its event stream keeps
|
||||||
|
* flowing, its draft and scroll position stay where they were, and coming back from a file costs
|
||||||
|
* nothing. Back steps one level inside here -- editor to viewer, viewer to the directory it came
|
||||||
|
* from, directory to the one above it -- and only closes from where it opened.
|
||||||
|
*
|
||||||
|
* Every directory that has been visited is kept for as long as this is open, so stepping back is
|
||||||
|
* instant; the refresh glyph is how a directory gets asked again on purpose, and creating something
|
||||||
|
* refetches the directory it was created in, since that is the one thing that changed.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun FilesScreen(settings: ServerSettings, target: FilesTarget, onClose: () -> Unit) {
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
var stack by remember { mutableStateOf(listOf<Spot>(Spot.Dir(target.start))) }
|
||||||
|
val listings = remember { mutableStateMapOf<String, LoadState<Listing>>() }
|
||||||
|
var creating by remember { mutableStateOf(false) }
|
||||||
|
// Edit mode and whether anything has been typed live here rather than in the pane below,
|
||||||
|
// because they are what back has to know about -- and back arrives from two places, the arrow
|
||||||
|
// and the platform's own gesture, which must mean the same thing.
|
||||||
|
var editing by remember { mutableStateOf(false) }
|
||||||
|
var dirty by remember { mutableStateOf(false) }
|
||||||
|
var askUnsaved by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
val here = stack.last()
|
||||||
|
|
||||||
|
fun go(spot: Spot) {
|
||||||
|
editing = false
|
||||||
|
dirty = false
|
||||||
|
stack = stack + spot
|
||||||
|
}
|
||||||
|
|
||||||
|
fun back() {
|
||||||
|
when {
|
||||||
|
editing && dirty -> askUnsaved = true
|
||||||
|
editing -> editing = false
|
||||||
|
stack.size > 1 -> {
|
||||||
|
stack = stack.dropLast(1)
|
||||||
|
editing = false
|
||||||
|
dirty = false
|
||||||
|
}
|
||||||
|
else -> onClose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun load(path: String, again: Boolean) {
|
||||||
|
if (!again && listings[path] is LoadState.Loaded) return
|
||||||
|
listings[path] = LoadState.Loading
|
||||||
|
listings[path] =
|
||||||
|
try {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
LoadState.Loaded(fetchDir(settings, target.setup, path))
|
||||||
|
}
|
||||||
|
} catch (e: ApiException) {
|
||||||
|
LoadState.failed(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
BackHandler(onBack = ::back)
|
||||||
|
|
||||||
|
Box(
|
||||||
|
Modifier.fillMaxSize()
|
||||||
|
.background(MaterialTheme.colorScheme.background)
|
||||||
|
// The session under this deliberately takes no keyboard inset (see SessionScreen's
|
||||||
|
// layout note), so the explorer adds its own -- otherwise the editor types under the
|
||||||
|
// keyboard.
|
||||||
|
.imePadding()
|
||||||
|
) {
|
||||||
|
Column(Modifier.fillMaxSize()) {
|
||||||
|
when (val spot = here) {
|
||||||
|
is Spot.Dir -> {
|
||||||
|
val state = listings[spot.path] ?: LoadState.Loading
|
||||||
|
// The resolved path once there is one: a directory opened as `~` is called
|
||||||
|
// what it turned out to be, not what it was asked for.
|
||||||
|
val at = (state as? LoadState.Loaded)?.value?.path ?: spot.path
|
||||||
|
FilesHeader(
|
||||||
|
title = baseName(at),
|
||||||
|
path = at,
|
||||||
|
machine = target.setupName,
|
||||||
|
onBack = ::back,
|
||||||
|
) {
|
||||||
|
GlyphButton(
|
||||||
|
REFRESH_GLYPH,
|
||||||
|
"Refresh this directory",
|
||||||
|
{ scope.launch { load(spot.path, again = true) } },
|
||||||
|
enabled = state !is LoadState.Loading,
|
||||||
|
)
|
||||||
|
GlyphButton(
|
||||||
|
PLUS_GLYPH,
|
||||||
|
"Create here",
|
||||||
|
{ creating = true },
|
||||||
|
enabled = state is LoadState.Loaded,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
LaunchedEffect(spot.path) { load(spot.path, again = false) }
|
||||||
|
DirectoryBody(state, onOpen = ::go)
|
||||||
|
}
|
||||||
|
is Spot.Doc ->
|
||||||
|
DocPane(
|
||||||
|
settings = settings,
|
||||||
|
target = target,
|
||||||
|
path = spot.path,
|
||||||
|
name = baseName(spot.path),
|
||||||
|
editing = editing,
|
||||||
|
onEditing = { editing = it },
|
||||||
|
onDirty = { dirty = it },
|
||||||
|
onBack = ::back,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (askUnsaved) {
|
||||||
|
UnsavedDialog(
|
||||||
|
onDiscard = {
|
||||||
|
askUnsaved = false
|
||||||
|
editing = false
|
||||||
|
dirty = false
|
||||||
|
},
|
||||||
|
onCancel = { askUnsaved = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val dir = here as? Spot.Dir
|
||||||
|
val listing = (listings[dir?.path] as? LoadState.Loaded)?.value
|
||||||
|
if (creating && dir != null && listing != null) {
|
||||||
|
CreateDialog(
|
||||||
|
settings = settings,
|
||||||
|
setup = target.setup,
|
||||||
|
directory = listing.path,
|
||||||
|
onDismiss = { creating = false },
|
||||||
|
onCreated = { path, isDirectory ->
|
||||||
|
creating = false
|
||||||
|
scope.launch {
|
||||||
|
// The directory it was created in is the one thing that changed, so that is
|
||||||
|
// what gets asked again -- not the whole stack.
|
||||||
|
load(dir.path, again = true)
|
||||||
|
// A new file has nothing to look at, so it opens where it can be filled in.
|
||||||
|
if (!isDirectory) {
|
||||||
|
go(Spot.Doc(path))
|
||||||
|
editing = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The row every view in here has at the top: back, what this is, and what acts on it.
|
||||||
|
*
|
||||||
|
* The path is truncated in the middle when it will not fit, because both ends carry something the
|
||||||
|
* reader needs -- the machine and the top of the tree at one end, the file at the other -- and it
|
||||||
|
* is the longest paths, the ones being read most closely, that get cut.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun FilesHeader(
|
||||||
|
title: String,
|
||||||
|
path: String,
|
||||||
|
machine: String,
|
||||||
|
onBack: () -> Unit,
|
||||||
|
actions: @Composable () -> Unit,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
|
||||||
|
) {
|
||||||
|
GlyphButton(BACK_GLYPH, "Back", onBack)
|
||||||
|
Spacer(Modifier.width(GLYPH_BUTTON_MARGIN))
|
||||||
|
Column(Modifier.weight(1f)) {
|
||||||
|
Text(title, style = MaterialTheme.typography.titleMedium, maxLines = 1)
|
||||||
|
Text(
|
||||||
|
"$machine · $path",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.MiddleEllipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Row { actions() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What is in a directory.
|
||||||
|
*
|
||||||
|
* A listing that failed says why, in the machine's own words, where the rows would be -- never an
|
||||||
|
* empty list, which is what "there is nothing here" looks like and is the one wrong answer that
|
||||||
|
* looks like a right one.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun ColumnScope.DirectoryBody(state: LoadState<Listing>, onOpen: (Spot) -> Unit) {
|
||||||
|
when (state) {
|
||||||
|
is LoadState.Loading -> CircularProgressIndicator(Modifier.padding(16.dp))
|
||||||
|
is LoadState.Error ->
|
||||||
|
Text(
|
||||||
|
state.message,
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
)
|
||||||
|
is LoadState.Loaded -> {
|
||||||
|
val listing = state.value
|
||||||
|
val sorted = remember(listing) { sortForDisplay(listing.entries) }
|
||||||
|
LazyColumn(Modifier.weight(1f).fillMaxWidth()) {
|
||||||
|
parentOf(listing.path)?.let { parent ->
|
||||||
|
item("..") {
|
||||||
|
EntryRow(
|
||||||
|
glyph = FOLDER_GLYPH,
|
||||||
|
name = "..",
|
||||||
|
trailing = null,
|
||||||
|
onClick = { onOpen(Spot.Dir(parent)) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (sorted.isEmpty()) {
|
||||||
|
item("empty") {
|
||||||
|
Text(
|
||||||
|
"Nothing here",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
uniqueItems(sorted, key = { it.name }) { entry ->
|
||||||
|
val path = join(listing.path, entry.name)
|
||||||
|
EntryRow(
|
||||||
|
glyph = if (entry.isDirectory) FOLDER_GLYPH else FILE_GLYPH,
|
||||||
|
name = entry.name,
|
||||||
|
trailing = trailingOf(entry),
|
||||||
|
onClick = {
|
||||||
|
onOpen(if (entry.isDirectory) Spot.Dir(path) else Spot.Doc(path))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a row says after the name, or nothing.
|
||||||
|
*
|
||||||
|
* A symlink says so instead of giving a size, because the size a listing reports for one is the
|
||||||
|
* length of the path it points at -- a number that looks exactly like a file size and is about
|
||||||
|
* something else entirely. `other` covers a fifo, a device, and a link whose target is gone: the
|
||||||
|
* row still appears, because a directory that hid what it held would be lying about being empty,
|
||||||
|
* and the word is there because a colour cannot say "this is a different kind of thing".
|
||||||
|
*/
|
||||||
|
private fun trailingOf(entry: DirEntry): String? =
|
||||||
|
when {
|
||||||
|
entry.link -> "link"
|
||||||
|
entry.isDirectory -> null
|
||||||
|
entry.kind == "file" -> humanSize(entry.size) ?: "0 B"
|
||||||
|
else -> "other"
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun EntryRow(glyph: String, name: String, trailing: String?, onClick: () -> Unit) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier =
|
||||||
|
Modifier.fillMaxWidth()
|
||||||
|
.clickable(onClick = onClick)
|
||||||
|
.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||||
|
) {
|
||||||
|
Glyph(glyph, colour = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
Spacer(Modifier.width(12.dp))
|
||||||
|
Text(
|
||||||
|
name,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.MiddleEllipsis,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
trailing?.let {
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Text(
|
||||||
|
it,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One file: read, and edited behind the pencil.
|
||||||
|
*
|
||||||
|
* Its own composable so that everything about one file -- what came back, what has been typed, and
|
||||||
|
* whether a save is out -- is remembered under that file's path and thrown away when the reader
|
||||||
|
* moves to another. What is *not* here is edit mode itself: back has to know about it, and back
|
||||||
|
* belongs to the screen.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun ColumnScope.DocPane(
|
||||||
|
settings: ServerSettings,
|
||||||
|
target: FilesTarget,
|
||||||
|
path: String,
|
||||||
|
name: String,
|
||||||
|
editing: Boolean,
|
||||||
|
onEditing: (Boolean) -> Unit,
|
||||||
|
onDirty: (Boolean) -> Unit,
|
||||||
|
onBack: () -> Unit,
|
||||||
|
) {
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
var state by remember(path) { mutableStateOf<LoadState<FileContent>>(LoadState.Loading) }
|
||||||
|
var draft by remember(path) { mutableStateOf(TextFieldValue()) }
|
||||||
|
var saving by remember(path) { mutableStateOf(false) }
|
||||||
|
var saveError by remember(path) { mutableStateOf<String?>(null) }
|
||||||
|
var conflict by remember(path) { mutableStateOf<String?>(null) }
|
||||||
|
// The editor's own vertical scroll, hoisted so the gutter and the text move together: they are
|
||||||
|
// two composables in one row, and a scroll inside either would leave the other behind.
|
||||||
|
val editScroll = rememberScrollState()
|
||||||
|
val language = remember(name) { fileLanguage(name) }
|
||||||
|
val loaded = (state as? LoadState.Loaded)?.value as? FileContent.Text
|
||||||
|
// Readable but not editable: see [EDIT_LIMIT]. The size is the one the machine reported, so
|
||||||
|
// this is decided before anything is typed rather than discovered by a keyboard that stops
|
||||||
|
// answering.
|
||||||
|
val editable = loaded != null && loaded.size <= EDIT_LIMIT
|
||||||
|
|
||||||
|
suspend fun fetch() {
|
||||||
|
state = LoadState.Loading
|
||||||
|
state =
|
||||||
|
try {
|
||||||
|
val got = withContext(Dispatchers.IO) { fetchFile(settings, target.setup, path) }
|
||||||
|
if (got is FileContent.Text) draft = TextFieldValue(got.content)
|
||||||
|
LoadState.Loaded(got)
|
||||||
|
} catch (e: ApiException) {
|
||||||
|
LoadState.failed(e)
|
||||||
|
}
|
||||||
|
onDirty(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(path) { fetch() }
|
||||||
|
|
||||||
|
val changed = loaded != null && draft.text != loaded.content
|
||||||
|
LaunchedEffect(changed) { onDirty(changed) }
|
||||||
|
|
||||||
|
/** Writes the draft back, [against] being the digest it is allowed to replace. */
|
||||||
|
fun save(against: String) {
|
||||||
|
if (saving) return
|
||||||
|
saving = true
|
||||||
|
saveError = null
|
||||||
|
scope.launch {
|
||||||
|
try {
|
||||||
|
val written =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
writeFile(settings, target.setup, path, draft.text, against)
|
||||||
|
}
|
||||||
|
state =
|
||||||
|
LoadState.Loaded(
|
||||||
|
FileContent.Text(
|
||||||
|
path,
|
||||||
|
written.size,
|
||||||
|
written.modified,
|
||||||
|
written.sha256,
|
||||||
|
draft.text,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
conflict = null
|
||||||
|
onDirty(false)
|
||||||
|
onEditing(false)
|
||||||
|
} catch (e: ApiException) {
|
||||||
|
// The one refusal that is a question rather than a message: somebody else's edit
|
||||||
|
// is on the machine, and which of the two survives is not this app's to decide.
|
||||||
|
if (e.status == 409) conflict = e.message ?: "It changed on the machine."
|
||||||
|
else saveError = e.message
|
||||||
|
} finally {
|
||||||
|
saving = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
FilesHeader(title = name, path = path, machine = target.setupName, onBack = onBack) {
|
||||||
|
if (editing) {
|
||||||
|
if (saving) {
|
||||||
|
GlyphSpinner("Saving")
|
||||||
|
} else {
|
||||||
|
GlyphButton(
|
||||||
|
SAVE_GLYPH,
|
||||||
|
"Save",
|
||||||
|
{ loaded?.let { save(it.sha256) } },
|
||||||
|
// Disabled rather than hidden while there is nothing to write: a button that
|
||||||
|
// comes and goes makes its own absence the signal.
|
||||||
|
enabled = changed,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
GlyphButton(
|
||||||
|
REFRESH_GLYPH,
|
||||||
|
"Read this file again",
|
||||||
|
{ scope.launch { fetch() } },
|
||||||
|
enabled = state !is LoadState.Loading,
|
||||||
|
)
|
||||||
|
GlyphButton(EDIT_GLYPH, "Edit", { onEditing(true) }, enabled = editable)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
saveError?.let {
|
||||||
|
Text(
|
||||||
|
it,
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Why the pencil is off. A disabled control teaches what the thing can do, but it cannot say
|
||||||
|
// why it is disabled -- and a reader who cannot edit a file they can plainly read will
|
||||||
|
// otherwise conclude the app is broken. Said once, here, rather than waiting for a tap that a
|
||||||
|
// disabled button never receives.
|
||||||
|
if (loaded != null && !editable) {
|
||||||
|
Text(
|
||||||
|
"Too big to edit here (${humanSize(loaded.size)}; the limit is " +
|
||||||
|
"${humanSize(EDIT_LIMIT)}). A text field this large stops answering the keyboard.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(Modifier.weight(1f).fillMaxWidth().background(rawSurface).padding(horizontal = 8.dp)) {
|
||||||
|
when (val current = state) {
|
||||||
|
is LoadState.Loading -> CircularProgressIndicator(Modifier.padding(8.dp))
|
||||||
|
is LoadState.Error ->
|
||||||
|
Text(
|
||||||
|
current.message,
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
modifier = Modifier.padding(8.dp),
|
||||||
|
)
|
||||||
|
is LoadState.Loaded ->
|
||||||
|
when (val file = current.value) {
|
||||||
|
is FileContent.Text ->
|
||||||
|
if (editing) {
|
||||||
|
FileEditor(
|
||||||
|
draft,
|
||||||
|
{ draft = it },
|
||||||
|
language,
|
||||||
|
Modifier.verticalScroll(editScroll),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
ScannedFile(file.content, language)
|
||||||
|
}
|
||||||
|
// Said in words, with the measurement that makes it make sense. Neither of
|
||||||
|
// these is an empty file and neither is an error, so neither may look like one.
|
||||||
|
is FileContent.Binary ->
|
||||||
|
Note(
|
||||||
|
"This is not text (${humanSize(file.size) ?: "0 B"}), so there is nothing to show."
|
||||||
|
)
|
||||||
|
is FileContent.TooBig ->
|
||||||
|
Note(
|
||||||
|
"This file is ${humanSize(file.size)}, which is more than the server will " +
|
||||||
|
"send. Nothing was read, so nothing here is a sample of it."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
conflict?.let { message ->
|
||||||
|
ConflictDialog(
|
||||||
|
message = message,
|
||||||
|
busy = saving,
|
||||||
|
onOverwrite = {
|
||||||
|
// Re-read only to learn what it hashes to *now*, which is the digest an overwrite
|
||||||
|
// has to be allowed against. The content is deliberately thrown away: overwriting
|
||||||
|
// is the choice to lose it.
|
||||||
|
scope.launch {
|
||||||
|
val fresh =
|
||||||
|
try {
|
||||||
|
withContext(Dispatchers.IO) { fetchFile(settings, target.setup, path) }
|
||||||
|
} catch (e: ApiException) {
|
||||||
|
saveError = e.message
|
||||||
|
conflict = null
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
if (fresh is FileContent.Text) save(fresh.sha256)
|
||||||
|
else {
|
||||||
|
saveError =
|
||||||
|
"It is no longer a text file, so this app will not write over it."
|
||||||
|
conflict = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onReload = {
|
||||||
|
conflict = null
|
||||||
|
scope.launch { fetch() }
|
||||||
|
},
|
||||||
|
onCancel = { conflict = null },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A sentence where the file's content would be, for the two states that have no content. */
|
||||||
|
@Composable
|
||||||
|
private fun Note(text: String) {
|
||||||
|
Text(
|
||||||
|
text,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Naming one thing in the directory that is open.
|
||||||
|
*
|
||||||
|
* A name and a switch, not a name and a body: the editor is where content is typed, and a modal
|
||||||
|
* with a text area in it is a second editor to keep in step with the first. A created file opens
|
||||||
|
* straight into edit mode, because an empty file is not something to look at.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun CreateDialog(
|
||||||
|
settings: ServerSettings,
|
||||||
|
setup: String,
|
||||||
|
directory: String,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onCreated: (String, Boolean) -> Unit,
|
||||||
|
) {
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
var name by remember { mutableStateOf("") }
|
||||||
|
var isDirectory by remember { mutableStateOf(false) }
|
||||||
|
var busy by remember { mutableStateOf(false) }
|
||||||
|
var error by remember { mutableStateOf<String?>(null) }
|
||||||
|
|
||||||
|
fun create() {
|
||||||
|
val chosen = name.trim()
|
||||||
|
if (busy || chosen.isEmpty()) return
|
||||||
|
busy = true
|
||||||
|
error = null
|
||||||
|
val path = join(directory, chosen)
|
||||||
|
scope.launch {
|
||||||
|
try {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
if (isDirectory) createDir(settings, setup, path)
|
||||||
|
else createFile(settings, setup, path)
|
||||||
|
}
|
||||||
|
onCreated(path, isDirectory)
|
||||||
|
} catch (e: ApiException) {
|
||||||
|
// Beside the button that caused it: this dialog is the only thing on screen that
|
||||||
|
// knows something was being created, and the reason is usually the name itself.
|
||||||
|
error = e.message
|
||||||
|
busy = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text("Create in ${baseName(directory)}") },
|
||||||
|
text = {
|
||||||
|
Column {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = name,
|
||||||
|
onValueChange = { name = it },
|
||||||
|
label = { Text("Name") },
|
||||||
|
singleLine = true,
|
||||||
|
enabled = !busy,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text("Directory", modifier = Modifier.weight(1f))
|
||||||
|
Switch(
|
||||||
|
checked = isDirectory,
|
||||||
|
onCheckedChange = { isDirectory = it },
|
||||||
|
enabled = !busy,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
"A name that is already taken is refused rather than replaced.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
error?.let {
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
Text(
|
||||||
|
it,
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = { create() }, enabled = !busy && name.isNotBlank()) {
|
||||||
|
Text(if (busy) "Creating..." else "Create")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = { TextButton(onClick = onDismiss, enabled = !busy) { Text("Cancel") } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Directories first, then by name ignoring case, and stably.
|
||||||
|
*
|
||||||
|
* Sorted here rather than by the machine: presentation order is a display decision, and `find`
|
||||||
|
* answers in whatever order the directory happens to be stored in. Dotfiles are not hidden -- in a
|
||||||
|
* repository they are half of what matters.
|
||||||
|
*/
|
||||||
|
internal fun sortForDisplay(entries: List<DirEntry>): List<DirEntry> =
|
||||||
|
entries.sortedWith(compareBy({ !it.isDirectory }, { it.name.lowercase() }))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The directory above [path], or null at the root.
|
||||||
|
*
|
||||||
|
* A string operation on a path the *machine* resolved, which is what makes it safe: every listing
|
||||||
|
* answers with its own `pwd -P`, so there is never a `..` or a symlink left in here to reason
|
||||||
|
* about, and this app never has to resolve one.
|
||||||
|
*/
|
||||||
|
internal fun parentOf(path: String): String? {
|
||||||
|
val trimmed = path.trimEnd('/')
|
||||||
|
if (trimmed.isEmpty()) return null
|
||||||
|
val cut = trimmed.lastIndexOf('/')
|
||||||
|
return when {
|
||||||
|
cut < 0 -> null
|
||||||
|
cut == 0 -> "/"
|
||||||
|
else -> trimmed.substring(0, cut)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What a path names: its last segment, with `/` naming itself. */
|
||||||
|
internal fun baseName(path: String): String {
|
||||||
|
val trimmed = path.trimEnd('/')
|
||||||
|
return if (trimmed.isEmpty()) "/" else trimmed.substringAfterLast('/')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A resolved directory and a name in it, as one path. */
|
||||||
|
internal fun join(directory: String, name: String): String =
|
||||||
|
if (directory.endsWith("/")) "$directory$name" else "$directory/$name"
|
||||||
@@ -60,7 +60,7 @@ data class SyntaxPalette(
|
|||||||
*/
|
*/
|
||||||
fun highlight(code: String, language: Language?): AnnotatedString {
|
fun highlight(code: String, language: Language?): AnnotatedString {
|
||||||
if (language == null) return AnnotatedString(code)
|
if (language == null) return AnnotatedString(code)
|
||||||
val spans = DebugStats.timed("code highlighted") { scan(code, rulesOf(language)) }
|
val spans = DebugStats.timed("code highlighted") { spansOf(code, language) }
|
||||||
val palette = catppuccinSyntax()
|
val palette = catppuccinSyntax()
|
||||||
return buildAnnotatedString {
|
return buildAnnotatedString {
|
||||||
append(code)
|
append(code)
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
package com.example.aiapp
|
package com.example.aiapp
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A language the highlighter has rules for.
|
* A language the highlighter can colour.
|
||||||
*
|
*
|
||||||
* The names the reader writes after the backticks are aliases onto these; [fenceLanguage] holds
|
* The names the reader writes after the backticks are aliases onto these; [fenceLanguage] holds
|
||||||
* that table. A word with no entry there is null, and null is drawn plain, because a fence coloured
|
* that table. A word with no entry there is null, and null is drawn plain, because a fence coloured
|
||||||
* by another language's rules looks highlighted and is wrong in a way the reader cannot see.
|
* by another language's rules looks highlighted and is wrong in a way the reader cannot see.
|
||||||
|
*
|
||||||
|
* Nearly all of them are a row of [RULES], read by one shared scanner. [MARKDOWN] is the one that
|
||||||
|
* is not; see [spansOf].
|
||||||
*/
|
*/
|
||||||
enum class Language {
|
enum class Language {
|
||||||
C,
|
C,
|
||||||
@@ -19,6 +22,7 @@ enum class Language {
|
|||||||
JAVASCRIPT,
|
JAVASCRIPT,
|
||||||
JSON,
|
JSON,
|
||||||
KOTLIN,
|
KOTLIN,
|
||||||
|
MARKDOWN,
|
||||||
PERL,
|
PERL,
|
||||||
PHP,
|
PHP,
|
||||||
PYTHON,
|
PYTHON,
|
||||||
@@ -83,8 +87,23 @@ enum class Attributes {
|
|||||||
LINE_BRACKET,
|
LINE_BRACKET,
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The rules for [language]. */
|
/**
|
||||||
fun rulesOf(language: Language): Rules = RULES.getValue(language)
|
* The spans [language] colours in [code] -- the one way to ask, whatever the language turns out to
|
||||||
|
* be made of.
|
||||||
|
*
|
||||||
|
* Nearly every language here is tokens: keywords, strings and comments, which is a row of [RULES]
|
||||||
|
* and the one shared scanner in [scan]. Markdown has none of those, and what a character means
|
||||||
|
* there depends on where on the line it sits, so it brings a scanner of its own ([scanMarkdown]).
|
||||||
|
* That is the whole extension point -- a new language is a row of rules or an entry in [SCANNERS],
|
||||||
|
* and no caller learns which one it got.
|
||||||
|
*/
|
||||||
|
fun spansOf(code: String, language: Language): List<Span> = SCANNERS.getValue(language)(code)
|
||||||
|
|
||||||
|
// Lazy for the same reason [RULES] is, since it reads it.
|
||||||
|
private val SCANNERS: Map<Language, (String) -> List<Span>> by lazy {
|
||||||
|
RULES.mapValues { (_, rules) -> { code: String -> scan(code, rules) } } +
|
||||||
|
mapOf(Language.MARKDOWN to ::scanMarkdown)
|
||||||
|
}
|
||||||
|
|
||||||
private val C_STYLE = BlockComment("/*", "*/", nests = false)
|
private val C_STYLE = BlockComment("/*", "*/", nests = false)
|
||||||
private val NESTING = BlockComment("/*", "*/", nests = true)
|
private val NESTING = BlockComment("/*", "*/", nests = true)
|
||||||
|
|||||||
@@ -0,0 +1,463 @@
|
|||||||
|
package com.example.aiapp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Markdown read into the spans that carry a colour -- a ```markdown fence in a reply, and a `.md`
|
||||||
|
* file in the viewer.
|
||||||
|
*
|
||||||
|
* Its own scanner rather than a row of [Rules] because markdown has neither keywords nor strings:
|
||||||
|
* what a character means depends on where it sits. A `#` opens a heading at the start of a line and
|
||||||
|
* is an ordinary character three words in; a `*` opens emphasis only if something closes it on the
|
||||||
|
* same line. The token scanner cannot ask either question, and answering them with its rules is how
|
||||||
|
* a highlighter comes to grey out the second half of a paragraph.
|
||||||
|
*
|
||||||
|
* Structure is read a line at a time and each line's prose is then read left to right, so every
|
||||||
|
* decision is made inside one line -- except the two things that are not one line. A fenced block
|
||||||
|
* is state carried forward, so an unclosed fence colours the rest of the text, which is also what
|
||||||
|
* it looks like while somebody is still writing it. A table is found by its delimiter row
|
||||||
|
* (`|---|---|`), which is the only line of one that cannot be anything else, and its header is the
|
||||||
|
* line before that -- the one place here that looks ahead.
|
||||||
|
*
|
||||||
|
* What is deliberately *not* recognised: an indented code block. Four spaces after a blank line is
|
||||||
|
* one, and four spaces after a bullet is a list item's second paragraph, and the two are told apart
|
||||||
|
* by what came before rather than by the line itself. Colouring the wrong one of those as code is a
|
||||||
|
* mistake the reader cannot see, so both are left plain, which is the safe answer.
|
||||||
|
*
|
||||||
|
* Like [scan], the spans come out ordered, non-overlapping and inside the text by construction:
|
||||||
|
* every one is emitted by a pass that only moves forward, and nothing here throws.
|
||||||
|
*/
|
||||||
|
fun scanMarkdown(code: String): List<Span> = MarkdownScanner(code).run()
|
||||||
|
|
||||||
|
/** The characters an unordered list may be bulleted with. */
|
||||||
|
private const val BULLETS = "-*+"
|
||||||
|
|
||||||
|
/** The characters a thematic break, or a setext heading's underline, can be drawn with. */
|
||||||
|
private const val RULE_MARKERS = "-*_="
|
||||||
|
|
||||||
|
/** The characters that can open emphasis, strong emphasis or a strikethrough. */
|
||||||
|
private const val EMPHASIS = "*_~"
|
||||||
|
|
||||||
|
/** Characters that end a bare URL wherever they appear in it, and ones only trimmed off the end. */
|
||||||
|
private const val URL_STOPS = "<>\"'`|"
|
||||||
|
private const val URL_TRAILING = ".,:;!?"
|
||||||
|
|
||||||
|
private class MarkdownScanner(private val code: String) {
|
||||||
|
private val spans = ArrayList<Span>()
|
||||||
|
|
||||||
|
fun run(): List<Span> {
|
||||||
|
var at = 0
|
||||||
|
// The delimiter run that opened the fenced block we are inside, or null between them.
|
||||||
|
var fence: String? = null
|
||||||
|
// Whether the row above was part of a table, which is what makes this one a body row.
|
||||||
|
var table = false
|
||||||
|
while (at <= code.length) {
|
||||||
|
val end = lineEnd(at)
|
||||||
|
val open = fence
|
||||||
|
if (open != null) {
|
||||||
|
// The content and the closing line alike: a fence is one block of code, and its
|
||||||
|
// own delimiters belong to it the way a string's quotes belong to the string.
|
||||||
|
emit(at, end, Kind.STRING)
|
||||||
|
if (closesFence(at, end, open)) fence = null
|
||||||
|
} else {
|
||||||
|
val opened = opensFence(at, end)
|
||||||
|
fence = opened
|
||||||
|
if (opened != null) table = false else table = row(at, end, table)
|
||||||
|
}
|
||||||
|
if (end == code.length) break
|
||||||
|
at = end + 1
|
||||||
|
}
|
||||||
|
return spans
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The end of the line beginning at [at]: the newline, or the end of the text. */
|
||||||
|
private fun lineEnd(at: Int): Int {
|
||||||
|
val newline = code.indexOf('\n', at)
|
||||||
|
return if (newline < 0) code.length else newline
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One line that is not inside a fence, and whether the table it may be part of is still open.
|
||||||
|
*
|
||||||
|
* A table is recognised by its delimiter row (`|---|---|`), which is the only line of one that
|
||||||
|
* cannot be anything else. That row comes *after* the header it belongs to, so the header is
|
||||||
|
* found by looking one line ahead -- the single piece of lookahead here, and cheaper than the
|
||||||
|
* alternative of colouring every `|` in the document, which would mark the pipes in a shell
|
||||||
|
* command written in a paragraph.
|
||||||
|
*/
|
||||||
|
private fun row(start: Int, end: Int, table: Boolean): Boolean {
|
||||||
|
if (tableDelimiter(start, end)) {
|
||||||
|
emit(indented(start, end), end, Kind.MARK)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
val header = end < code.length && tableDelimiter(end + 1, lineEnd(end + 1))
|
||||||
|
if ((table || header) && hasPipe(start, end)) {
|
||||||
|
tableRow(start, end)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
structure(start, end)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A line of nothing but pipes, dashes, alignment colons and space, with one of each needed. */
|
||||||
|
private fun tableDelimiter(start: Int, end: Int): Boolean {
|
||||||
|
var dashes = false
|
||||||
|
var pipes = false
|
||||||
|
for (at in indented(start, end) until end) {
|
||||||
|
when (code[at]) {
|
||||||
|
'-' -> dashes = true
|
||||||
|
'|' -> pipes = true
|
||||||
|
':',
|
||||||
|
' ',
|
||||||
|
'\t' -> {}
|
||||||
|
else -> return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return dashes && pipes
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hasPipe(start: Int, end: Int): Boolean {
|
||||||
|
var at = start
|
||||||
|
while (at < end) {
|
||||||
|
if (code[at] == '\\') at += 2 else if (code[at] == '|') return true else at++
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A table row: the pipes are the structure, and what is between them is prose. */
|
||||||
|
private fun tableRow(start: Int, end: Int) {
|
||||||
|
var at = indented(start, end)
|
||||||
|
var cell = at
|
||||||
|
while (at < end) {
|
||||||
|
when (code[at]) {
|
||||||
|
'\\' -> at += 2
|
||||||
|
'|' -> {
|
||||||
|
inline(cell, at)
|
||||||
|
emit(at, at + 1, Kind.MARK)
|
||||||
|
at++
|
||||||
|
cell = at
|
||||||
|
}
|
||||||
|
else -> at++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
inline(cell, end)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spans, coalesced with the one before when they touch and agree.
|
||||||
|
*
|
||||||
|
* Worth doing here rather than leaving it to the caller: the line scanner emits per marker and
|
||||||
|
* per word, so a heading would otherwise arrive as a dozen abutting spans of one colour.
|
||||||
|
*/
|
||||||
|
private fun emit(start: Int, end: Int, kind: Kind) {
|
||||||
|
if (end <= start) return
|
||||||
|
val last = spans.lastOrNull()
|
||||||
|
if (last != null && last.kind == kind && last.end == start) {
|
||||||
|
spans[spans.size - 1] = Span(last.start, end, kind)
|
||||||
|
} else {
|
||||||
|
spans.add(Span(start, end, kind))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The first character of the line at or after [start] that is not indentation. */
|
||||||
|
private fun indented(start: Int, end: Int): Int {
|
||||||
|
var at = start
|
||||||
|
while (at < end && (code[at] == ' ' || code[at] == '\t')) at++
|
||||||
|
return at
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The run of backticks or tildes that could open or close a fence on this line, or null. */
|
||||||
|
private fun fenceRun(start: Int, end: Int): IntRange? {
|
||||||
|
val at = indented(start, end)
|
||||||
|
if (at == end) return null
|
||||||
|
val marker = code[at]
|
||||||
|
if (marker != '`' && marker != '~') return null
|
||||||
|
var run = at
|
||||||
|
while (run < end && code[run] == marker) run++
|
||||||
|
return if (run - at >= 3) at until run else null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Draws an opening fence line and answers its delimiter, or null if this is not one. */
|
||||||
|
private fun opensFence(start: Int, end: Int): String? {
|
||||||
|
val run = fenceRun(start, end) ?: return null
|
||||||
|
emit(run.first, run.last + 1, Kind.STRING)
|
||||||
|
// The info word is what the fence is a fence *of*, which is metadata about the block
|
||||||
|
// rather than part of it -- the same reading as a Rust attribute above a struct.
|
||||||
|
emit(indented(run.last + 1, end), end, Kind.METADATA)
|
||||||
|
return code.substring(run.first, run.last + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether this line closes a fence opened by [open].
|
||||||
|
*
|
||||||
|
* The same character, at least as many of them, and nothing else on the line -- so a longer run
|
||||||
|
* closes a shorter one and a line of backticks with a word after it does not close anything.
|
||||||
|
*/
|
||||||
|
private fun closesFence(start: Int, end: Int, open: String): Boolean {
|
||||||
|
val run = fenceRun(start, end) ?: return false
|
||||||
|
if (code[run.first] != open[0] || run.last + 1 - run.first < open.length) return false
|
||||||
|
return indented(run.last + 1, end) == end
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One ordinary line: what its opening characters make it, and then its prose. */
|
||||||
|
private fun structure(start: Int, end: Int) {
|
||||||
|
var at = indented(start, end)
|
||||||
|
// Quote markers come before everything else and can be several deep, and what follows one
|
||||||
|
// is an ordinary line again -- a heading inside a quote is still a heading.
|
||||||
|
while (at < end && code[at] == '>') {
|
||||||
|
at++
|
||||||
|
emit(at - 1, at, Kind.MARK)
|
||||||
|
at = indented(at, end)
|
||||||
|
}
|
||||||
|
if (at == end) return
|
||||||
|
if (heading(at, end) || thematicBreak(at, end)) return
|
||||||
|
inline(bullet(at, end), end)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `#` to `######` and a space. Without the space it is a word beginning with a hash. */
|
||||||
|
private fun heading(start: Int, end: Int): Boolean {
|
||||||
|
var at = start
|
||||||
|
while (at < end && code[at] == '#') at++
|
||||||
|
val depth = at - start
|
||||||
|
if (depth !in 1..6) return false
|
||||||
|
if (at < end && code[at] != ' ' && code[at] != '\t') return false
|
||||||
|
emit(start, end, Kind.KEYWORD)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A line made of one repeated rule character and nothing else.
|
||||||
|
*
|
||||||
|
* `---`, `***` and `___` are thematic breaks; `===` and `---` are also the underline of a
|
||||||
|
* setext heading. The two are the same line to look at and mean the same thing to a reader -- a
|
||||||
|
* rule drawn across the page -- so they get one appearance rather than a lookback to tell them
|
||||||
|
* apart. One `=` is enough because a setext underline may be a single character; a break needs
|
||||||
|
* three, which is what keeps a `- ` bullet out of here.
|
||||||
|
*/
|
||||||
|
private fun thematicBreak(start: Int, end: Int): Boolean {
|
||||||
|
val marker = code[start]
|
||||||
|
if (marker !in RULE_MARKERS) return false
|
||||||
|
var seen = 0
|
||||||
|
for (at in start until end) {
|
||||||
|
val character = code[at]
|
||||||
|
if (character == marker) seen++ else if (!character.isWhitespace()) return false
|
||||||
|
}
|
||||||
|
if (seen < if (marker == '=') 1 else 3) return false
|
||||||
|
emit(start, end, Kind.MARK)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Draws a list marker if the line opens with one, and answers where the item's text starts. */
|
||||||
|
private fun bullet(start: Int, end: Int): Int {
|
||||||
|
val marker = code[start]
|
||||||
|
if (marker in BULLETS && spaceOrEnd(start + 1, end)) {
|
||||||
|
emit(start, start + 1, Kind.MARK)
|
||||||
|
return indented(start + 1, end)
|
||||||
|
}
|
||||||
|
var digits = start
|
||||||
|
while (digits < end && code[digits].isDigit()) digits++
|
||||||
|
val delimiter = code.getOrNull(digits)
|
||||||
|
if (
|
||||||
|
digits > start && (delimiter == '.' || delimiter == ')') && spaceOrEnd(digits + 1, end)
|
||||||
|
) {
|
||||||
|
emit(start, digits + 1, Kind.MARK)
|
||||||
|
return indented(digits + 1, end)
|
||||||
|
}
|
||||||
|
return start
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun spaceOrEnd(at: Int, end: Int) = at >= end || code[at] == ' ' || code[at] == '\t'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The inline forms, left to right.
|
||||||
|
*
|
||||||
|
* Every branch answers a position strictly after [start] of its call, so this terminates
|
||||||
|
* whether or not the form it was looking at turned out to be one.
|
||||||
|
*/
|
||||||
|
private fun inline(start: Int, end: Int) {
|
||||||
|
var at = start
|
||||||
|
while (at < end) {
|
||||||
|
val character = code[at]
|
||||||
|
at =
|
||||||
|
when {
|
||||||
|
// A backslash takes the character after it out of the running entirely, which
|
||||||
|
// is how `\*` stays an asterisk rather than opening emphasis.
|
||||||
|
character == '\\' -> at + 2
|
||||||
|
character == '`' -> codeSpan(at, end)
|
||||||
|
character == '[' -> link(at, at, end)
|
||||||
|
character == '!' && code.getOrNull(at + 1) == '[' -> link(at, at + 1, end)
|
||||||
|
character == '<' -> autolink(at, end)
|
||||||
|
character in EMPHASIS -> emphasis(at, end)
|
||||||
|
else -> url(at, end) ?: (at + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `` `code` ``, closed by a run of exactly as many backticks as opened it.
|
||||||
|
*
|
||||||
|
* That count is what lets a span hold a backtick of its own (``` ``a ` b`` ```), and it is why
|
||||||
|
* the search skips over a shorter or longer run rather than stopping at the first backtick.
|
||||||
|
*/
|
||||||
|
private fun codeSpan(start: Int, end: Int): Int {
|
||||||
|
var open = start
|
||||||
|
while (open < end && code[open] == '`') open++
|
||||||
|
val ticks = open - start
|
||||||
|
var at = open
|
||||||
|
while (at < end) {
|
||||||
|
if (code[at] != '`') {
|
||||||
|
at++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var close = at
|
||||||
|
while (close < end && code[close] == '`') close++
|
||||||
|
if (close - at == ticks) {
|
||||||
|
emit(start, close, Kind.STRING)
|
||||||
|
return close
|
||||||
|
}
|
||||||
|
at = close
|
||||||
|
}
|
||||||
|
// Nothing closes it on this line, so those were ordinary backticks.
|
||||||
|
return open
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `[text](destination)`, and the same with a leading `!` for an image.
|
||||||
|
*
|
||||||
|
* The text is drawn as prose -- it is what the reader reads -- so only the brackets around it
|
||||||
|
* are marked, and the destination is metadata: the place the link goes rather than anything
|
||||||
|
* said to the reader. A `[text]` with no destination after it is left plain, because that is
|
||||||
|
* what a reference link and a bracketed aside look like, and neither is worth guessing at.
|
||||||
|
*/
|
||||||
|
private fun link(start: Int, bracket: Int, end: Int): Int {
|
||||||
|
var depth = 0
|
||||||
|
var close = bracket
|
||||||
|
while (close < end) {
|
||||||
|
when (code[close]) {
|
||||||
|
'\\' -> close++
|
||||||
|
'[' -> depth++
|
||||||
|
']' -> {
|
||||||
|
depth--
|
||||||
|
if (depth == 0) break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
close++
|
||||||
|
}
|
||||||
|
if (close >= end) return start + 1
|
||||||
|
val destination = close + 1
|
||||||
|
if (code.getOrNull(destination) != '(') return start + 1
|
||||||
|
val paren = code.indexOf(')', destination)
|
||||||
|
if (paren < 0 || paren >= end) return start + 1
|
||||||
|
emit(start, bracket + 1, Kind.MARK)
|
||||||
|
inline(bracket + 1, close)
|
||||||
|
emit(close, destination, Kind.MARK)
|
||||||
|
emit(destination, paren + 1, Kind.METADATA)
|
||||||
|
return paren + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `<https://example.com>` and `<name@example.com>`, drawn as the destination they are.
|
||||||
|
*
|
||||||
|
* The angle brackets have to hold no whitespace and something that makes an address of it -- a
|
||||||
|
* scheme's colon or an at sign -- which is what keeps an HTML tag out: `<div>` has neither, and
|
||||||
|
* `<img src="http://x">` has the colon but also a space.
|
||||||
|
*/
|
||||||
|
private fun autolink(start: Int, end: Int): Int {
|
||||||
|
var at = start + 1
|
||||||
|
var addressed = false
|
||||||
|
while (at < end) {
|
||||||
|
val character = code[at]
|
||||||
|
if (character.isWhitespace() || character == '<') return start + 1
|
||||||
|
if (character == '>') {
|
||||||
|
if (!addressed) return start + 1
|
||||||
|
emit(start, at + 1, Kind.METADATA)
|
||||||
|
return at + 1
|
||||||
|
}
|
||||||
|
if (character == ':' || character == '@') addressed = true
|
||||||
|
at++
|
||||||
|
}
|
||||||
|
return start + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A bare `scheme://…` written in prose, or null if one does not start here.
|
||||||
|
*
|
||||||
|
* A scheme and `://` rather than a list of them, so `ftp`, `file` and `ssh` need no entry, and
|
||||||
|
* the pair of colons is what makes the match unambiguous enough to draw without a closer.
|
||||||
|
*
|
||||||
|
* Where it ends is the part worth stating: the sentence's punctuation is not the address, so a
|
||||||
|
* trailing `.` or `,` is given back, and so is a closing bracket unless one opened inside the
|
||||||
|
* URL -- otherwise a link in parentheses loses its `)` to the address. A pipe stops it too,
|
||||||
|
* because a URL in a table cell must not swallow the cell's edge.
|
||||||
|
*/
|
||||||
|
private fun url(start: Int, end: Int): Int? {
|
||||||
|
if (start > 0 && isWord(code[start - 1])) return null
|
||||||
|
var scheme = start
|
||||||
|
while (scheme < end && code[scheme].isLetter()) scheme++
|
||||||
|
if (scheme == start || !code.startsWith("://", scheme)) return null
|
||||||
|
val body = scheme + 3
|
||||||
|
var at = body
|
||||||
|
var openers = 0
|
||||||
|
var closers = 0
|
||||||
|
while (at < end && !code[at].isWhitespace() && code[at] !in URL_STOPS) {
|
||||||
|
if (code[at] == '(') openers++ else if (code[at] == ')') closers++
|
||||||
|
at++
|
||||||
|
}
|
||||||
|
while (at > body) {
|
||||||
|
val last = code[at - 1]
|
||||||
|
if (last in URL_TRAILING) at--
|
||||||
|
else if (last == ')' && closers > openers) {
|
||||||
|
closers--
|
||||||
|
at--
|
||||||
|
} else break
|
||||||
|
}
|
||||||
|
if (at == body) return null
|
||||||
|
emit(start, at, Kind.METADATA)
|
||||||
|
return at
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `*emph*`, `**strong**`, `_emph_` and `~~struck~~`, drawn markers and all.
|
||||||
|
*
|
||||||
|
* Markers and all because that is how the token scanner draws a string: the quotes are part of
|
||||||
|
* the thing. The two guards are what keep this off code that happens to be in a paragraph --
|
||||||
|
* the opener must be followed by something to emphasise and the closer preceded by something
|
||||||
|
* emphasised, so `a * b * c` opens nothing and neither does the `*p = *q` of a C fragment.
|
||||||
|
* Underscores additionally may not start or end inside a word, or every `snake_case_name` in a
|
||||||
|
* document would be half emphasised.
|
||||||
|
*/
|
||||||
|
private fun emphasis(start: Int, end: Int): Int {
|
||||||
|
val marker = code[start]
|
||||||
|
var open = start
|
||||||
|
while (open < end && code[open] == marker) open++
|
||||||
|
val length = open - start
|
||||||
|
if (marker == '~' && length != 2) return open
|
||||||
|
if (length > 3) return open
|
||||||
|
if (open == end || code[open].isWhitespace()) return open
|
||||||
|
if (marker == '_' && start > 0 && isWord(code[start - 1])) return open
|
||||||
|
var at = open
|
||||||
|
while (at < end) {
|
||||||
|
if (code[at] == '\\') {
|
||||||
|
at += 2
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (code[at] != marker) {
|
||||||
|
at++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var close = at
|
||||||
|
while (close < end && code[close] == marker) close++
|
||||||
|
val finish = at + length
|
||||||
|
if (
|
||||||
|
close - at >= length &&
|
||||||
|
!code[at - 1].isWhitespace() &&
|
||||||
|
!(marker == '_' && finish < end && isWord(code[finish]))
|
||||||
|
) {
|
||||||
|
emit(start, finish, Kind.LITERAL)
|
||||||
|
return finish
|
||||||
|
}
|
||||||
|
at = close
|
||||||
|
}
|
||||||
|
return open
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isWord(character: Char) = character.isLetterOrDigit() || character == '_'
|
||||||
@@ -29,10 +29,10 @@ import androidx.compose.ui.unit.sp
|
|||||||
* grounds that a system font may not have the glyph and whoever gets the empty box instead is never
|
* grounds that a system font may not have the glyph and whoever gets the empty box instead is never
|
||||||
* the person who wrote it. That objection is about *relying* on a system font, and it is exactly
|
* the person who wrote it. That objection is about *relying* on a system font, and it is exactly
|
||||||
* right: the answer is not to avoid glyphs but to ship them. The font here is
|
* right: the answer is not to avoid glyphs but to ship them. The font here is
|
||||||
* `app/build-icon-font.sh`'s output -- eleven glyphs, 2.1 KB, subset out of the 3 MB symbols font
|
* `app/build-icon-font.sh`'s output -- seventeen glyphs, 2.8 KB, subset out of the 3 MB symbols
|
||||||
* and committed -- so the codepoints below are resolved by an asset in the APK and cannot come back
|
* font and committed -- so the codepoints below are resolved by an asset in the APK and cannot come
|
||||||
* as tofu. Adding one means adding its codepoint in *both* places; a codepoint here that the script
|
* back as tofu. Adding one means adding its codepoint in *both* places; a codepoint here that the
|
||||||
* did not subset is a glyph that silently isn't there.
|
* script did not subset is a glyph that silently isn't there.
|
||||||
*
|
*
|
||||||
* The subset is the font's **Mono** face, where every glyph is exactly one em wide and one em tall.
|
* The subset is the font's **Mono** face, where every glyph is exactly one em wide and one em tall.
|
||||||
* That is what makes two icons the same size without either of them being given a size: the
|
* That is what makes two icons the same size without either of them being given a size: the
|
||||||
@@ -117,6 +117,33 @@ val USAGE_GLYPH = glyph(0xF201)
|
|||||||
*/
|
*/
|
||||||
val SPEED_GLYPH = glyph(0xF04C5)
|
val SPEED_GLYPH = glyph(0xF04C5)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `md-folder` -- the files on the machine this session runs on.
|
||||||
|
*
|
||||||
|
* The same codepoint dev-updater uses, and it must not drift from it, for the reason the cog and
|
||||||
|
* the refresh arrow must not: a folder that meant something else in one of the two apps is exactly
|
||||||
|
* the confusion sharing them prevents. Doubles as the mark on a directory row inside the explorer,
|
||||||
|
* which is what makes the button say where it leads.
|
||||||
|
*/
|
||||||
|
val FOLDER_GLYPH = glyph(0xF024B)
|
||||||
|
|
||||||
|
/** `md-file_outline` -- one file, in a listing beside the directories. */
|
||||||
|
val FILE_GLYPH = glyph(0xF0224)
|
||||||
|
|
||||||
|
/** `md-plus` -- make something here. dev-updater's codepoint as well. */
|
||||||
|
val PLUS_GLYPH = glyph(0xF0415)
|
||||||
|
|
||||||
|
/** `md-pencil` -- change what this file says, rather than only reading it. */
|
||||||
|
val EDIT_GLYPH = glyph(0xF03EB)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `md-content_save` -- write the edits back to the machine.
|
||||||
|
*
|
||||||
|
* The floppy disk, which is what save has meant for longer than most of the people reading it have
|
||||||
|
* been alive and is still the only mark anybody recognises for it.
|
||||||
|
*/
|
||||||
|
val SAVE_GLYPH = glyph(0xF0193)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The size an icon draws at beside a line of text.
|
* The size an icon draws at beside a line of text.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -235,6 +235,8 @@ fun SessionScreen(
|
|||||||
settings: ServerSettings,
|
settings: ServerSettings,
|
||||||
summary: SessionSummary,
|
summary: SessionSummary,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
|
/** Opens the file explorer on this session's machine, starting where this session works. */
|
||||||
|
onFiles: (FilesTarget) -> Unit,
|
||||||
/** What another app shared in while this session is the one open; see [ShareRequest]. */
|
/** What another app shared in while this session is the one open; see [ShareRequest]. */
|
||||||
share: ShareRequest? = null,
|
share: ShareRequest? = null,
|
||||||
/** Said once [share] has been attached here, so it is not attached again. */
|
/** Said once [share] has been attached here, so it is not attached again. */
|
||||||
@@ -1347,6 +1349,60 @@ fun SessionScreen(
|
|||||||
// interpolated, so there is nothing for a dropped frame to interrupt), so it is what both
|
// interpolated, so there is nothing for a dropped frame to interrupt), so it is what both
|
||||||
// places below fall back to.
|
// places below fall back to.
|
||||||
val imeVisible = WindowInsets.isImeVisible
|
val imeVisible = WindowInsets.isImeVisible
|
||||||
|
|
||||||
|
// What this session is costing to draw, copied out to somewhere it can be read.
|
||||||
|
//
|
||||||
|
// Written here rather than beside the control that runs it, because everything it measures --
|
||||||
|
// the events, the rows, the units, what the list has on screen, which cards are open -- is this
|
||||||
|
// composable's own state, and a control in a dialog cannot reach it. The control is a row in
|
||||||
|
// [SessionSettingsDialog]: that is where the session's other about-the-session controls are,
|
||||||
|
// and the header is for what a reader presses while reading. It copies rather than opens,
|
||||||
|
// because what it produces is for somewhere else -- a message to whoever is looking at the
|
||||||
|
// code -- and a screenful of timings read on the phone is a screenful nobody can act on.
|
||||||
|
//
|
||||||
|
// Whatever presses this, it is found by its **name**: `ui-trace`'s tap-by-label action resolves
|
||||||
|
// "Session settings" and then "Copy render timings" from what is on screen at that moment, so
|
||||||
|
// `transcript-bench.sh` and `stream-bench.sh` keep working when this moves again. They pressed
|
||||||
|
// it at a coordinate measured once by hand until 2026-09-03, and anything that moved the header
|
||||||
|
// made that tap land on whatever now sat there -- reporting a number that was never measured.
|
||||||
|
val copyRenderReport = {
|
||||||
|
val report =
|
||||||
|
debugReport(
|
||||||
|
device =
|
||||||
|
"device: ${Build.MODEL} (${Build.MANUFACTURER})," +
|
||||||
|
" Android ${Build.VERSION.RELEASE}\n" +
|
||||||
|
// A debuggable build runs Compose at a fraction of release speed, so a
|
||||||
|
// report that did not say which it came from was read as the app's own
|
||||||
|
// cost.
|
||||||
|
"build: ${if (debuggable(context)) "debug" else "release"}",
|
||||||
|
transcript =
|
||||||
|
listOf(
|
||||||
|
" ${items.size} events, ${rows.size} rows, ${units.size} units loaded",
|
||||||
|
" viewport ${listState.layoutInfo.viewportSize.height}px," +
|
||||||
|
" ${listState.layoutInfo.visibleItemsInfo.size} units visible",
|
||||||
|
visibleUnits(units, listState.layoutInfo.visibleItemsInfo, UNITS_START),
|
||||||
|
" ${expandedTools.size} tool calls and ${expandedGroups.size} groups open",
|
||||||
|
),
|
||||||
|
frames = FrameStats.lines(context.refreshHz()),
|
||||||
|
accounting =
|
||||||
|
FrameStats.drawPhase().let { (nanos, count) -> drawAccounting(nanos, count) },
|
||||||
|
crash = lastCrash(context),
|
||||||
|
)
|
||||||
|
context.copyToClipboard("ai-app render report", report)
|
||||||
|
// Also to the log, so a session driving the app over adb can read the same report the
|
||||||
|
// button copies. The clipboard is not reachable from a shell, and a counter nobody can
|
||||||
|
// check from here is a counter that only gets checked by asking Iris to press a button
|
||||||
|
// and paste.
|
||||||
|
Log.i("ai-app", report)
|
||||||
|
// Only once it is somewhere it can be read from, so a copy that never happened does not
|
||||||
|
// throw the stack away with it.
|
||||||
|
clearCrash(context)
|
||||||
|
// Emptied by the copy, so pressing it twice measures two separate stretches of scrolling
|
||||||
|
// rather than one and then the same one again.
|
||||||
|
FrameStats.reset()
|
||||||
|
DebugStats.reset()
|
||||||
|
Toast.makeText(context, "Copied render report", Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
Box(Modifier.fillMaxSize()) {
|
Box(Modifier.fillMaxSize()) {
|
||||||
Column(Modifier.fillMaxSize()) {
|
Column(Modifier.fillMaxSize()) {
|
||||||
Row(
|
Row(
|
||||||
@@ -1388,74 +1444,36 @@ fun SessionScreen(
|
|||||||
// yellow or red near a limit -- and the theme's plain control colour whenever there
|
// yellow or red near a limit -- and the theme's plain control colour whenever there
|
||||||
// is no measurement, since blue is the low end of the scale here and would read as
|
// is no measurement, since blue is the low end of the scale here and would read as
|
||||||
// "checked, and fine" about a machine nobody could reach.
|
// "checked, and fine" about a machine nobody could reach.
|
||||||
|
// Usage, files, settings -- widest scope first, narrowing to the right, so the
|
||||||
|
// cog stays at the end where every other screen keeps it. Asked for in this order
|
||||||
|
// by Iris on 2026-09-03.
|
||||||
Row {
|
Row {
|
||||||
// Left of the numbers about the *conversation*, because it is the same kind of
|
|
||||||
// thing about the *app*: what this session is costing to draw. It copies rather
|
|
||||||
// than opens, because what it produces is for somewhere else -- a message to
|
|
||||||
// whoever is looking at the code -- and a screenful of timings read on the
|
|
||||||
// phone
|
|
||||||
// is a screenful nobody can act on.
|
|
||||||
GlyphButton(
|
|
||||||
SPEED_GLYPH,
|
|
||||||
"Copy render timings",
|
|
||||||
onClick = {
|
|
||||||
val report =
|
|
||||||
debugReport(
|
|
||||||
device =
|
|
||||||
"device: ${Build.MODEL} (${Build.MANUFACTURER})," +
|
|
||||||
" Android ${Build.VERSION.RELEASE}\n" +
|
|
||||||
// A debuggable build runs Compose at a fraction of
|
|
||||||
// release speed, so a report that did not say which
|
|
||||||
// it came from was read as the app's own cost.
|
|
||||||
"build: ${if (debuggable(context)) "debug" else "release"}",
|
|
||||||
transcript =
|
|
||||||
listOf(
|
|
||||||
" ${items.size} events, ${rows.size} rows," +
|
|
||||||
" ${units.size} units loaded",
|
|
||||||
" viewport" +
|
|
||||||
" ${listState.layoutInfo.viewportSize.height}px," +
|
|
||||||
" ${listState.layoutInfo.visibleItemsInfo.size}" +
|
|
||||||
" units visible",
|
|
||||||
visibleUnits(
|
|
||||||
units,
|
|
||||||
listState.layoutInfo.visibleItemsInfo,
|
|
||||||
UNITS_START,
|
|
||||||
),
|
|
||||||
" ${expandedTools.size} tool calls and" +
|
|
||||||
" ${expandedGroups.size} groups open",
|
|
||||||
),
|
|
||||||
frames = FrameStats.lines(context.refreshHz()),
|
|
||||||
accounting =
|
|
||||||
FrameStats.drawPhase().let { (nanos, count) ->
|
|
||||||
drawAccounting(nanos, count)
|
|
||||||
},
|
|
||||||
crash = lastCrash(context),
|
|
||||||
)
|
|
||||||
context.copyToClipboard("ai-app render report", report)
|
|
||||||
// Also to the log, so a session driving the app over adb can read the
|
|
||||||
// same report the button copies. The clipboard is not reachable from a
|
|
||||||
// shell, and a counter nobody can check from here is a counter that
|
|
||||||
// only
|
|
||||||
// gets checked by asking Iris to press a button and paste.
|
|
||||||
Log.i("ai-app", report)
|
|
||||||
// Only once it is somewhere it can be read from, so a copy that never
|
|
||||||
// happened does not throw the stack away with it.
|
|
||||||
clearCrash(context)
|
|
||||||
// Emptied by the copy, so pressing it twice measures two separate
|
|
||||||
// stretches
|
|
||||||
// of scrolling rather than one and then the same one again.
|
|
||||||
FrameStats.reset()
|
|
||||||
DebugStats.reset()
|
|
||||||
Toast.makeText(context, "Copied render report", Toast.LENGTH_SHORT)
|
|
||||||
.show()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
GlyphButton(
|
GlyphButton(
|
||||||
USAGE_GLYPH,
|
USAGE_GLYPH,
|
||||||
"Usage",
|
"Usage",
|
||||||
{ usageOpen = true },
|
{ usageOpen = true },
|
||||||
colour = usageGlyphColour(usage),
|
colour = usageGlyphColour(usage),
|
||||||
)
|
)
|
||||||
|
// The machine's files, which is where the answer to "what did it actually
|
||||||
|
// change" is. It opens *over* this screen rather than replacing it -- see
|
||||||
|
// [Screen.Session].
|
||||||
|
GlyphButton(
|
||||||
|
FOLDER_GLYPH,
|
||||||
|
"Files",
|
||||||
|
onClick = {
|
||||||
|
onFiles(
|
||||||
|
FilesTarget(
|
||||||
|
setup = summary.setup,
|
||||||
|
setupName = summary.setupName,
|
||||||
|
// Where this session works, and the machine's own home when it
|
||||||
|
// was never given a directory -- resolved there rather than
|
||||||
|
// guessed at here, since this app does not know that machine's
|
||||||
|
// home and must not invent one.
|
||||||
|
start = summary.cwd?.takeIf { it.isNotBlank() } ?: "~",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
// What it opens is about this session, so it sits at the end of the session's
|
// What it opens is about this session, so it sits at the end of the session's
|
||||||
// own row. The name is the whole of what it holds today, which is why it is a
|
// own row. The name is the whole of what it holds today, which is why it is a
|
||||||
// cog
|
// cog
|
||||||
@@ -2092,6 +2110,7 @@ fun SessionScreen(
|
|||||||
settingsOpen = false
|
settingsOpen = false
|
||||||
},
|
},
|
||||||
onDismiss = { settingsOpen = false },
|
onDismiss = { settingsOpen = false },
|
||||||
|
onCopyRenderReport = copyRenderReport,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,6 +64,11 @@ fun SessionSettingsDialog(
|
|||||||
cachedBytes: Long?,
|
cachedBytes: Long?,
|
||||||
onReload: () -> Unit,
|
onReload: () -> Unit,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
|
/**
|
||||||
|
* Copies what this session costs to draw. Built by the session screen, because everything it
|
||||||
|
* measures is that screen's own state -- see `copyRenderReport` there.
|
||||||
|
*/
|
||||||
|
onCopyRenderReport: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
var name by remember(sessionId) { mutableStateOf(title) }
|
var name by remember(sessionId) { mutableStateOf(title) }
|
||||||
@@ -304,6 +309,20 @@ fun SessionSettingsDialog(
|
|||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
// About this session, which is what everything in here is -- and it was on the
|
||||||
|
// header until 2026-09-03, where the folder button now is. It copies rather than
|
||||||
|
// opening anything, so it says so and then says it happened: a row that looks like
|
||||||
|
// a control and gives no sign of having run is one people press twice.
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Glyph(SPEED_GLYPH, colour = MaterialTheme.colorScheme.onSurface)
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Text("Render timings", modifier = Modifier.weight(1f))
|
||||||
|
TextButton(onClick = onCopyRenderReport) { Text("Copy") }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
// Disabled rather than absent while there is nothing to save: a button that comes and
|
// Disabled rather than absent while there is nothing to save: a button that comes and
|
||||||
|
|||||||
@@ -1,14 +1,17 @@
|
|||||||
package com.example.aiapp
|
package com.example.aiapp
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A byte count at the coarsest unit that still says something, so two of them stay comparable.
|
* A byte count at the coarsest unit that still says something, so rows stay comparable.
|
||||||
*
|
*
|
||||||
* Null for nothing at all, which is a different answer from a small number and is drawn with words
|
* Null at zero and below, because the screens that ask disagree about what nothing means and only
|
||||||
* rather than a figure: an import row with no size says nothing about size, and a transcript cache
|
* the caller knows: a transcript of no bytes is a measurement that has not happened, and is left
|
||||||
* holding nothing says "nothing cached".
|
* off the row; a file of no bytes is a file with nothing in it, and the explorer says `0 B` rather
|
||||||
|
* than leaving a gap the reader would have to interpret; a session with no cached transcript says
|
||||||
|
* "nothing cached", because a figure of none would read as a measurement.
|
||||||
*
|
*
|
||||||
* Here rather than beside either caller because a second copy of it would drift, and there is
|
* Its own file rather than the import screen's, where it started: three screens now say a size, and
|
||||||
* already one variant too many -- `ModelsScreen`'s `gigabytes` writes a download's size to two
|
* a second copy of these thresholds is how one list comes to call 4 kB what the other calls 4096 B.
|
||||||
|
* `ModelsScreen`'s `gigabytes` is deliberately not folded in -- it writes a download's size to two
|
||||||
* decimal places, which is a different question about a much larger number.
|
* decimal places, which is a different question about a much larger number.
|
||||||
*/
|
*/
|
||||||
fun humanSize(bytes: Long): String? =
|
fun humanSize(bytes: Long): String? =
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ class Sse(private val settings: ServerSettings) {
|
|||||||
if (!closed) {
|
if (!closed) {
|
||||||
throw ApiException(
|
throw ApiException(
|
||||||
"Can't reach the server -- retrying. (${e.message ?: e::class.simpleName})",
|
"Can't reach the server -- retrying. (${e.message ?: e::class.simpleName})",
|
||||||
e,
|
cause = e,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
Binary file not shown.
@@ -0,0 +1,87 @@
|
|||||||
|
package com.example.aiapp
|
||||||
|
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The line arithmetic behind the file viewer.
|
||||||
|
*
|
||||||
|
* Worth a test rather than an eye: a line number that is one out is invisible in a short file and
|
||||||
|
* obvious in a long one, and a colour that stops at a line break is invisible until the file has a
|
||||||
|
* block comment in it.
|
||||||
|
*/
|
||||||
|
class FileLinesTest {
|
||||||
|
@Test
|
||||||
|
fun `a file that ends with a newline has the number of lines its author would count`() {
|
||||||
|
assertEquals(listOf("one", "two"), FileLines.of("one\ntwo\n", null).lines)
|
||||||
|
assertEquals(listOf("one", "two"), FileLines.of("one\ntwo", null).lines)
|
||||||
|
// Only one is dropped: a blank line at the end of a file is a line somebody typed.
|
||||||
|
assertEquals(listOf("one", "two", ""), FileLines.of("one\ntwo\n\n", null).lines)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an empty file is one empty line`() {
|
||||||
|
val lines = FileLines.of("", null)
|
||||||
|
assertEquals(1, lines.size)
|
||||||
|
assertEquals("", lines.line(0).text)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a comment that spans lines is coloured on every line it covers`() {
|
||||||
|
val text = "fn a() {}\n/* still\n a comment */\nfn b() {}\n"
|
||||||
|
val lines = FileLines.of(text, Language.RUST)
|
||||||
|
assertEquals(4, lines.size)
|
||||||
|
val comment = catppuccinSyntax().of(Kind.COMMENT)
|
||||||
|
// The whole of the middle line, and the part of the third up to the closer.
|
||||||
|
assertTrue(lines.line(1).spanStyles.any { it.item.color == comment && it.start == 0 })
|
||||||
|
val third = lines.line(2)
|
||||||
|
assertTrue(third.spanStyles.any { it.item.color == comment && it.end == third.length })
|
||||||
|
// And the code around it is not commented.
|
||||||
|
assertTrue(lines.line(0).spanStyles.none { it.item.color == comment })
|
||||||
|
assertTrue(lines.line(3).spanStyles.none { it.item.color == comment })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a span never runs past the line it was cut into`() {
|
||||||
|
val lines = FileLines.of("val x = \"a\nb\"\nval y = 1\n", Language.KOTLIN)
|
||||||
|
for (index in 0 until lines.size) {
|
||||||
|
val line = lines.line(index)
|
||||||
|
assertTrue(
|
||||||
|
line.spanStyles.all { it.start >= 0 && it.end <= line.length },
|
||||||
|
"line $index",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The number every row in the viewer is sized to. It has to be the widest line, because rows of
|
||||||
|
* their natural widths scroll sideways by different amounts -- see [FileViewer].
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `the column count is the widest line, counting a tab as eight`() {
|
||||||
|
assertEquals(5, FileLines.of("one\nthree\nx\n", null).columns)
|
||||||
|
// A tab counts up to eight, and upwards on purpose: over-estimating leaves empty space
|
||||||
|
// past the longest line, under-estimating puts its end out of reach.
|
||||||
|
assertEquals(9, FileLines.of("\tx\nshort\n", null).columns)
|
||||||
|
// An empty file is one empty line, which is no columns at all rather than an error.
|
||||||
|
assertEquals(0, FileLines.of("", null).columns)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a file with no language is plain`() {
|
||||||
|
val lines = FileLines.of("fn main() {}\n", null)
|
||||||
|
assertTrue(lines.line(0).spanStyles.isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a language comes from the extension, and only from a real one`() {
|
||||||
|
assertEquals(Language.KOTLIN, fileLanguage("Main.kt"))
|
||||||
|
assertEquals(Language.KOTLIN, fileLanguage("build.gradle.kts"))
|
||||||
|
assertEquals(Language.RUST, fileLanguage("files.rs"))
|
||||||
|
assertEquals(Language.TOML, fileLanguage("Cargo.toml"))
|
||||||
|
assertEquals(null, fileLanguage("Makefile"))
|
||||||
|
assertEquals(null, fileLanguage(".bashrc"))
|
||||||
|
assertEquals(null, fileLanguage("notes.txt"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,9 +16,7 @@ import kotlin.test.assertTrue
|
|||||||
class HighlighterTest {
|
class HighlighterTest {
|
||||||
/** Every span of [kind] in [code], as the text it covers. */
|
/** Every span of [kind] in [code], as the text it covers. */
|
||||||
private fun spans(code: String, language: Language, kind: Kind): List<String> =
|
private fun spans(code: String, language: Language, kind: Kind): List<String> =
|
||||||
scan(code, rulesOf(language))
|
spansOf(code, language).filter { it.kind == kind }.map { code.substring(it.start, it.end) }
|
||||||
.filter { it.kind == kind }
|
|
||||||
.map { code.substring(it.start, it.end) }
|
|
||||||
|
|
||||||
private fun assertSpans(
|
private fun assertSpans(
|
||||||
code: String,
|
code: String,
|
||||||
@@ -182,6 +180,158 @@ class HighlighterTest {
|
|||||||
assertSpans(code, Language.RON, Kind.LITERAL, "3")
|
assertSpans(code, Language.RON, Kind.LITERAL, "3")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Markdown, which has a scanner of its own: what a character means there is decided by where
|
||||||
|
// it sits rather than by what it is, so most of these are about the cases where it means
|
||||||
|
// nothing at all.
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a heading is coloured whole and a hash inside a word is not one`() {
|
||||||
|
val code = "## Layout\nissue #12 is fixed\n#hashtag"
|
||||||
|
assertSpans(code, Language.MARKDOWN, Kind.KEYWORD, "## Layout")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `seven hashes are not a heading`() {
|
||||||
|
assertSpans("####### deep", Language.MARKDOWN, Kind.KEYWORD)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a fence carries its language as metadata and its body as one string`() {
|
||||||
|
val code = "text\n```kotlin\nval x = 1\n```\nmore"
|
||||||
|
assertSpans(code, Language.MARKDOWN, Kind.METADATA, "kotlin")
|
||||||
|
assertSpans(code, Language.MARKDOWN, Kind.STRING, "```", "val x = 1", "```")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The state that crosses a line, so the one worth asking about at both ends. */
|
||||||
|
@Test
|
||||||
|
fun `a longer fence is not closed by a shorter one, and a heading inside it is not a heading`() {
|
||||||
|
val code = "````\n```\n# not a heading\n````\nafter"
|
||||||
|
assertSpans(code, Language.MARKDOWN, Kind.KEYWORD)
|
||||||
|
assertSpans(code, Language.MARKDOWN, Kind.STRING, "````", "```", "# not a heading", "````")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an unclosed fence runs to the end rather than throwing`() {
|
||||||
|
assertSpans("```\nstill going", Language.MARKDOWN, Kind.STRING, "```", "still going")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `list markers and quote markers colour without their text`() {
|
||||||
|
val code = "- one\n2. two\n> quoted"
|
||||||
|
assertSpans(code, Language.MARKDOWN, Kind.MARK, "-", "2.", ">")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a rule and a setext underline are the same mark`() {
|
||||||
|
assertSpans("Title\n=====\n\n---", Language.MARKDOWN, Kind.MARK, "=====", "---")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `emphasis needs something on both sides of it`() {
|
||||||
|
assertSpans("**bold** and *thin*", Language.MARKDOWN, Kind.LITERAL, "**bold**", "*thin*")
|
||||||
|
// The case the guards exist for: a C fragment written in a paragraph.
|
||||||
|
assertSpans("a * b * c and *p = *q", Language.MARKDOWN, Kind.LITERAL)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an underscore inside a word emphasises nothing`() {
|
||||||
|
assertSpans("snake_case_name and _real_", Language.MARKDOWN, Kind.LITERAL, "_real_")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a code span holds a backtick when opened with two`() {
|
||||||
|
assertSpans("``a ` b`` and `c`", Language.MARKDOWN, Kind.STRING, "``a ` b``", "`c`")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an unclosed code span is ordinary text`() {
|
||||||
|
assertSpans("a ` b", Language.MARKDOWN, Kind.STRING)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a link marks its brackets and colours its destination`() {
|
||||||
|
val code = "see [the plan](PLAN.md) now"
|
||||||
|
assertSpans(code, Language.MARKDOWN, Kind.MARK, "[", "]")
|
||||||
|
assertSpans(code, Language.MARKDOWN, Kind.METADATA, "(PLAN.md)")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a table is found by its delimiter row, and pipes elsewhere are plain`() {
|
||||||
|
val code = "| a | b |\n|---|---|\n| 1 | 2 |\n\nrun a | b in a paragraph"
|
||||||
|
assertSpans(
|
||||||
|
code,
|
||||||
|
Language.MARKDOWN,
|
||||||
|
Kind.MARK,
|
||||||
|
"|",
|
||||||
|
"|",
|
||||||
|
"|",
|
||||||
|
"|---|---|",
|
||||||
|
"|",
|
||||||
|
"|",
|
||||||
|
"|",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a table without outer pipes still colours, and the table ends with the rows`() {
|
||||||
|
val code = "a | b\n--- | ---\nnot a row"
|
||||||
|
assertSpans(code, Language.MARKDOWN, Kind.MARK, "|", "--- | ---")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The tag in the last case is not an autolink and is left plain, but the address inside it is
|
||||||
|
* still an address and the bare-URL pass finds it. That is the intended reading: raw HTML is
|
||||||
|
* not something this scanner knows, and a URL is a URL wherever it was written.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `an autolink colours and an HTML tag does not`() {
|
||||||
|
val code = "<https://example.com> and <a@b.com> and <div> and <img src=\"http://x\">"
|
||||||
|
assertSpans(
|
||||||
|
code,
|
||||||
|
Language.MARKDOWN,
|
||||||
|
Kind.METADATA,
|
||||||
|
"<https://example.com>",
|
||||||
|
"<a@b.com>",
|
||||||
|
"http://x",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a bare URL gives back the sentence's punctuation`() {
|
||||||
|
assertSpans(
|
||||||
|
"see https://example.com/a., and ssh://host/x)",
|
||||||
|
Language.MARKDOWN,
|
||||||
|
Kind.METADATA,
|
||||||
|
"https://example.com/a",
|
||||||
|
"ssh://host/x",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a bracket a URL opened itself stays in it`() {
|
||||||
|
assertSpans(
|
||||||
|
"https://en.wikipedia.org/wiki/A_(b) here",
|
||||||
|
Language.MARKDOWN,
|
||||||
|
Kind.METADATA,
|
||||||
|
"https://en.wikipedia.org/wiki/A_(b)",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a URL inside a link destination is not coloured twice`() {
|
||||||
|
assertSpans(
|
||||||
|
"[x](https://example.com)",
|
||||||
|
Language.MARKDOWN,
|
||||||
|
Kind.METADATA,
|
||||||
|
"(https://example.com)",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a bracket with no destination after it is left plain`() {
|
||||||
|
assertSpans("an [aside] here", Language.MARKDOWN, Kind.MARK)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `an unknown fence language is drawn plain`() {
|
fun `an unknown fence language is drawn plain`() {
|
||||||
assertEquals(null, fenceLanguage("brainfuck"))
|
assertEquals(null, fenceLanguage("brainfuck"))
|
||||||
@@ -189,8 +339,8 @@ class HighlighterTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `every alias the fence table knows has rules`() {
|
fun `every language the fence table knows has a scanner`() {
|
||||||
Language.entries.forEach { rulesOf(it) }
|
Language.entries.forEach { spansOf("x", it) }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -218,11 +368,26 @@ class HighlighterTest {
|
|||||||
"0x",
|
"0x",
|
||||||
"1.2.3",
|
"1.2.3",
|
||||||
"a#b//c/*d*/'e\"f",
|
"a#b//c/*d*/'e\"f",
|
||||||
|
"```",
|
||||||
|
"*",
|
||||||
|
"**",
|
||||||
|
"~~",
|
||||||
|
"> ",
|
||||||
|
"- ",
|
||||||
|
"1.",
|
||||||
|
"[x](",
|
||||||
|
"#######",
|
||||||
|
"|",
|
||||||
|
"|---|",
|
||||||
|
"<",
|
||||||
|
"<>",
|
||||||
|
"http://",
|
||||||
|
"a://",
|
||||||
"\n\n \n",
|
"\n\n \n",
|
||||||
)
|
)
|
||||||
for (language in Language.entries) {
|
for (language in Language.entries) {
|
||||||
for (code in nasty) {
|
for (code in nasty) {
|
||||||
val spans = scan(code, rulesOf(language))
|
val spans = spansOf(code, language)
|
||||||
spans.forEach {
|
spans.forEach {
|
||||||
assertTrue(
|
assertTrue(
|
||||||
it.start in 0..it.end && it.end <= code.length,
|
it.start in 0..it.end && it.end <= code.length,
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# Shared by transcript-bench.sh and stream-bench.sh: the parts of driving
|
||||||
|
# this app that both need and that neither should describe twice.
|
||||||
|
#
|
||||||
|
# Sourced, not run. It assumes android-env.sh has already been sourced, so
|
||||||
|
# `ui-trace` and `adb` are this checkout's.
|
||||||
|
#
|
||||||
|
# **Nothing here presses a coordinate.** Every control is found by the name
|
||||||
|
# it already carries for assistive technology, resolved from the screen at
|
||||||
|
# the moment of the gesture (`ui-trace record --do "tap 'Save'"`). A
|
||||||
|
# coordinate is a position measured once by hand, and anything that moves
|
||||||
|
# the control -- a button added to the row, a font size, another device --
|
||||||
|
# makes the tap land on whatever now sits there; the bench then reports a
|
||||||
|
# number that was never measured, which reads exactly like a result. That
|
||||||
|
# is not hypothetical: both scripts pressed the render-report button at
|
||||||
|
# `tap 723 205` until 2026-09-03, when it moved into the session settings
|
||||||
|
# dialog. The check that none has crept back:
|
||||||
|
#
|
||||||
|
# grep -n "tap [0-9]" app/*.sh
|
||||||
|
|
||||||
|
# The title of the session these benches open by default: whichever one the
|
||||||
|
# server lists first, which is the most recently active.
|
||||||
|
#
|
||||||
|
# Asked of the server rather than taken from the screen, because a row has
|
||||||
|
# no stable name of its own and "the first one" is a fact about the list
|
||||||
|
# rather than about a position on it. Passing -s overrides it, and that is
|
||||||
|
# the honest way to measure a particular session: the list is ordered by
|
||||||
|
# last activity, so "the first row" is a different session at different
|
||||||
|
# times of day.
|
||||||
|
first_session_title() {
|
||||||
|
./ui-sandbox.sh api /sessions |
|
||||||
|
python3 -c 'import json,sys; d=json.load(sys.stdin); print(d[0]["title"] if d else "")'
|
||||||
|
}
|
||||||
|
|
||||||
|
# Restarts the app and opens the session called $1.
|
||||||
|
open_session() {
|
||||||
|
adb shell am force-stop com.example.aiapp
|
||||||
|
adb shell am start -n com.example.aiapp/.MainActivity >/dev/null
|
||||||
|
sleep 5
|
||||||
|
ui-trace record -d 6000 --do "tap '$1'" --do 'wait 3000' \
|
||||||
|
-o /tmp/bench-open.txt >/dev/null
|
||||||
|
sleep 2
|
||||||
|
}
|
||||||
|
|
||||||
|
# Copies the app's render report to the clipboard and the log, through the
|
||||||
|
# session settings dialog it lives in.
|
||||||
|
#
|
||||||
|
# Pressed twice per run: once to empty the counters, and once at the end,
|
||||||
|
# so what comes back covers exactly the work between the two. Closing the
|
||||||
|
# dialog is part of it -- it covers the transcript, which is the thing the
|
||||||
|
# gestures in between are meant to reach.
|
||||||
|
copy_render_report() {
|
||||||
|
ui-trace record -d 7000 \
|
||||||
|
--do "tap 'Session settings'" --do 'wait 1500' \
|
||||||
|
--do "tap 'Copy'" --do 'wait 800' \
|
||||||
|
--do "tap 'Close'" --do 'wait 800' \
|
||||||
|
-o "${1:-/tmp/bench-report.txt}" >/dev/null
|
||||||
|
}
|
||||||
@@ -39,6 +39,11 @@ GLYPHS=(
|
|||||||
U+F004D # md-arrow_left
|
U+F004D # md-arrow_left
|
||||||
U+F009A # md-bell
|
U+F009A # md-bell
|
||||||
U+F04C5 # md-speedometer
|
U+F04C5 # md-speedometer
|
||||||
|
U+F024B # md-folder -- dev-updater's too; a folder means the same in both
|
||||||
|
U+F0415 # md-plus -- likewise
|
||||||
|
U+F03EB # md-pencil
|
||||||
|
U+F0193 # md-content_save
|
||||||
|
U+F0224 # md-file_outline
|
||||||
U+F201 # fa-line_chart -- Font Awesome's, asked for by name
|
U+F201 # fa-line_chart -- Font Awesome's, asked for by name
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+17
-18
@@ -4,8 +4,9 @@
|
|||||||
# reply that is still arriving cheap", the way transcript-bench.sh is for
|
# reply that is still arriving cheap", the way transcript-bench.sh is for
|
||||||
# scrolling one that has settled.
|
# scrolling one that has settled.
|
||||||
#
|
#
|
||||||
# ./stream-bench.sh FILE restart the app, open the first
|
# ./stream-bench.sh FILE restart the app, open the session the
|
||||||
# session, stream FILE into it, report
|
# sandbox lists first, stream FILE into
|
||||||
|
# it, report
|
||||||
# ./stream-bench.sh -k FILE keep whatever session is open now
|
# ./stream-bench.sh -k FILE keep whatever session is open now
|
||||||
#
|
#
|
||||||
# The session is the first one the sandbox lists (ui-sandbox.sh spawn makes
|
# The session is the first one the sandbox lists (ui-sandbox.sh spawn makes
|
||||||
@@ -17,6 +18,7 @@
|
|||||||
set -eu
|
set -eu
|
||||||
cd "$(dirname "$0")"
|
cd "$(dirname "$0")"
|
||||||
. ./android-env.sh >/dev/null 2>&1
|
. ./android-env.sh >/dev/null 2>&1
|
||||||
|
. ./bench-lib.sh
|
||||||
|
|
||||||
keep=""
|
keep=""
|
||||||
while getopts k flag; do
|
while getopts k flag; do
|
||||||
@@ -31,11 +33,9 @@ file=${1:?usage: stream-bench.sh [-k] FILE}
|
|||||||
sid=$(./ui-sandbox.sh api /sessions | python3 -c 'import json,sys; print(json.load(sys.stdin)[0]["id"])')
|
sid=$(./ui-sandbox.sh api /sessions | python3 -c 'import json,sys; print(json.load(sys.stdin)[0]["id"])')
|
||||||
|
|
||||||
if [ -z "$keep" ]; then
|
if [ -z "$keep" ]; then
|
||||||
adb shell am force-stop com.example.aiapp
|
title=$(first_session_title)
|
||||||
adb shell am start -n com.example.aiapp/.MainActivity >/dev/null
|
[ -n "$title" ] || { echo "stream-bench: no sessions to open" >&2; exit 1; }
|
||||||
sleep 5
|
open_session "$title"
|
||||||
ui-trace record -d 3000 --do 'tap 500 545' -o /tmp/bench-open.txt >/dev/null 2>&1
|
|
||||||
sleep 3
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Pinned to the newest end before anything is sent. The transcript never
|
# Pinned to the newest end before anything is sent. The transcript never
|
||||||
@@ -46,18 +46,17 @@ fi
|
|||||||
# reads exactly like a build where the work vanished. The control is the
|
# reads exactly like a build where the work vanished. The control is the
|
||||||
# app's own "Jump to latest", which is only there while the newest message
|
# app's own "Jump to latest", which is only there while the newest message
|
||||||
# is off screen -- so when it is absent the list is already where it needs
|
# is off screen -- so when it is absent the list is already where it needs
|
||||||
# to be.
|
# to be. Looked for before it is pressed rather than pressed blindly: a tap
|
||||||
jump=$(ui-trace record -d 1200 -o /tmp/bench-jump.txt >/dev/null 2>&1
|
# by name fails the whole run when its label is missing, and missing is the
|
||||||
ui-trace show /tmp/bench-jump.txt -m 'Jump to latest' --field box |
|
# ordinary case here.
|
||||||
grep -o '[0-9]*,[0-9]*\.\.[0-9]*,[0-9]*' | tail -1)
|
ui-trace record -d 1200 -o /tmp/bench-jump.txt >/dev/null 2>&1
|
||||||
if [ -n "$jump" ]; then
|
if ui-trace show /tmp/bench-jump.txt -m 'Jump to latest' --field box | grep -q '[0-9],[0-9]'; then
|
||||||
x=$(echo "$jump" | awk -F'[,.]' '{print int(($1 + $4) / 2)}')
|
ui-trace record -d 2500 --do "tap 'Jump to latest'" --do 'wait 1000' \
|
||||||
y=$(echo "$jump" | awk -F'[,.]' '{print int(($2 + $5) / 2)}')
|
-o /tmp/bench-tolatest.txt >/dev/null
|
||||||
ui-trace record -d 1500 --do "tap $x $y" -o /tmp/bench-tolatest.txt >/dev/null 2>&1
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# The first tap resets the report's window; see transcript-bench.sh.
|
# The first copy resets the report's window; see transcript-bench.sh.
|
||||||
ui-trace record -d 2000 --do 'tap 723 205' -o /tmp/bench-reset.txt >/dev/null 2>&1
|
copy_render_report /tmp/bench-reset.txt
|
||||||
adb logcat -c
|
adb logcat -c
|
||||||
|
|
||||||
./ui-sandbox.sh send "$sid" "@$file" >/dev/null
|
./ui-sandbox.sh send "$sid" "@$file" >/dev/null
|
||||||
@@ -90,6 +89,6 @@ while [ "$i" -lt 180 ]; do
|
|||||||
done
|
done
|
||||||
sleep 1
|
sleep 1
|
||||||
|
|
||||||
ui-trace record -d 2000 --do 'tap 723 205' -o /tmp/bench-report.txt >/dev/null 2>&1
|
copy_render_report /tmp/bench-report.txt
|
||||||
sleep 1
|
sleep 1
|
||||||
adb logcat -d -s ai-app:I | sed -n '/ai-app render report/,$p' | sed 's/^.*ai-app : //'
|
adb logcat -d -s ai-app:I | sed -n '/ai-app render report/,$p' | sed 's/^.*ai-app : //'
|
||||||
+17
-14
@@ -3,8 +3,9 @@
|
|||||||
# standard measurement for "is the transcript smooth", so a perf session
|
# standard measurement for "is the transcript smooth", so a perf session
|
||||||
# starts from a number instead of re-inventing this loop in /tmp.
|
# starts from a number instead of re-inventing this loop in /tmp.
|
||||||
#
|
#
|
||||||
# ./transcript-bench.sh restart the app, open the first
|
# ./transcript-bench.sh restart the app, open the session the
|
||||||
# session, scroll, report
|
# server lists first, scroll, report
|
||||||
|
# ./transcript-bench.sh -s TITLE open that session instead
|
||||||
# ./transcript-bench.sh -k keep whatever screen is open now
|
# ./transcript-bench.sh -k keep whatever screen is open now
|
||||||
# ./transcript-bench.sh -n 4 swipe cycles (default 6; one cycle is
|
# ./transcript-bench.sh -n 4 swipe cycles (default 6; one cycle is
|
||||||
# two swipes back and two forward)
|
# two swipes back and two forward)
|
||||||
@@ -16,33 +17,35 @@
|
|||||||
# went. Compare two runs of this with the same gestures, not one run
|
# went. Compare two runs of this with the same gestures, not one run
|
||||||
# against a phone.
|
# against a phone.
|
||||||
#
|
#
|
||||||
# Coordinates are the ai-app AVD's 1080x2400 screen: the report button in
|
# The swipes are still coordinates, because a gesture across a scrolling
|
||||||
# the session top bar, and the first row of the session list.
|
# area is a distance rather than a control. Every *control* is pressed by
|
||||||
|
# name -- see bench-lib.sh.
|
||||||
set -eu
|
set -eu
|
||||||
cd "$(dirname "$0")"
|
cd "$(dirname "$0")"
|
||||||
. ./android-env.sh >/dev/null 2>&1
|
. ./android-env.sh >/dev/null 2>&1
|
||||||
|
. ./bench-lib.sh
|
||||||
|
|
||||||
keep=""
|
keep=""
|
||||||
cycles=6
|
cycles=6
|
||||||
while getopts kn: flag; do
|
session=""
|
||||||
|
while getopts kn:s: flag; do
|
||||||
case $flag in
|
case $flag in
|
||||||
k) keep=1 ;;
|
k) keep=1 ;;
|
||||||
n) cycles=$OPTARG ;;
|
n) cycles=$OPTARG ;;
|
||||||
|
s) session=$OPTARG ;;
|
||||||
*) exit 2 ;;
|
*) exit 2 ;;
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
|
|
||||||
if [ -z "$keep" ]; then
|
if [ -z "$keep" ]; then
|
||||||
adb shell am force-stop com.example.aiapp
|
[ -n "$session" ] || session=$(first_session_title)
|
||||||
adb shell am start -n com.example.aiapp/.MainActivity >/dev/null
|
[ -n "$session" ] || { echo "transcript-bench: no sessions to open" >&2; exit 1; }
|
||||||
sleep 5
|
open_session "$session"
|
||||||
ui-trace record -d 3000 --do 'tap 500 545' -o /tmp/bench-open.txt >/dev/null 2>&1
|
|
||||||
sleep 3
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# First tap resets the report's "work since last copied" window, so the
|
# The first copy resets the report's "work since last copied" window, so the
|
||||||
# numbers cover exactly the swipes between the two taps.
|
# numbers cover exactly the swipes between the two.
|
||||||
ui-trace record -d 2000 --do 'tap 723 205' -o /tmp/bench-reset.txt >/dev/null 2>&1
|
copy_render_report /tmp/bench-reset.txt
|
||||||
adb logcat -c
|
adb logcat -c
|
||||||
|
|
||||||
DO=""
|
DO=""
|
||||||
@@ -56,7 +59,7 @@ while [ "$i" -lt "$cycles" ]; do
|
|||||||
done
|
done
|
||||||
eval ui-trace record -d $((cycles * 4400 + 2000)) $DO -o /tmp/bench-scroll.txt >/dev/null 2>&1
|
eval ui-trace record -d $((cycles * 4400 + 2000)) $DO -o /tmp/bench-scroll.txt >/dev/null 2>&1
|
||||||
|
|
||||||
ui-trace record -d 2000 --do 'tap 723 205' -o /tmp/bench-report.txt >/dev/null 2>&1
|
copy_render_report /tmp/bench-report.txt
|
||||||
sleep 1
|
sleep 1
|
||||||
adb logcat -d -s ai-app:I | sed -n '/ai-app render report/,$p' | sed 's/^.*ai-app : //'
|
adb logcat -d -s ai-app:I | sed -n '/ai-app render report/,$p' | sed 's/^.*ai-app : //'
|
||||||
adb logcat -d 2>/dev/null | grep -i "duplicate unit key" | head -3 || true
|
adb logcat -d 2>/dev/null | grep -i "duplicate unit key" | head -3 || true
|
||||||
@@ -270,6 +270,34 @@ printf 'def main():\n # a comment\n print("hello")\n' >"$FILES/main.py"
|
|||||||
printf '#!/bin/sh\n# a comment\necho hello\n' >"$FILES/run.sh"
|
printf '#!/bin/sh\n# a comment\necho hello\n' >"$FILES/run.sh"
|
||||||
chmod +x "$FILES/run.sh"
|
chmod +x "$FILES/run.sh"
|
||||||
printf '{"a": 1, "b": [true, null]}\n' >"$FILES/data.json"
|
printf '{"a": 1, "b": [true, null]}\n' >"$FILES/data.json"
|
||||||
|
# Markdown's scanner is line-structured rather than tokens, so the fixture holds one of each
|
||||||
|
# thing it decides by position: a heading, a fence, a list, a quote, a link and a rule.
|
||||||
|
cat >"$FILES/notes.md" <<'MARKDOWN'
|
||||||
|
# Notes
|
||||||
|
|
||||||
|
A paragraph with `code`, **bold** and a [link](PLAN.md).
|
||||||
|
Not emphasis: a * b * c, and snake_case_name.
|
||||||
|
|
||||||
|
## A list
|
||||||
|
|
||||||
|
- one
|
||||||
|
- two
|
||||||
|
|
||||||
|
> quoted
|
||||||
|
|
||||||
|
| column | what it holds |
|
||||||
|
|--------|---------------|
|
||||||
|
| one | a value |
|
||||||
|
|
||||||
|
A link <https://example.com> and a bare https://example.com/a., but run a | b
|
||||||
|
in a paragraph has no table in it.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
fn main() { println!("hello"); }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
MARKDOWN
|
||||||
# Not UTF-8, so it reads as binary rather than as mojibake.
|
# Not UTF-8, so it reads as binary rather than as mojibake.
|
||||||
printf '\377\376\000\001binary\n' >"$FILES/picture.bin"
|
printf '\377\376\000\001binary\n' >"$FILES/picture.bin"
|
||||||
# Over FILE_LIMIT (1 MiB), so the read refuses before anything transfers.
|
# Over FILE_LIMIT (1 MiB), so the read refuses before anything transfers.
|
||||||
@@ -281,6 +309,33 @@ chmod 000 "$FILES/unreadable.txt"
|
|||||||
printf 'in a subdirectory\n' >"$FILES/sub/inside.txt"
|
printf 'in a subdirectory\n' >"$FILES/sub/inside.txt"
|
||||||
ln -sfn sub "$FILES/link-to-sub"
|
ln -sfn sub "$FILES/link-to-sub"
|
||||||
ln -sfn nowhere "$FILES/broken-link"
|
ln -sfn nowhere "$FILES/broken-link"
|
||||||
|
# The sizes the explorer's limits were measured against, so the numbers in
|
||||||
|
# EXPLORER.md can be taken again rather than re-derived. 32k is the largest
|
||||||
|
# the editor handles (EDIT_LIMIT); 128k is where typing loses characters;
|
||||||
|
# 1M is FILE_LIMIT, which the viewer reads fine and the editor refuses.
|
||||||
|
python3 - "$FILES" <<'FIXTURE'
|
||||||
|
import pathlib, sys
|
||||||
|
out = pathlib.Path(sys.argv[1])
|
||||||
|
block = """/// A doc comment on function number {i}, long enough that the scanner
|
||||||
|
/// has real comment spans to find rather than a token few.
|
||||||
|
fn generated_{i}(input: &str, count: u32) -> String {{
|
||||||
|
// an ordinary line comment
|
||||||
|
let mut out = String::from("prefix {i}: ");
|
||||||
|
for index in 0..count {{
|
||||||
|
out.push_str(&format!("{{}}-{{}}", index, input));
|
||||||
|
}}
|
||||||
|
out
|
||||||
|
}}
|
||||||
|
"""
|
||||||
|
for name, size in (("edit-32k.rs", 32 * 1024), ("edit-128k.rs", 128 * 1024),
|
||||||
|
("big-source.rs", 1024 * 1024)):
|
||||||
|
parts, written, i = [], 0, 0
|
||||||
|
while written < size - 400:
|
||||||
|
parts.append(block.format(i=i))
|
||||||
|
written += len(parts[-1])
|
||||||
|
i += 1
|
||||||
|
(out / name).write_text("".join(parts))
|
||||||
|
FIXTURE
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ -n "$regen_config" ]; then
|
if [ -n "$regen_config" ]; then
|
||||||
|
|||||||
+12
-1
@@ -1798,9 +1798,20 @@ async fn stream_session(
|
|||||||
/// and local; revisit if daily use produces transcripts where this shows
|
/// and local; revisit if daily use produces transcripts where this shows
|
||||||
/// (phase 6 territory).
|
/// (phase 6 territory).
|
||||||
async fn send_backlog(transcript: &Path, last: &mut u64, tx: &mpsc::Sender<SseEvent>) -> bool {
|
async fn send_backlog(transcript: &Path, last: &mut u64, tx: &mpsc::Sender<SseEvent>) -> bool {
|
||||||
|
let cursor = *last;
|
||||||
let entries = match catch_up(transcript, *last, CATCH_UP_LIMIT) {
|
let entries = match catch_up(transcript, *last, CATCH_UP_LIMIT) {
|
||||||
Ok(CatchUp::Continue(entries)) => entries,
|
Ok(CatchUp::Continue(entries)) => {
|
||||||
|
// The pair of them at debug, because "was this subscriber reset,
|
||||||
|
// and how far behind was it" is a question about a phone that
|
||||||
|
// nothing else here can answer -- the app sees a window arrive
|
||||||
|
// and cannot tell how far it had fallen, and a reset is the one
|
||||||
|
// thing that makes its screen jump. `RUST_LOG=ai_server=debug`,
|
||||||
|
// beside the transcript pages.
|
||||||
|
tracing::debug!(cursor, sent = entries.len(), "stream backlog: continue");
|
||||||
|
entries
|
||||||
|
}
|
||||||
Ok(CatchUp::Restart(entries)) => {
|
Ok(CatchUp::Restart(entries)) => {
|
||||||
|
tracing::debug!(cursor, sent = entries.len(), "stream backlog: reset");
|
||||||
if tx.send(SseEvent::default().event("reset")).await.is_err() {
|
if tx.send(SseEvent::default().event("reset")).await.is_err() {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user