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

@@ -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('/')