Navigate explorer back toward project
This commit is contained in:
1 parent
2ff0b13950
commit
62cb6c91d5
3 files changed
+119
-26
No files matched your search
+11
-8
@@ -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"))
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user