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.Dir(target.start))) } val listings = remember { mutableStateMapOf>() } 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, 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.Loading) } var draft by remember(path) { mutableStateOf(TextFieldValue()) } var saving by remember(path) { mutableStateOf(false) } var saveError by remember(path) { mutableStateOf(null) } var conflict by remember(path) { mutableStateOf(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(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): List = 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"