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 **machine**, 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 machines tab -- one more * caller rather than any new code here. */ data class FilesTarget( val machine: String, val machineName: String, val start: String, /** A document to open immediately; [start] remains the fallback directory. */ val file: String? = null, ) /** The explorer target for this session's machine, optionally opened on [file]. */ fun SessionSummary.filesTarget(file: String? = null) = FilesTarget( machine = machine, machineName = machineName, start = cwd?.takeIf { it.isNotBlank() } ?: "~", file = file, ) /** 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, val directory: Dir) : Spot(path) } private enum class UnsavedDestination { Directory, Session, } /** * 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. Both back controls return from a file to its * 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 * created in. */ @Composable fun FilesScreen(settings: ServerSettings, target: FilesTarget, onClose: () -> Unit) { val scope = rememberCoroutineScope() val initialDirectory = target.file?.let(::parentOf)?.let { Spot.Dir(it) } ?: Spot.Dir(target.start) var here by remember(target) { mutableStateOf( target.file?.let { Spot.Doc(it, initialDirectory) } ?: initialDirectory ) } 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 both ways out have to ask before discarding it. var editing by remember { mutableStateOf(false) } var dirty by remember { mutableStateOf(false) } var unsavedDestination by remember { mutableStateOf(null) } fun go(spot: Spot) { editing = false dirty = false here = spot } fun leave(destination: UnsavedDestination) { if (editing && dirty) { unsavedDestination = destination } else if (destination == UnsavedDestination.Directory) { go((here as Spot.Doc).directory) } else { onClose() } } suspend fun load(path: String, again: Boolean) { val existing = listings[path] if (!again && (existing is LoadState.Loaded || existing is LoadState.Loading)) return listings[path] = LoadState.Loading listings[path] = try { withContext(Dispatchers.IO) { LoadState.Loaded(fetchDir(settings, target.machine, path)) } } catch (e: ApiException) { LoadState.failed(e) } } 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.machine, target.start) { if (target.file != null) load(target.start, again = false) if (target.start != "~") load("~", again = false) } BackHandler(onBack = ::systemBack) 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 // 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(shownAt), path = shownAt, machine = target.machineName, onBack = { leave(UnsavedDestination.Session) }, ) { 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, directory = spot, onOpen = ::go) } is Spot.Doc -> DocPane( settings = settings, target = target, path = spot.path, name = baseName(spot.path), editing = editing, homeDirectory = homeDirectory, onEditing = { editing = it }, onDirty = { dirty = it }, onBack = { leave(UnsavedDestination.Directory) }, ) } } } unsavedDestination?.let { destination -> UnsavedDialog( onDiscard = { unsavedDestination = null if (destination == UnsavedDestination.Directory) { go((here as Spot.Doc).directory) } else { onClose() } }, onCancel = { unsavedDestination = null }, ) } val dir = here as? Spot.Dir val listing = (listings[dir?.path] as? LoadState.Loaded)?.value if (creating && dir != null && listing != null) { CreateDialog( settings = settings, machine = target.machine, 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, dir)) 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, directory: Spot.Dir, 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, directory) ) }, ) } } } } } /** * 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, homeDirectory: String?, 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.machine, 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.machine, 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 = tildePath(path, homeDirectory), machine = target.machineName, 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.machine, 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, machine: 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, machine, path) else createFile(settings, machine, 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) } } /** * 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('/') return if (trimmed.isEmpty()) "/" else trimmed.substringAfterLast('/') } internal fun join(directory: String, name: String): String = if (directory.endsWith("/")) "$directory$name" else "$directory/$name"