Navigate explorer back toward project

This commit is contained in:
iris committed 2026-09-12 19:38:18 -04:00
1 parent 2ff0b13950
commit 62cb6c91d5
3 files changed
+119 -26

No files matched your search

+11 -8
View File
@@ -82,9 +82,10 @@ writing are fixed scripts; the phone chooses only the path and the bytes.
Same rule as `POST /sessions/{id}/cwd`, with the same wording, because where
a relative path would be depends on something the reader cannot see. Every
listing answers with `pwd -P` of the directory it listed, so the phone
navigates on a resolved absolute path — the parent is a string operation on
that, and a `~` the session was spawned with is shown as what it turned out
to be. The phone never resolves `..` itself.
navigates on a resolved absolute path. The phone also resolves `~` through the
same route, then shortens that directory and every path beneath it back to
tilde notation for display; it never guesses where a local or ssh user's home
is. The phone never resolves `..` itself.
### 5. A read is capped and typed, and every state it can be in has a word
@@ -216,11 +217,13 @@ the session stays composed under it: its event stream keeps flowing, its
scroll position and draft stay where they were, and returning from a file
costs nothing. From an open file, both the header's back button and Android back
return to its containing directory. From a directory, the header's back button
clears `files` and returns to the session, while Android back walks to the parent
directory until the root and only then returns to the session. The `..` row
remains as the visible, tappable form of the same directory movement. An editor
with unsaved changes asks before either route discards them. "Back returns; it
does not exit."
clears `files` and returns to the session. Android back instead walks toward the
session's project directory: upward to the common ancestor, then down one path
segment per press, and at the project it returns to the session. This makes
Back from `/etc` visibly travel through `/`, `/home`, and onward to a project
under `~/repos`, rather than leading away from it. The `..` row remains explicit
parent navigation. An editor with unsaved changes asks before either route
discards them. "Back returns; it does not exit."
Rejected: a `Screen.Files` beside `Screen.Session`. Every route back from a
leaf screen goes to Main today, and a session disposed and re-created on each
@@ -82,8 +82,8 @@ private enum class UnsavedDestination {
*
* Drawn **over** the session rather than instead of it (see [AppRoot]), so its event stream keeps
* flowing and coming back from a file costs nothing. Both back controls return from a file to its
* directory. In a directory, Android back walks up the directory tree while the header's back
* button closes the explorer and returns to the session.
* directory. In a directory, Android back walks toward the session's project directory and closes
* the explorer once it gets there; the header's back button closes it immediately.
*
* Every directory that has been visited is kept for as long as this is open; the refresh glyph is
* how one gets asked again on purpose, and creating something refetches the directory it was
@@ -124,18 +124,9 @@ fun FilesScreen(settings: ServerSettings, target: FilesTarget, onClose: () -> Un
}
}
fun systemBack() {
when (val spot = here) {
is Spot.Doc -> leave(UnsavedDestination.Directory)
is Spot.Dir -> {
val path = (listings[spot.path] as? LoadState.Loaded)?.value?.path ?: spot.path
parentOf(path)?.let { go(Spot.Dir(it)) } ?: onClose()
}
}
}
suspend fun load(path: String, again: Boolean) {
if (!again && listings[path] is LoadState.Loaded) return
val existing = listings[path]
if (!again && (existing is LoadState.Loaded || existing is LoadState.Loading)) return
listings[path] = LoadState.Loading
listings[path] =
try {
@@ -147,6 +138,35 @@ fun FilesScreen(settings: ServerSettings, target: FilesTarget, onClose: () -> Un
}
}
val projectDirectory = (listings[target.start] as? LoadState.Loaded)?.value?.path
val homeDirectory =
if (target.start == "~") projectDirectory
else (listings["~"] as? LoadState.Loaded)?.value?.path
fun systemBack() {
when (val spot = here) {
is Spot.Doc -> leave(UnsavedDestination.Directory)
is Spot.Dir -> {
val path = (listings[spot.path] as? LoadState.Loaded)?.value?.path ?: spot.path
when {
path == projectDirectory || path == target.start -> onClose()
projectDirectory != null ->
nextDirectoryToward(path, projectDirectory)?.let { go(Spot.Dir(it)) }
?: onClose()
else -> parentOf(path)?.let { go(Spot.Dir(it)) } ?: onClose()
}
}
}
}
// A file link can open without visiting the project first, but Back still needs to know where
// the project is. Home is likewise resolved by the machine rather than guessed on the phone;
// it is what lets every path beneath it be displayed with `~`, including over ssh.
LaunchedEffect(target.setup, target.start) {
if (target.file != null) load(target.start, again = false)
if (target.start != "~") load("~", again = false)
}
BackHandler(onBack = ::systemBack)
Box(
@@ -160,12 +180,13 @@ fun FilesScreen(settings: ServerSettings, target: FilesTarget, onClose: () -> Un
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.
// Navigate with the resolved path, but name anything under the machine's home
// the way somebody working there would write it.
val at = (state as? LoadState.Loaded)?.value?.path ?: spot.path
val shownAt = tildePath(at, homeDirectory)
FilesHeader(
title = baseName(at),
path = at,
title = baseName(shownAt),
path = shownAt,
machine = target.setupName,
onBack = { leave(UnsavedDestination.Session) },
) {
@@ -192,6 +213,7 @@ fun FilesScreen(settings: ServerSettings, target: FilesTarget, onClose: () -> Un
path = spot.path,
name = baseName(spot.path),
editing = editing,
homeDirectory = homeDirectory,
onEditing = { editing = it },
onDirty = { dirty = it },
onBack = { leave(UnsavedDestination.Directory) },
@@ -397,6 +419,7 @@ private fun ColumnScope.DocPane(
path: String,
name: String,
editing: Boolean,
homeDirectory: String?,
onEditing: (Boolean) -> Unit,
onDirty: (Boolean) -> Unit,
onBack: () -> Unit,
@@ -470,7 +493,12 @@ private fun ColumnScope.DocPane(
}
}
FilesHeader(title = name, path = path, machine = target.setupName, onBack = onBack) {
FilesHeader(
title = name,
path = tildePath(path, homeDirectory),
machine = target.setupName,
onBack = onBack,
) {
if (editing) {
if (saving) {
GlyphSpinner("Saving")
@@ -718,6 +746,35 @@ internal fun parentOf(path: String): String? {
}
}
/**
* The next directory on the filesystem path from [current] to [destination], or null when there.
*
* Moving between two branches first walks upward to their common ancestor. Once [current] is that
* ancestor, the next press walks one segment down toward [destination]. Both paths are answers from
* the machine, so they are absolute and have no symlinks or `..` left to resolve here.
*/
internal fun nextDirectoryToward(current: String, destination: String): String? {
val here = current.trimEnd('/').ifEmpty { "/" }
val there = destination.trimEnd('/').ifEmpty { "/" }
if (here == there) return null
val beneathHere = if (here == "/") there.startsWith('/') else there.startsWith("$here/")
if (!beneathHere) return parentOf(here)
val next = there.removePrefix(here).trimStart('/').substringBefore('/')
return join(here, next)
}
/** A path as somebody on [home] writes it, leaving paths outside that home unchanged. */
internal fun tildePath(path: String, home: String?): String {
val at = path.trimEnd('/').ifEmpty { "/" }
val resolvedHome = home?.trimEnd('/')?.ifEmpty { "/" } ?: return at
return when {
at == resolvedHome -> "~"
resolvedHome != "/" && at.startsWith("$resolvedHome/") ->
"~${at.removePrefix(resolvedHome)}"
else -> at
}
}
/** What a path names: its last segment, with `/` naming itself. */
internal fun baseName(path: String): String {
val trimmed = path.trimEnd('/')
@@ -0,0 +1,33 @@
package com.example.aiapp
import kotlin.test.Test
import kotlin.test.assertEquals
class FilesNavigationTest {
@Test
fun `back walks through the common ancestor toward the project`() {
val project = "/home/bob/repos/project"
assertEquals("/", nextDirectoryToward("/etc", project))
assertEquals("/home", nextDirectoryToward("/", project))
assertEquals("/home/bob", nextDirectoryToward("/home", project))
assertEquals("/home/bob/repos", nextDirectoryToward("/home/bob", project))
assertEquals(project, nextDirectoryToward("/home/bob/repos", project))
assertEquals(null, nextDirectoryToward(project, project))
}
@Test
fun `back leaves a project descendant one directory at a time`() {
assertEquals(
"/home/bob/repos/project/src",
nextDirectoryToward("/home/bob/repos/project/src/main", "/home/bob/repos/project"),
)
}
@Test
fun `paths inside the machine home use tilde notation`() {
assertEquals("~", tildePath("/home/bob", "/home/bob"))
assertEquals("~/repos/project", tildePath("/home/bob/repos/project", "/home/bob/"))
assertEquals("/home/bobby/project", tildePath("/home/bobby/project", "/home/bob"))
assertEquals("/etc", tildePath("/etc", "/home/bob"))
}
}