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
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user