The file explorer on the phone
The other half of EXPLORER.md: a folder button on the session header opens the machine's filesystem, starting where the session works. It draws **over** the session in the same `Box`, so the session under it stays composed -- 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 it (editor, viewer, directory, parent) and only closes from where it opened; the platform gesture, the button and the swipe all go through the one function, so they cannot mean different things. 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 a single `Text` measures all of it to draw a screenful. Lines do not wrap and share one horizontal scroll, so a logical line is a visual line and the gutter cannot come to number the wrong text; the gutter's width is measured from the digit count of the line count in the style it is drawn in. The editor is a `BasicTextField` with a `VisualTransformation` carrying the scanner's spans, which is the one Compose API that colours a field's own text rather than replacing the field. `fileLanguage` reads the same table `fenceLanguage` does, so a language added for fences is a language added for files. A file that changed on the machine while it was open here refuses to be overwritten and asks, with what each of the three answers costs. That is the ordinary case, not the exotic one: an agent editing the file somebody is reading is what this whole feature is for. The speedometer moves off the header into the session settings dialog, where the session's other about-the-session controls are, and the folder takes a place between the usage chart and the cog -- widest scope to narrowest, cog at the end, as Iris asked. Both benchmark scripts move onto `ui-trace`'s new tap-by-label action in the same change, so the render report is never unavailable and never pressed at a coordinate that has stopped meaning anything; `app/bench-lib.sh` is what they share, and `grep -n "tap [0-9]" app/*.sh` is the check. Exercised on the emulator against the sandbox's new fixture tree, with a screenshot or a ui-trace for each: the listing (dotfiles, directories first, a symlink to a directory sorted with them, a name with a tab in it), a highlighted file, binary, too big, a permission error, editing and saving, the 409 and its Overwrite, back with unsaved edits, creating a name that exists, creating one that does not and landing in the editor, an empty directory, and `..` above the directory the session opened in. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
4a9c547293
commit
db55ed4a8f
22 files changed
+1647
-161
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()
|
||||
|
||||
@@ -206,9 +216,20 @@ fun AppRoot(
|
||||
settings = current,
|
||||
summary = here.summary,
|
||||
onBack = goToMain,
|
||||
onFiles = { screen = here.copy(files = it) },
|
||||
share = share,
|
||||
onShareTaken = { share = null },
|
||||
)
|
||||
// Over the session, in the same Box, with the session still composed beneath
|
||||
// it. 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 ->
|
||||
|
||||
@@ -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,681 @@
|
||||
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 three places (the
|
||||
// button, the platform gesture, the swipe) which must all 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()
|
||||
// Innermost wins, so this takes the gesture before the session's own swipe-to-list
|
||||
// does, and the reader steps back through the explorer rather than out of it.
|
||||
.swipeBack(::back)
|
||||
) {
|
||||
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"))
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user