Merge remote-tracking branch 'origin/main'

# Conflicts:
#	app/androidApp/src/main/kotlin/com/example/aiapp/Sizes.kt
This commit is contained in:
iris committed 2026-09-04 15:03:37 -04:00
commit e3e02d55f7
29 files changed
+2737 -479

No files matched your search

@@ -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,
@@ -182,6 +201,8 @@ private val FENCE_LANGUAGES: Map<String, Language> =
"toml" to Language.TOML,
"fish" to Language.FISH,
"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 {
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()
return buildAnnotatedString {
append(code)
@@ -1,11 +1,14 @@
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
* 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.
*
* 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 {
C,
@@ -19,6 +22,7 @@ enum class Language {
JAVASCRIPT,
JSON,
KOTLIN,
MARKDOWN,
PERL,
PHP,
PYTHON,
@@ -83,8 +87,23 @@ enum class Attributes {
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 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
* 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.
*
@@ -235,6 +235,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. */
@@ -1347,6 +1349,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(
@@ -1388,74 +1444,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
@@ -2092,6 +2110,7 @@ fun SessionScreen(
settingsOpen = false
},
onDismiss = { settingsOpen = false },
onCopyRenderReport = copyRenderReport,
)
}
}
@@ -64,6 +64,11 @@ fun SessionSettingsDialog(
cachedBytes: Long?,
onReload: () -> 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) }
@@ -304,6 +309,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
@@ -1,14 +1,17 @@
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
* rather than a figure: an import row with no size says nothing about size, and a transcript cache
* holding nothing says "nothing cached".
* Null at zero and below, because the 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; 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
* already one variant too many -- `ModelsScreen`'s `gigabytes` writes a download's size to two
* Its own file rather than the import screen's, where it started: three 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.
* `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.
*/
fun humanSize(bytes: Long): String? =
@@ -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 {