The same pass the server had, on the Kotlin side: comments restating what the code says are gone, and the ones recording a measurement, a constraint or an incident are kept but cut to a few lines each. 6540 comment lines to 5674, and 920 lines off the app. Two doc comments had drifted onto the item above the one they describe -- `contextAfter`'s onto `sessionWorking` in Events.kt, and `UsageMonitor`'s equivalent on the server was fixed in the previous commit. Each is back on its own item, which is the only non-comment line this diff moves. The comments are reflowed to the column limit at their own indentation: several were written wide, and ktfmt re-wrapped them into lines holding a single orphan word. `/tmp` script, not kept -- ktfmt is idempotent over the result, which is the check. Left alone deliberately: this codebase's remaining comment density is high because the comments carry things the code cannot say -- what a null means, what a number was measured against, which bug a guard exists for. Of the 238 one-line doc comments in the app, five were pure restatement of the name and were removed; the rest each say something the signature does not. ktfmtFormat, compileDebugKotlin, lintDebug and testDebugUnitTest pass; cargo test (127), clippy --all-targets and fmt still clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
689 lines
26 KiB
Kotlin
689 lines
26 KiB
Kotlin
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 -- 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 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 -- and only closes from
|
|
* where it opened.
|
|
*
|
|
* 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
|
|
* created in.
|
|
*/
|
|
@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 two places, the arrow
|
|
// and the platform's own gesture, which must 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, so the explorer adds its
|
|
// own -- otherwise the editor types under the keyboard.
|
|
.imePadding()
|
|
) {
|
|
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. `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.
|
|
*/
|
|
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.
|
|
*/
|
|
@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
|
|
// Readable but not editable: see [EDIT_LIMIT]. The size is the one the machine reported, so
|
|
// this is decided before anything is typed rather than discovered by a keyboard that stops
|
|
// answering.
|
|
val editable = loaded != null && loaded.size <= EDIT_LIMIT
|
|
|
|
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 = editable)
|
|
}
|
|
}
|
|
|
|
saveError?.let {
|
|
Text(
|
|
it,
|
|
color = MaterialTheme.colorScheme.error,
|
|
style = MaterialTheme.typography.bodySmall,
|
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
|
|
)
|
|
}
|
|
|
|
// Why the pencil is off. A disabled control teaches what the thing can do but cannot say why it
|
|
// is disabled -- and a reader who cannot edit a file they can plainly read will otherwise
|
|
// conclude the app is broken. Said once, here, rather than waiting for a tap a disabled button
|
|
// never gets.
|
|
if (loaded != null && !editable) {
|
|
Text(
|
|
"Too big to edit here (${humanSize(loaded.size)}; the limit is " +
|
|
"${humanSize(EDIT_LIMIT)}). A text field this large stops answering the keyboard.",
|
|
style = MaterialTheme.typography.bodySmall,
|
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
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 {
|
|
ScannedFile(file.content, language)
|
|
}
|
|
// 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('/')
|
|
}
|
|
|
|
internal fun join(directory: String, name: String): String =
|
|
if (directory.endsWith("/")) "$directory$name" else "$directory/$name"
|