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) {
|
||||
|
||||
Reference in new issue
Block a user