Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
135 changes: 86 additions & 49 deletions app/src/main/java/com/kaappi/studio/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ import com.kaappi.studio.data.FileRepository
import com.kaappi.studio.data.FileRepositoryException
import com.kaappi.studio.data.SchemeFileNames
import com.kaappi.studio.data.SettingsRepository
import com.kaappi.studio.domain.SchemeFile
import com.kaappi.studio.domain.ThemeMode
import com.kaappi.studio.runtime.SchemeRunner
import com.kaappi.studio.ui.screens.EditorScreen
Expand Down Expand Up @@ -249,7 +250,13 @@ private fun KaappiStudioApp(
}
}

val createEmptyFile = { name: String ->
// File operations are suspend functions whose I/O runs on Dispatchers.IO
// inside the repository (issue #12): each is launched on the composition
// scope, so click handlers and JS callbacks never block the main thread.
// State updates run back on Main after the I/O completes.
// Shared by createEmptyFile (the overwrite dialog) and newFile, so the
// new-file decision and the write stay within a single coroutine.
suspend fun createEmptyFileInternal(name: String) {
try {
val saved = fileBrowserVM.saveFile(name, "")
editorVM.setCurrentFile(saved.name)
Expand All @@ -262,6 +269,77 @@ private fun KaappiStudioApp(
}
}

fun createEmptyFile(name: String) {
scope.launch { createEmptyFileInternal(name) }
}

fun openFile(file: SchemeFile) {
scope.launch {
try {
// Listing no longer carries contents (issue #12): read on open,
// and switch to the editor only once the read succeeded so a
// failed open never shows an empty document under the file's
// name (which the next save would persist).
val content = fileBrowserVM.readFile(file.path)
editorVM.setPendingCode(content)
editorVM.setCurrentFile(file.name)
editorVM.clearOutput()
currentSection = NavSection.EDITOR
} catch (e: FileRepositoryException) {
showFileError("Could not open file", e)
}
}
}

fun newFile(name: String) {
scope.launch {
try {
if (fileBrowserVM.fileExists(name)) {
overwriteTarget = SchemeFileNames.sanitize(name)
} else {
createEmptyFileInternal(name)
}
} catch (e: IllegalArgumentException) {
showFileError("Could not create file", e)
}
}
}

fun deleteFile(file: SchemeFile) {
scope.launch {
try {
fileBrowserVM.deleteFile(file.path)
// Only reached when the file is really gone. SchemeFile.name
// and currentFileName are both stored as base names without
// the .scm extension, so they compare directly. Resetting the
// pending code queues an empty document that replaces the
// deleted file's content next time the editor is shown, and
// clearing the name drops it from the title and the
// save-dialog prefill (issue #15).
if (file.name == editorVM.currentFileName.value) {
editorVM.setPendingCode("")
editorVM.setCurrentFile(null)
editorVM.clearOutput()
}
} catch (e: FileRepositoryException) {
showFileError("Could not delete file", e)
}
}
}

fun saveEditorCode(name: String, code: String) {
scope.launch {
try {
val saved = fileBrowserVM.saveFile(name, code)
editorVM.setCurrentFile(saved.name)
} catch (e: FileRepositoryException) {
showFileError("Could not save file", e)
} catch (e: IllegalArgumentException) {
showFileError("Could not save file", e)
}
}
}

val isDark = when (themeMode) {
ThemeMode.DARK -> true
ThemeMode.LIGHT -> false
Expand Down Expand Up @@ -407,44 +485,9 @@ private fun KaappiStudioApp(
NavSection.FILES -> {
FileBrowserScreen(
viewModel = fileBrowserVM,
onFileSelected = { file ->
editorVM.setPendingCode(file.content)
editorVM.setCurrentFile(file.name)
editorVM.clearOutput()
currentSection = NavSection.EDITOR
},
onNewFile = { name ->
try {
if (fileBrowserVM.fileExists(name)) {
overwriteTarget = SchemeFileNames.sanitize(name)
} else {
createEmptyFile(name)
}
} catch (e: IllegalArgumentException) {
showFileError("Could not create file", e)
}
},
onDeleteFile = { file ->
try {
fileBrowserVM.deleteFile(file.path)
// Only reached when the file is really gone.
// SchemeFile.name and currentFileName are both
// stored as base names without the .scm
// extension, so they compare directly. Resetting
// the pending code queues an empty document that
// replaces the deleted file's content next time
// the editor is shown, and clearing the name
// drops it from the title and the save-dialog
// prefill (issue #15).
if (file.name == editorVM.currentFileName.value) {
editorVM.setPendingCode("")
editorVM.setCurrentFile(null)
editorVM.clearOutput()
}
} catch (e: FileRepositoryException) {
showFileError("Could not delete file", e)
}
},
onFileSelected = ::openFile,
onNewFile = ::newFile,
onDeleteFile = ::deleteFile,
modifier = Modifier
.fillMaxSize()
.padding(padding),
Expand Down Expand Up @@ -479,22 +522,16 @@ private fun KaappiStudioApp(
androidx.compose.material3.TextButton(
onClick = {
if (saveFileName.isNotBlank()) {
// Capture the name now: the callback runs after the
// dialog has closed and its field may be reused.
val name = saveFileName
Comment thread
baijum marked this conversation as resolved.
getEditorWebView().evaluateJavascript(
"window.kaappiAPI?.getCode()",
) { rawCode ->
// Skip the save entirely when there is no
// content to save (page not ready) rather than
// clobbering the target file with "null".
decodeCodeResult(rawCode)?.let { code ->
try {
val saved = fileBrowserVM.saveFile(saveFileName, code)
editorVM.setCurrentFile(saved.name)
} catch (e: FileRepositoryException) {
showFileError("Could not save file", e)
} catch (e: IllegalArgumentException) {
showFileError("Could not save file", e)
}
}
decodeCodeResult(rawCode)?.let { code -> saveEditorCode(name, code) }
}
showSaveDialog = false
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,24 +10,30 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow

/**
* Every operation is a `suspend` function: the repository does its disk I/O
* on `Dispatchers.IO`, so UI callers launch these from the main thread
* without ever blocking it (issue #12). Failures propagate as exceptions to
* the caller, which owns the snackbar.
*/
class FileBrowserViewModel(private val fileRepository: FileRepository) : ViewModel() {

private val _files = MutableStateFlow<List<SchemeFile>>(emptyList())
val files: StateFlow<List<SchemeFile>> = _files.asStateFlow()

fun refresh() {
suspend fun refresh() {
_files.value = fileRepository.listFiles()
}

fun saveFile(name: String, content: String): SchemeFile =
suspend fun saveFile(name: String, content: String): SchemeFile =
fileRepository.writeFile(name, content).also { refresh() }

/**
* True when a file with this name (sanitized, base name) already exists on
* disk. Used by the new-file flow so it can confirm before overwriting
* (issue #10). Throws [IllegalArgumentException] for invalid names.
*/
fun fileExists(name: String): Boolean {
suspend fun fileExists(name: String): Boolean {
val base = SchemeFileNames.sanitize(name)
return fileRepository.listFiles().any { it.name == base }
}
Expand All @@ -39,14 +45,15 @@ class FileBrowserViewModel(private val fileRepository: FileRepository) : ViewMod
* repository's never-fabricate-success contract: callers must not reset
* editor state for a file that still exists (issue #15).
*/
fun deleteFile(path: String) {
suspend fun deleteFile(path: String) {
val deleted = fileRepository.deleteFile(path)
refresh()
if (!deleted) throw FileRepositoryException("Cannot delete ${File(path).name}")
}

fun renameFile(oldPath: String, newName: String): SchemeFile? =
suspend fun renameFile(oldPath: String, newName: String): SchemeFile? =
fileRepository.renameFile(oldPath, newName).also { refresh() }

fun readFile(path: String): String = fileRepository.readFile(path)
/** The file's contents; [SchemeFile] itself carries none (issue #12). */
suspend fun readFile(path: String): String = fileRepository.readFile(path)
}
14 changes: 14 additions & 0 deletions app/src/test/java/com/kaappi/studio/SuspendAsserts.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package com.kaappi.studio

import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertThrows

/**
* `assertThrows` for suspend calls. `FileRepository` and `FileBrowserViewModel`
* expose suspend-only APIs (issue #12), and JUnit's `ThrowingRunnable` is not a
* suspend lambda, so the call is bridged through `runBlocking`.
*/
inline fun <reified T : Throwable> assertThrowsSuspend(
message: String? = null,
crossinline block: suspend () -> Unit,
): T = assertThrows(message, T::class.java) { runBlocking { block() } }
Loading
Loading