Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9c4d33273b | ||
|
|
db55ed4a8f |
No files matched your search
@@ -129,6 +129,26 @@ repo is in PLAN.md's "Backend layout" section.
|
||||
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
|
||||
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, and a source file
|
||||
per language. 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.
|
||||
- `.dev-updater.ron` — what Dev Updater is asked to do with this checkout:
|
||||
the server (built in `server/`, run as `service: Managed(...)`) and the
|
||||
APK (built in `app/`), built in parallel. The project it serves is the
|
||||
@@ -212,6 +232,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
|
||||
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
|
||||
itself. Generate a throwaway key, append the public half to
|
||||
`~/.ssh/authorized_keys`, and configure a host of `bob@127.0.0.1` with
|
||||
|
||||
+16
-48
@@ -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
|
||||
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
|
||||
what was rejected, so that when one changes it is changed here rather than
|
||||
re-argued. Once built, the operational notes (how to test it, what bit)
|
||||
move to AGENTS.md and this file keeps only the design.
|
||||
Built on 2026-09-03. This is the design, decision by decision with the
|
||||
reason and what was rejected, so that when one changes it is changed here
|
||||
rather than re-argued. The operational half -- how to run it, what to press,
|
||||
what to produce on purpose -- is in AGENTS.md, where the rest of this
|
||||
project's working notes are.
|
||||
|
||||
## What it is, in one paragraph
|
||||
|
||||
@@ -258,12 +259,18 @@ rerun and its output committed (it needs network):
|
||||
codepoint dev-updater uses, and it must not drift from it, as the cog
|
||||
and the refresh arrow already must not.
|
||||
- `md-plus` U+F0415 -- create. Also dev-updater's.
|
||||
- `md-pencil` -- edit.
|
||||
- `md-content_save` -- save.
|
||||
- `md-file_outline` -- file rows.
|
||||
- `md-pencil` U+F03EB -- edit.
|
||||
- `md-content_save` U+F0193 -- save.
|
||||
- `md-file_outline` U+F0224 -- file rows.
|
||||
|
||||
The last three are verified against the Nerd Fonts cheat sheet when they
|
||||
are added, not copied from memory.
|
||||
All five were looked up in Nerd Fonts' own `glyphnames.json` rather than
|
||||
copied from memory, which is the check that a codepoint means the glyph its
|
||||
comment names.
|
||||
|
||||
**Where the folder button sits**: between the usage chart and the cog, so
|
||||
the header reads widest scope to narrowest and the cog stays at the end
|
||||
where every other screen in this app keeps it. Asked for in that order by
|
||||
Iris on 2026-09-03.
|
||||
|
||||
### 13. The render report moves, and the benches move with it
|
||||
|
||||
@@ -386,32 +393,6 @@ pure functions with tests.
|
||||
`~/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`.
|
||||
|
||||
## Testing
|
||||
|
||||
- **Server**: `./run-tests.sh`, `cargo clippy --all-targets`, `cargo fmt`.
|
||||
- **Local transport, by hand**: `./ui-sandbox.sh api
|
||||
"/setups/<id>/dir?path=~"` against the sandbox, whose `$HOME` is a
|
||||
throwaway tree it is fine to write into. The sandbox gets a small
|
||||
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
|
||||
|
||||
- Scan time for a 1 MiB source file on the emulator, and on the phone
|
||||
@@ -425,19 +406,6 @@ pure functions with tests.
|
||||
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
|
||||
|
||||
Each step leaves the app working and is one commit.
|
||||
|
||||
1. Server: `files.rs` with `list` and `read`, routes, tests. Half a day.
|
||||
2. App: icons, `Api.kt`, `FilesScreen` listing, `FileViewer`, the root
|
||||
and session wiring, the render-report move with the benches. A day.
|
||||
3. Server: `write`, `create_file`, `create_dir`, the stdin helper and
|
||||
`ship_attachment` onto it. Half a day.
|
||||
4. App: `FileEditor`, the create dialog, the conflict dialog. Half a day.
|
||||
5. Measurements above, the sandbox fixture, PLAN.md and AGENTS.md. Half a
|
||||
day.
|
||||
|
||||
## Later, deliberately not now
|
||||
|
||||
- Delete, rename and move. Destructive controls belong here eventually,
|
||||
|
||||
@@ -17,7 +17,18 @@ import org.json.JSONObject
|
||||
const val CONNECT_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
|
||||
@@ -68,7 +79,8 @@ fun <T> requestFromServer(
|
||||
detail.isNullOrEmpty() ->
|
||||
"Server returned HTTP ${connection.responseCode} for $path"
|
||||
else -> detail
|
||||
}
|
||||
},
|
||||
status = connection.responseCode,
|
||||
)
|
||||
}
|
||||
return readBody(connection)
|
||||
@@ -82,13 +94,13 @@ fun <T> requestFromServer(
|
||||
"Couldn't reach the server at ${settings.baseUrl} " +
|
||||
"(${e::class.simpleName}: ${e.message}) -- is ai-server running, and is " +
|
||||
"this device able to reach that address (WireGuard up)?",
|
||||
e,
|
||||
cause = e,
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
throw ApiException(
|
||||
"Reached ${settings.baseUrl}$path but couldn't read its response " +
|
||||
"(${e::class.simpleName}: ${e.message})",
|
||||
e,
|
||||
cause = e,
|
||||
)
|
||||
} finally {
|
||||
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). */
|
||||
fun fetchSessionFile(settings: ServerSettings, sessionId: String, name: String): ByteArray =
|
||||
requestFromServer(settings, "/sessions/$sessionId/files/$name", readTimeoutMs = 30000) {
|
||||
|
||||
@@ -38,7 +38,17 @@ import kotlinx.coroutines.withContext
|
||||
private sealed class 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()
|
||||
|
||||
@@ -198,14 +208,28 @@ fun AppRoot(
|
||||
// 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.
|
||||
key(here.summary.id) {
|
||||
// No imePadding here, for the reason above.
|
||||
SessionScreen(
|
||||
settings = current,
|
||||
summary = here.summary,
|
||||
onBack = goToMain,
|
||||
share = share,
|
||||
onShareTaken = { share = null },
|
||||
)
|
||||
// A Box so the explorer can be drawn *over* the session rather than instead of
|
||||
// it; the session stays composed underneath. No imePadding here, for the reason
|
||||
// above -- the explorer adds its own, since it has a text field.
|
||||
Box {
|
||||
SessionScreen(
|
||||
settings = current,
|
||||
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 ->
|
||||
Box(Modifier.imePadding()) {
|
||||
|
||||
@@ -64,7 +64,7 @@ suspend fun uploadPicked(
|
||||
} catch (e: java.io.IOException) {
|
||||
// Either side of the copy can fail; the message names the file, which is the
|
||||
// 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? =
|
||||
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> =
|
||||
mapOf(
|
||||
"kotlin" to Language.KOTLIN,
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
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 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,104 @@
|
||||
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>>,
|
||||
) {
|
||||
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 {
|
||||
val body = text.removeSuffix("\n")
|
||||
val lines = body.split('\n')
|
||||
val rules = language?.let { rulesOf(it) }
|
||||
val scanned = if (rules == null) emptyList() else scan(body, rules)
|
||||
return FileLines(lines, bucket(lines, scanned))
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,119 @@
|
||||
package com.example.aiapp
|
||||
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
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.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
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.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/** 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)
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* The gutter stays put while the text scrolls, so a line number is still there to read at the right
|
||||
* hand end of a long line. 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.
|
||||
*/
|
||||
@Composable
|
||||
fun FileViewer(lines: FileLines, modifier: Modifier = Modifier) {
|
||||
val style = codeStyle()
|
||||
val scroll = rememberScrollState()
|
||||
val gutter = gutterWidth(lines.size, style)
|
||||
// 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(modifier) {
|
||||
LazyColumn(Modifier.fillMaxWidth()) {
|
||||
items(lines.size) { index ->
|
||||
Row(verticalAlignment = Alignment.Top) {
|
||||
LineNumber(index + 1, gutter, style)
|
||||
Text(
|
||||
lines.line(index),
|
||||
style = style,
|
||||
softWrap = false,
|
||||
modifier = Modifier.horizontalScroll(scroll),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One line's number, right-aligned in the gutter.
|
||||
*
|
||||
* `onSurfaceVariant`, because it is not part of the file: it is this app numbering it, and giving
|
||||
* it the text's own colour would put it in the same voice as the code.
|
||||
*/
|
||||
@Composable
|
||||
fun LineNumber(number: Int, width: Dp, style: TextStyle) {
|
||||
Text(
|
||||
number.toString(),
|
||||
style = style,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.End,
|
||||
maxLines = 1,
|
||||
modifier = Modifier.width(width),
|
||||
)
|
||||
Spacer(Modifier.width(GUTTER_GAP))
|
||||
}
|
||||
|
||||
/**
|
||||
* 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,678 @@
|
||||
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
|
||||
|
||||
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 = loaded != null)
|
||||
}
|
||||
}
|
||||
|
||||
saveError?.let {
|
||||
Text(
|
||||
it,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
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 {
|
||||
val lines =
|
||||
remember(file.content, language) {
|
||||
FileLines.of(file.content, language)
|
||||
}
|
||||
FileViewer(lines)
|
||||
}
|
||||
// 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"
|
||||
@@ -667,15 +667,6 @@ private fun ImportableList(
|
||||
}
|
||||
}
|
||||
|
||||
/** A byte count at the coarsest unit that still says something, so rows stay comparable. */
|
||||
private fun humanSize(bytes: Long): String? =
|
||||
when {
|
||||
bytes <= 0L -> null
|
||||
bytes >= 1_000_000L -> "${bytes / 1_000_000L} MB"
|
||||
bytes >= 1_000L -> "${bytes / 1_000L} kB"
|
||||
else -> "$bytes B"
|
||||
}
|
||||
|
||||
/** What this session is: the measurements, in the order they are worth knowing. */
|
||||
private fun statsOf(session: Importable): String =
|
||||
listOfNotNull(
|
||||
|
||||
@@ -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
|
||||
* 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
|
||||
* `app/build-icon-font.sh`'s output -- eleven glyphs, 2.1 KB, subset out of the 3 MB symbols font
|
||||
* and committed -- so the codepoints below are resolved by an asset in the APK and cannot come back
|
||||
* as tofu. Adding one means adding its codepoint in *both* places; a codepoint here that the script
|
||||
* did not subset is a glyph that silently isn't there.
|
||||
* `app/build-icon-font.sh`'s output -- seventeen glyphs, 2.8 KB, subset out of the 3 MB symbols
|
||||
* font and committed -- so the codepoints below are resolved by an asset in the APK and cannot come
|
||||
* back as tofu. Adding one means adding its codepoint in *both* places; a codepoint here that the
|
||||
* 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.
|
||||
* 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)
|
||||
|
||||
/**
|
||||
* `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.
|
||||
*
|
||||
|
||||
@@ -236,6 +236,8 @@ fun SessionScreen(
|
||||
settings: ServerSettings,
|
||||
summary: SessionSummary,
|
||||
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]. */
|
||||
share: ShareRequest? = null,
|
||||
/** Said once [share] has been attached here, so it is not attached again. */
|
||||
@@ -1225,6 +1227,60 @@ fun SessionScreen(
|
||||
// interpolated, so there is nothing for a dropped frame to interrupt), so it is what both
|
||||
// places below fall back to.
|
||||
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()) {
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
Row(
|
||||
@@ -1266,74 +1322,36 @@ fun SessionScreen(
|
||||
// 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
|
||||
// "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 {
|
||||
// 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(
|
||||
USAGE_GLYPH,
|
||||
"Usage",
|
||||
{ usageOpen = true },
|
||||
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
|
||||
// own row. The name is the whole of what it holds today, which is why it is a
|
||||
// cog
|
||||
@@ -1948,6 +1966,7 @@ fun SessionScreen(
|
||||
settingsOpen = false
|
||||
},
|
||||
onDismiss = { settingsOpen = false },
|
||||
onCopyRenderReport = copyRenderReport,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,6 +56,11 @@ fun SessionSettingsDialog(
|
||||
title: String,
|
||||
onRenamed: (String) -> 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()
|
||||
var name by remember(sessionId) { mutableStateOf(title) }
|
||||
@@ -258,6 +263,20 @@ fun SessionSettingsDialog(
|
||||
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
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.example.aiapp
|
||||
|
||||
/**
|
||||
* A byte count at the coarsest unit that still says something, so rows stay comparable.
|
||||
*
|
||||
* Null at zero and below, because the two screens that ask disagree about what nothing means and
|
||||
* only the caller knows: a transcript of no bytes is a measurement that has not happened, and is
|
||||
* left 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.
|
||||
*
|
||||
* Its own file rather than the import screen's, where it started: two screens now say a size, and a
|
||||
* second copy of these thresholds is how one list comes to call 4 kB what the other calls 4096 B.
|
||||
*/
|
||||
fun humanSize(bytes: Long): String? =
|
||||
when {
|
||||
bytes <= 0L -> null
|
||||
bytes >= 1_000_000L -> "${bytes / 1_000_000L} MB"
|
||||
bytes >= 1_000L -> "${bytes / 1_000L} kB"
|
||||
else -> "$bytes B"
|
||||
}
|
||||
@@ -93,7 +93,7 @@ class Sse(private val settings: ServerSettings) {
|
||||
if (!closed) {
|
||||
throw ApiException(
|
||||
"Can't reach the server -- retrying. (${e.message ?: e::class.simpleName})",
|
||||
e,
|
||||
cause = e,
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,73 @@
|
||||
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",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@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"))
|
||||
}
|
||||
}
|
||||
@@ -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+F009A # md-bell
|
||||
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
|
||||
)
|
||||
|
||||
|
||||
+17
-18
@@ -4,8 +4,9 @@
|
||||
# reply that is still arriving cheap", the way transcript-bench.sh is for
|
||||
# scrolling one that has settled.
|
||||
#
|
||||
# ./stream-bench.sh FILE restart the app, open the first
|
||||
# session, stream FILE into it, report
|
||||
# ./stream-bench.sh FILE restart the app, open the session the
|
||||
# sandbox lists first, stream FILE into
|
||||
# it, report
|
||||
# ./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
|
||||
@@ -17,6 +18,7 @@
|
||||
set -eu
|
||||
cd "$(dirname "$0")"
|
||||
. ./android-env.sh >/dev/null 2>&1
|
||||
. ./bench-lib.sh
|
||||
|
||||
keep=""
|
||||
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"])')
|
||||
|
||||
if [ -z "$keep" ]; then
|
||||
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 3000 --do 'tap 500 545' -o /tmp/bench-open.txt >/dev/null 2>&1
|
||||
sleep 3
|
||||
title=$(first_session_title)
|
||||
[ -n "$title" ] || { echo "stream-bench: no sessions to open" >&2; exit 1; }
|
||||
open_session "$title"
|
||||
fi
|
||||
|
||||
# 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
|
||||
# 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
|
||||
# to be.
|
||||
jump=$(ui-trace record -d 1200 -o /tmp/bench-jump.txt >/dev/null 2>&1
|
||||
ui-trace show /tmp/bench-jump.txt -m 'Jump to latest' --field box |
|
||||
grep -o '[0-9]*,[0-9]*\.\.[0-9]*,[0-9]*' | tail -1)
|
||||
if [ -n "$jump" ]; then
|
||||
x=$(echo "$jump" | awk -F'[,.]' '{print int(($1 + $4) / 2)}')
|
||||
y=$(echo "$jump" | awk -F'[,.]' '{print int(($2 + $5) / 2)}')
|
||||
ui-trace record -d 1500 --do "tap $x $y" -o /tmp/bench-tolatest.txt >/dev/null 2>&1
|
||||
# to be. Looked for before it is pressed rather than pressed blindly: a tap
|
||||
# by name fails the whole run when its label is missing, and missing is the
|
||||
# ordinary case here.
|
||||
ui-trace record -d 1200 -o /tmp/bench-jump.txt >/dev/null 2>&1
|
||||
if ui-trace show /tmp/bench-jump.txt -m 'Jump to latest' --field box | grep -q '[0-9],[0-9]'; then
|
||||
ui-trace record -d 2500 --do "tap 'Jump to latest'" --do 'wait 1000' \
|
||||
-o /tmp/bench-tolatest.txt >/dev/null
|
||||
fi
|
||||
|
||||
# The first tap 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
|
||||
# The first copy resets the report's window; see transcript-bench.sh.
|
||||
copy_render_report /tmp/bench-reset.txt
|
||||
adb logcat -c
|
||||
|
||||
./ui-sandbox.sh send "$sid" "@$file" >/dev/null
|
||||
@@ -90,6 +89,6 @@ while [ "$i" -lt 180 ]; do
|
||||
done
|
||||
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
|
||||
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
|
||||
# starts from a number instead of re-inventing this loop in /tmp.
|
||||
#
|
||||
# ./transcript-bench.sh restart the app, open the first
|
||||
# session, scroll, report
|
||||
# ./transcript-bench.sh restart the app, open the session the
|
||||
# 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 -n 4 swipe cycles (default 6; one cycle is
|
||||
# two swipes back and two forward)
|
||||
@@ -16,33 +17,35 @@
|
||||
# went. Compare two runs of this with the same gestures, not one run
|
||||
# against a phone.
|
||||
#
|
||||
# Coordinates are the ai-app AVD's 1080x2400 screen: the report button in
|
||||
# the session top bar, and the first row of the session list.
|
||||
# The swipes are still coordinates, because a gesture across a scrolling
|
||||
# area is a distance rather than a control. Every *control* is pressed by
|
||||
# name -- see bench-lib.sh.
|
||||
set -eu
|
||||
cd "$(dirname "$0")"
|
||||
. ./android-env.sh >/dev/null 2>&1
|
||||
. ./bench-lib.sh
|
||||
|
||||
keep=""
|
||||
cycles=6
|
||||
while getopts kn: flag; do
|
||||
session=""
|
||||
while getopts kn:s: flag; do
|
||||
case $flag in
|
||||
k) keep=1 ;;
|
||||
n) cycles=$OPTARG ;;
|
||||
s) session=$OPTARG ;;
|
||||
*) exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ -z "$keep" ]; then
|
||||
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 3000 --do 'tap 500 545' -o /tmp/bench-open.txt >/dev/null 2>&1
|
||||
sleep 3
|
||||
[ -n "$session" ] || session=$(first_session_title)
|
||||
[ -n "$session" ] || { echo "transcript-bench: no sessions to open" >&2; exit 1; }
|
||||
open_session "$session"
|
||||
fi
|
||||
|
||||
# First tap resets the report's "work since last copied" window, so the
|
||||
# numbers cover exactly the swipes between the two taps.
|
||||
ui-trace record -d 2000 --do 'tap 723 205' -o /tmp/bench-reset.txt >/dev/null 2>&1
|
||||
# The first copy resets the report's "work since last copied" window, so the
|
||||
# numbers cover exactly the swipes between the two.
|
||||
copy_render_report /tmp/bench-reset.txt
|
||||
adb logcat -c
|
||||
|
||||
DO=""
|
||||
@@ -56,7 +59,7 @@ while [ "$i" -lt "$cycles" ]; do
|
||||
done
|
||||
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
|
||||
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
|
||||
Reference in new issue
Block a user