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 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 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 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 navigates on a resolved absolute path. The phone also resolves `~` through the
that, and a `~` the session was spawned with is shown as what it turned out same route, then shortens that directory and every path beneath it back to
to be. The phone never resolves `..` itself. 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 ### 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 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 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 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 clears `files` and returns to the session. Android back instead walks toward the
directory until the root and only then returns to the session. The `..` row session's project directory: upward to the common ancestor, then down one path
remains as the visible, tappable form of the same directory movement. An editor segment per press, and at the project it returns to the session. This makes
with unsaved changes asks before either route discards them. "Back returns; it Back from `/etc` visibly travel through `/`, `/home`, and onward to a project
does not exit." 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 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 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 * 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 * 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 * directory. In a directory, Android back walks toward the session's project directory and closes
* button closes the explorer and returns to the session. * 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 * 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 * 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) { 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] = LoadState.Loading
listings[path] = listings[path] =
try { 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) BackHandler(onBack = ::systemBack)
Box( Box(
@@ -160,12 +180,13 @@ fun FilesScreen(settings: ServerSettings, target: FilesTarget, onClose: () -> Un
when (val spot = here) { when (val spot = here) {
is Spot.Dir -> { is Spot.Dir -> {
val state = listings[spot.path] ?: LoadState.Loading val state = listings[spot.path] ?: LoadState.Loading
// The resolved path once there is one: a directory opened as `~` is called what // Navigate with the resolved path, but name anything under the machine's home
// it turned out to be, not what it was asked for. // the way somebody working there would write it.
val at = (state as? LoadState.Loaded)?.value?.path ?: spot.path val at = (state as? LoadState.Loaded)?.value?.path ?: spot.path
val shownAt = tildePath(at, homeDirectory)
FilesHeader( FilesHeader(
title = baseName(at), title = baseName(shownAt),
path = at, path = shownAt,
machine = target.setupName, machine = target.setupName,
onBack = { leave(UnsavedDestination.Session) }, onBack = { leave(UnsavedDestination.Session) },
) { ) {
@@ -192,6 +213,7 @@ fun FilesScreen(settings: ServerSettings, target: FilesTarget, onClose: () -> Un
path = spot.path, path = spot.path,
name = baseName(spot.path), name = baseName(spot.path),
editing = editing, editing = editing,
homeDirectory = homeDirectory,
onEditing = { editing = it }, onEditing = { editing = it },
onDirty = { dirty = it }, onDirty = { dirty = it },
onBack = { leave(UnsavedDestination.Directory) }, onBack = { leave(UnsavedDestination.Directory) },
@@ -397,6 +419,7 @@ private fun ColumnScope.DocPane(
path: String, path: String,
name: String, name: String,
editing: Boolean, editing: Boolean,
homeDirectory: String?,
onEditing: (Boolean) -> Unit, onEditing: (Boolean) -> Unit,
onDirty: (Boolean) -> Unit, onDirty: (Boolean) -> Unit,
onBack: () -> 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 (editing) {
if (saving) { if (saving) {
GlyphSpinner("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. */ /** What a path names: its last segment, with `/` naming itself. */
internal fun baseName(path: String): String { internal fun baseName(path: String): String {
val trimmed = path.trimEnd('/') 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"))
}
}