The file explorer on the phone
The other half of EXPLORER.md: a folder button on the session header opens the machine's filesystem, starting where the session works. It draws **over** the session in the same `Box`, so the session under it stays composed -- its event stream keeps flowing, its draft and scroll position stay where they were, and coming back from a file costs nothing. Back steps one level inside it (editor, viewer, directory, parent) and only closes from where it opened; the platform gesture, the button and the swipe all go through the one function, so they cannot mean different things. The viewer is a `LazyColumn` of lines rather than one `Text`, because text layout is linear in the text and a twenty-thousand-line file in a single `Text` measures all of it to draw a screenful. Lines do not wrap and share one horizontal scroll, so a logical line is a visual line and the gutter cannot come to number the wrong text; the gutter's width is measured from the digit count of the line count in the style it is drawn in. The editor is a `BasicTextField` with a `VisualTransformation` carrying the scanner's spans, which is the one Compose API that colours a field's own text rather than replacing the field. `fileLanguage` reads the same table `fenceLanguage` does, so a language added for fences is a language added for files. A file that changed on the machine while it was open here refuses to be overwritten and asks, with what each of the three answers costs. That is the ordinary case, not the exotic one: an agent editing the file somebody is reading is what this whole feature is for. The speedometer moves off the header into the session settings dialog, where the session's other about-the-session controls are, and the folder takes a place between the usage chart and the cog -- widest scope to narrowest, cog at the end, as Iris asked. Both benchmark scripts move onto `ui-trace`'s new tap-by-label action in the same change, so the render report is never unavailable and never pressed at a coordinate that has stopped meaning anything; `app/bench-lib.sh` is what they share, and `grep -n "tap [0-9]" app/*.sh` is the check. Exercised on the emulator against the sandbox's new fixture tree, with a screenshot or a ui-trace for each: the listing (dotfiles, directories first, a symlink to a directory sorted with them, a name with a tab in it), a highlighted file, binary, too big, a permission error, editing and saving, the 409 and its Overwrite, back with unsaved edits, creating a name that exists, creating one that does not and landing in the editor, an empty directory, and `..` above the directory the session opened in. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
4a9c547293
commit
db55ed4a8f
22 files changed
+1647
-161
No files matched your search
@@ -0,0 +1,681 @@
|
||||
package com.example.aiapp
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateMapOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* Which machine's files to show, and where to start.
|
||||
*
|
||||
* A **setup**, not a session: a filesystem is a property of a machine, and a session only says
|
||||
* where it was working. That is what makes a second way in -- from the setups tab, say -- one more
|
||||
* caller rather than any new code here.
|
||||
*/
|
||||
data class FilesTarget(val setup: String, val setupName: String, val start: String)
|
||||
|
||||
/** Where the explorer is: in a directory, or in one file. */
|
||||
private sealed class Spot(val path: String) {
|
||||
class Dir(path: String) : Spot(path)
|
||||
|
||||
class Doc(path: String) : Spot(path)
|
||||
}
|
||||
|
||||
/**
|
||||
* The files on the machine a session runs on: browse them, read one, change one.
|
||||
*
|
||||
* Drawn **over** the session rather than instead of it (see [AppRoot]), so its event stream keeps
|
||||
* flowing, its draft and scroll position stay where they were, and coming back from a file costs
|
||||
* nothing. Back steps one level inside here -- editor to viewer, viewer to the directory it came
|
||||
* from, directory to the one above it -- and only closes from where it opened.
|
||||
*
|
||||
* Every directory that has been visited is kept for as long as this is open, so stepping back is
|
||||
* instant; the refresh glyph is how a directory gets asked again on purpose, and creating something
|
||||
* refetches the directory it was created in, since that is the one thing that changed.
|
||||
*/
|
||||
@Composable
|
||||
fun FilesScreen(settings: ServerSettings, target: FilesTarget, onClose: () -> Unit) {
|
||||
val scope = rememberCoroutineScope()
|
||||
var stack by remember { mutableStateOf(listOf<Spot>(Spot.Dir(target.start))) }
|
||||
val listings = remember { mutableStateMapOf<String, LoadState<Listing>>() }
|
||||
var creating by remember { mutableStateOf(false) }
|
||||
// Edit mode and whether anything has been typed live here rather than in the pane below,
|
||||
// because they are what back has to know about -- and back arrives from three places (the
|
||||
// button, the platform gesture, the swipe) which must all mean the same thing.
|
||||
var editing by remember { mutableStateOf(false) }
|
||||
var dirty by remember { mutableStateOf(false) }
|
||||
var askUnsaved by remember { mutableStateOf(false) }
|
||||
|
||||
val here = stack.last()
|
||||
|
||||
fun go(spot: Spot) {
|
||||
editing = false
|
||||
dirty = false
|
||||
stack = stack + spot
|
||||
}
|
||||
|
||||
fun back() {
|
||||
when {
|
||||
editing && dirty -> askUnsaved = true
|
||||
editing -> editing = false
|
||||
stack.size > 1 -> {
|
||||
stack = stack.dropLast(1)
|
||||
editing = false
|
||||
dirty = false
|
||||
}
|
||||
else -> onClose()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun load(path: String, again: Boolean) {
|
||||
if (!again && listings[path] is LoadState.Loaded) return
|
||||
listings[path] = LoadState.Loading
|
||||
listings[path] =
|
||||
try {
|
||||
withContext(Dispatchers.IO) {
|
||||
LoadState.Loaded(fetchDir(settings, target.setup, path))
|
||||
}
|
||||
} catch (e: ApiException) {
|
||||
LoadState.failed(e)
|
||||
}
|
||||
}
|
||||
|
||||
BackHandler(onBack = ::back)
|
||||
|
||||
Box(
|
||||
Modifier.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.background)
|
||||
// The session under this deliberately takes no keyboard inset (see SessionScreen's
|
||||
// layout note), so the explorer adds its own -- otherwise the editor types under the
|
||||
// keyboard.
|
||||
.imePadding()
|
||||
// Innermost wins, so this takes the gesture before the session's own swipe-to-list
|
||||
// does, and the reader steps back through the explorer rather than out of it.
|
||||
.swipeBack(::back)
|
||||
) {
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
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.
|
||||
val at = (state as? LoadState.Loaded)?.value?.path ?: spot.path
|
||||
FilesHeader(
|
||||
title = baseName(at),
|
||||
path = at,
|
||||
machine = target.setupName,
|
||||
onBack = ::back,
|
||||
) {
|
||||
GlyphButton(
|
||||
REFRESH_GLYPH,
|
||||
"Refresh this directory",
|
||||
{ scope.launch { load(spot.path, again = true) } },
|
||||
enabled = state !is LoadState.Loading,
|
||||
)
|
||||
GlyphButton(
|
||||
PLUS_GLYPH,
|
||||
"Create here",
|
||||
{ creating = true },
|
||||
enabled = state is LoadState.Loaded,
|
||||
)
|
||||
}
|
||||
LaunchedEffect(spot.path) { load(spot.path, again = false) }
|
||||
DirectoryBody(state, onOpen = ::go)
|
||||
}
|
||||
is Spot.Doc ->
|
||||
DocPane(
|
||||
settings = settings,
|
||||
target = target,
|
||||
path = spot.path,
|
||||
name = baseName(spot.path),
|
||||
editing = editing,
|
||||
onEditing = { editing = it },
|
||||
onDirty = { dirty = it },
|
||||
onBack = ::back,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (askUnsaved) {
|
||||
UnsavedDialog(
|
||||
onDiscard = {
|
||||
askUnsaved = false
|
||||
editing = false
|
||||
dirty = false
|
||||
},
|
||||
onCancel = { askUnsaved = false },
|
||||
)
|
||||
}
|
||||
|
||||
val dir = here as? Spot.Dir
|
||||
val listing = (listings[dir?.path] as? LoadState.Loaded)?.value
|
||||
if (creating && dir != null && listing != null) {
|
||||
CreateDialog(
|
||||
settings = settings,
|
||||
setup = target.setup,
|
||||
directory = listing.path,
|
||||
onDismiss = { creating = false },
|
||||
onCreated = { path, isDirectory ->
|
||||
creating = false
|
||||
scope.launch {
|
||||
// The directory it was created in is the one thing that changed, so that is
|
||||
// what gets asked again -- not the whole stack.
|
||||
load(dir.path, again = true)
|
||||
// A new file has nothing to look at, so it opens where it can be filled in.
|
||||
if (!isDirectory) {
|
||||
go(Spot.Doc(path))
|
||||
editing = true
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The row every view in here has at the top: back, what this is, and what acts on it.
|
||||
*
|
||||
* The path is truncated in the middle when it will not fit, because both ends carry something the
|
||||
* reader needs -- the machine and the top of the tree at one end, the file at the other -- and it
|
||||
* is the longest paths, the ones being read most closely, that get cut.
|
||||
*/
|
||||
@Composable
|
||||
private fun FilesHeader(
|
||||
title: String,
|
||||
path: String,
|
||||
machine: String,
|
||||
onBack: () -> Unit,
|
||||
actions: @Composable () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
|
||||
) {
|
||||
GlyphButton(BACK_GLYPH, "Back", onBack)
|
||||
Spacer(Modifier.width(GLYPH_BUTTON_MARGIN))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(title, style = MaterialTheme.typography.titleMedium, maxLines = 1)
|
||||
Text(
|
||||
"$machine · $path",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.MiddleEllipsis,
|
||||
)
|
||||
}
|
||||
Row { actions() }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What is in a directory.
|
||||
*
|
||||
* A listing that failed says why, in the machine's own words, where the rows would be -- never an
|
||||
* empty list, which is what "there is nothing here" looks like and is the one wrong answer that
|
||||
* looks like a right one.
|
||||
*/
|
||||
@Composable
|
||||
private fun ColumnScope.DirectoryBody(state: LoadState<Listing>, onOpen: (Spot) -> Unit) {
|
||||
when (state) {
|
||||
is LoadState.Loading -> CircularProgressIndicator(Modifier.padding(16.dp))
|
||||
is LoadState.Error ->
|
||||
Text(
|
||||
state.message,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.padding(16.dp),
|
||||
)
|
||||
is LoadState.Loaded -> {
|
||||
val listing = state.value
|
||||
val sorted = remember(listing) { sortForDisplay(listing.entries) }
|
||||
LazyColumn(Modifier.weight(1f).fillMaxWidth()) {
|
||||
parentOf(listing.path)?.let { parent ->
|
||||
item("..") {
|
||||
EntryRow(
|
||||
glyph = FOLDER_GLYPH,
|
||||
name = "..",
|
||||
trailing = null,
|
||||
onClick = { onOpen(Spot.Dir(parent)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
if (sorted.isEmpty()) {
|
||||
item("empty") {
|
||||
Text(
|
||||
"Nothing here",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(16.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
uniqueItems(sorted, key = { it.name }) { entry ->
|
||||
val path = join(listing.path, entry.name)
|
||||
EntryRow(
|
||||
glyph = if (entry.isDirectory) FOLDER_GLYPH else FILE_GLYPH,
|
||||
name = entry.name,
|
||||
trailing = trailingOf(entry),
|
||||
onClick = {
|
||||
onOpen(if (entry.isDirectory) Spot.Dir(path) else Spot.Doc(path))
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What a row says after the name, or nothing.
|
||||
*
|
||||
* A symlink says so instead of giving a size, because the size a listing reports for one is the
|
||||
* length of the path it points at -- a number that looks exactly like a file size and is about
|
||||
* something else entirely. `other` covers a fifo, a device, and a link whose target is gone: the
|
||||
* row still appears, because a directory that hid what it held would be lying about being empty,
|
||||
* and the word is there because a colour cannot say "this is a different kind of thing".
|
||||
*/
|
||||
private fun trailingOf(entry: DirEntry): String? =
|
||||
when {
|
||||
entry.link -> "link"
|
||||
entry.isDirectory -> null
|
||||
entry.kind == "file" -> humanSize(entry.size) ?: "0 B"
|
||||
else -> "other"
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EntryRow(glyph: String, name: String, trailing: String?, onClick: () -> Unit) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier =
|
||||
Modifier.fillMaxWidth()
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
) {
|
||||
Glyph(glyph, colour = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Text(
|
||||
name,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.MiddleEllipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
trailing?.let {
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(
|
||||
it,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One file: read, and edited behind the pencil.
|
||||
*
|
||||
* Its own composable so that everything about one file -- what came back, what has been typed, and
|
||||
* whether a save is out -- is remembered under that file's path and thrown away when the reader
|
||||
* moves to another. What is *not* here is edit mode itself: back has to know about it, and back
|
||||
* belongs to the screen.
|
||||
*/
|
||||
@Composable
|
||||
private fun ColumnScope.DocPane(
|
||||
settings: ServerSettings,
|
||||
target: FilesTarget,
|
||||
path: String,
|
||||
name: String,
|
||||
editing: Boolean,
|
||||
onEditing: (Boolean) -> Unit,
|
||||
onDirty: (Boolean) -> Unit,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
var state by remember(path) { mutableStateOf<LoadState<FileContent>>(LoadState.Loading) }
|
||||
var draft by remember(path) { mutableStateOf(TextFieldValue()) }
|
||||
var saving by remember(path) { mutableStateOf(false) }
|
||||
var saveError by remember(path) { mutableStateOf<String?>(null) }
|
||||
var conflict by remember(path) { mutableStateOf<String?>(null) }
|
||||
// The editor's own vertical scroll, hoisted so the gutter and the text move together: they are
|
||||
// two composables in one row, and a scroll inside either would leave the other behind.
|
||||
val editScroll = rememberScrollState()
|
||||
val language = remember(name) { fileLanguage(name) }
|
||||
val loaded = (state as? LoadState.Loaded)?.value as? FileContent.Text
|
||||
|
||||
suspend fun fetch() {
|
||||
state = LoadState.Loading
|
||||
state =
|
||||
try {
|
||||
val got = withContext(Dispatchers.IO) { fetchFile(settings, target.setup, path) }
|
||||
if (got is FileContent.Text) draft = TextFieldValue(got.content)
|
||||
LoadState.Loaded(got)
|
||||
} catch (e: ApiException) {
|
||||
LoadState.failed(e)
|
||||
}
|
||||
onDirty(false)
|
||||
}
|
||||
|
||||
LaunchedEffect(path) { fetch() }
|
||||
|
||||
val changed = loaded != null && draft.text != loaded.content
|
||||
LaunchedEffect(changed) { onDirty(changed) }
|
||||
|
||||
/** Writes the draft back, [against] being the digest it is allowed to replace. */
|
||||
fun save(against: String) {
|
||||
if (saving) return
|
||||
saving = true
|
||||
saveError = null
|
||||
scope.launch {
|
||||
try {
|
||||
val written =
|
||||
withContext(Dispatchers.IO) {
|
||||
writeFile(settings, target.setup, path, draft.text, against)
|
||||
}
|
||||
state =
|
||||
LoadState.Loaded(
|
||||
FileContent.Text(
|
||||
path,
|
||||
written.size,
|
||||
written.modified,
|
||||
written.sha256,
|
||||
draft.text,
|
||||
)
|
||||
)
|
||||
conflict = null
|
||||
onDirty(false)
|
||||
onEditing(false)
|
||||
} catch (e: ApiException) {
|
||||
// The one refusal that is a question rather than a message: somebody else's edit
|
||||
// is on the machine, and which of the two survives is not this app's to decide.
|
||||
if (e.status == 409) conflict = e.message ?: "It changed on the machine."
|
||||
else saveError = e.message
|
||||
} finally {
|
||||
saving = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
FilesHeader(title = name, path = path, machine = target.setupName, onBack = onBack) {
|
||||
if (editing) {
|
||||
if (saving) {
|
||||
GlyphSpinner("Saving")
|
||||
} else {
|
||||
GlyphButton(
|
||||
SAVE_GLYPH,
|
||||
"Save",
|
||||
{ loaded?.let { save(it.sha256) } },
|
||||
// Disabled rather than hidden while there is nothing to write: a button that
|
||||
// comes and goes makes its own absence the signal.
|
||||
enabled = changed,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
GlyphButton(
|
||||
REFRESH_GLYPH,
|
||||
"Read this file again",
|
||||
{ scope.launch { fetch() } },
|
||||
enabled = state !is LoadState.Loading,
|
||||
)
|
||||
GlyphButton(EDIT_GLYPH, "Edit", { onEditing(true) }, enabled = loaded != null)
|
||||
}
|
||||
}
|
||||
|
||||
saveError?.let {
|
||||
Text(
|
||||
it,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
|
||||
)
|
||||
}
|
||||
|
||||
Box(Modifier.weight(1f).fillMaxWidth().background(rawSurface).padding(horizontal = 8.dp)) {
|
||||
when (val current = state) {
|
||||
is LoadState.Loading -> CircularProgressIndicator(Modifier.padding(8.dp))
|
||||
is LoadState.Error ->
|
||||
Text(
|
||||
current.message,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.padding(8.dp),
|
||||
)
|
||||
is LoadState.Loaded ->
|
||||
when (val file = current.value) {
|
||||
is FileContent.Text ->
|
||||
if (editing) {
|
||||
FileEditor(
|
||||
draft,
|
||||
{ draft = it },
|
||||
language,
|
||||
Modifier.verticalScroll(editScroll),
|
||||
)
|
||||
} else {
|
||||
val lines =
|
||||
remember(file.content, language) {
|
||||
FileLines.of(file.content, language)
|
||||
}
|
||||
FileViewer(lines)
|
||||
}
|
||||
// Said in words, with the measurement that makes it make sense. Neither of
|
||||
// these is an empty file and neither is an error, so neither may look like one.
|
||||
is FileContent.Binary ->
|
||||
Note(
|
||||
"This is not text (${humanSize(file.size) ?: "0 B"}), so there is nothing to show."
|
||||
)
|
||||
is FileContent.TooBig ->
|
||||
Note(
|
||||
"This file is ${humanSize(file.size)}, which is more than the server will " +
|
||||
"send. Nothing was read, so nothing here is a sample of it."
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
conflict?.let { message ->
|
||||
ConflictDialog(
|
||||
message = message,
|
||||
busy = saving,
|
||||
onOverwrite = {
|
||||
// Re-read only to learn what it hashes to *now*, which is the digest an overwrite
|
||||
// has to be allowed against. The content is deliberately thrown away: overwriting
|
||||
// is the choice to lose it.
|
||||
scope.launch {
|
||||
val fresh =
|
||||
try {
|
||||
withContext(Dispatchers.IO) { fetchFile(settings, target.setup, path) }
|
||||
} catch (e: ApiException) {
|
||||
saveError = e.message
|
||||
conflict = null
|
||||
return@launch
|
||||
}
|
||||
if (fresh is FileContent.Text) save(fresh.sha256)
|
||||
else {
|
||||
saveError =
|
||||
"It is no longer a text file, so this app will not write over it."
|
||||
conflict = null
|
||||
}
|
||||
}
|
||||
},
|
||||
onReload = {
|
||||
conflict = null
|
||||
scope.launch { fetch() }
|
||||
},
|
||||
onCancel = { conflict = null },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** A sentence where the file's content would be, for the two states that have no content. */
|
||||
@Composable
|
||||
private fun Note(text: String) {
|
||||
Text(
|
||||
text,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(8.dp),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Naming one thing in the directory that is open.
|
||||
*
|
||||
* A name and a switch, not a name and a body: the editor is where content is typed, and a modal
|
||||
* with a text area in it is a second editor to keep in step with the first. A created file opens
|
||||
* straight into edit mode, because an empty file is not something to look at.
|
||||
*/
|
||||
@Composable
|
||||
private fun CreateDialog(
|
||||
settings: ServerSettings,
|
||||
setup: String,
|
||||
directory: String,
|
||||
onDismiss: () -> Unit,
|
||||
onCreated: (String, Boolean) -> Unit,
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
var name by remember { mutableStateOf("") }
|
||||
var isDirectory by remember { mutableStateOf(false) }
|
||||
var busy by remember { mutableStateOf(false) }
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
fun create() {
|
||||
val chosen = name.trim()
|
||||
if (busy || chosen.isEmpty()) return
|
||||
busy = true
|
||||
error = null
|
||||
val path = join(directory, chosen)
|
||||
scope.launch {
|
||||
try {
|
||||
withContext(Dispatchers.IO) {
|
||||
if (isDirectory) createDir(settings, setup, path)
|
||||
else createFile(settings, setup, path)
|
||||
}
|
||||
onCreated(path, isDirectory)
|
||||
} catch (e: ApiException) {
|
||||
// Beside the button that caused it: this dialog is the only thing on screen that
|
||||
// knows something was being created, and the reason is usually the name itself.
|
||||
error = e.message
|
||||
busy = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Create in ${baseName(directory)}") },
|
||||
text = {
|
||||
Column {
|
||||
OutlinedTextField(
|
||||
value = name,
|
||||
onValueChange = { name = it },
|
||||
label = { Text("Name") },
|
||||
singleLine = true,
|
||||
enabled = !busy,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text("Directory", modifier = Modifier.weight(1f))
|
||||
Switch(
|
||||
checked = isDirectory,
|
||||
onCheckedChange = { isDirectory = it },
|
||||
enabled = !busy,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
"A name that is already taken is refused rather than replaced.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
error?.let {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
it,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = { create() }, enabled = !busy && name.isNotBlank()) {
|
||||
Text(if (busy) "Creating..." else "Create")
|
||||
}
|
||||
},
|
||||
dismissButton = { TextButton(onClick = onDismiss, enabled = !busy) { Text("Cancel") } },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Directories first, then by name ignoring case, and stably.
|
||||
*
|
||||
* Sorted here rather than by the machine: presentation order is a display decision, and `find`
|
||||
* answers in whatever order the directory happens to be stored in. Dotfiles are not hidden -- in a
|
||||
* repository they are half of what matters.
|
||||
*/
|
||||
internal fun sortForDisplay(entries: List<DirEntry>): List<DirEntry> =
|
||||
entries.sortedWith(compareBy({ !it.isDirectory }, { it.name.lowercase() }))
|
||||
|
||||
/**
|
||||
* The directory above [path], or null at the root.
|
||||
*
|
||||
* A string operation on a path the *machine* resolved, which is what makes it safe: every listing
|
||||
* answers with its own `pwd -P`, so there is never a `..` or a symlink left in here to reason
|
||||
* about, and this app never has to resolve one.
|
||||
*/
|
||||
internal fun parentOf(path: String): String? {
|
||||
val trimmed = path.trimEnd('/')
|
||||
if (trimmed.isEmpty()) return null
|
||||
val cut = trimmed.lastIndexOf('/')
|
||||
return when {
|
||||
cut < 0 -> null
|
||||
cut == 0 -> "/"
|
||||
else -> trimmed.substring(0, cut)
|
||||
}
|
||||
}
|
||||
|
||||
/** What a path names: its last segment, with `/` naming itself. */
|
||||
internal fun baseName(path: String): String {
|
||||
val trimmed = path.trimEnd('/')
|
||||
return if (trimmed.isEmpty()) "/" else trimmed.substringAfterLast('/')
|
||||
}
|
||||
|
||||
/** A resolved directory and a name in it, as one path. */
|
||||
internal fun join(directory: String, name: String): String =
|
||||
if (directory.endsWith("/")) "$directory$name" else "$directory/$name"
|
||||
Reference in new issue
Block a user