From 62cb6c91d52fc65e2af94f58433c89f80a8f66a8 Mon Sep 17 00:00:00 2001 From: iris <2+iris@noreply.localhost> Date: Sat, 12 Sep 2026 19:38:18 -0400 Subject: [PATCH] Navigate explorer back toward project --- EXPLORER.md | 19 ++-- .../kotlin/com/example/aiapp/FilesScreen.kt | 93 +++++++++++++++---- .../com/example/aiapp/FilesNavigationTest.kt | 33 +++++++ 3 files changed, 119 insertions(+), 26 deletions(-) create mode 100644 app/androidApp/src/test/kotlin/com/example/aiapp/FilesNavigationTest.kt diff --git a/EXPLORER.md b/EXPLORER.md index 275de56..fff55e6 100644 --- a/EXPLORER.md +++ b/EXPLORER.md @@ -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 diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/FilesScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/FilesScreen.kt index eda917e..00687c2 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/FilesScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/FilesScreen.kt @@ -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('/') diff --git a/app/androidApp/src/test/kotlin/com/example/aiapp/FilesNavigationTest.kt b/app/androidApp/src/test/kotlin/com/example/aiapp/FilesNavigationTest.kt new file mode 100644 index 0000000..bde8818 --- /dev/null +++ b/app/androidApp/src/test/kotlin/com/example/aiapp/FilesNavigationTest.kt @@ -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")) + } +}