Repository navigation
Move Android file I/O off the main thread and stop reading contents on list - #33
Merged
Merged
Conversation
…n list `FileBrowserViewModel` called the repository synchronously from a `LaunchedEffect`, from click handlers and from the `evaluateJavascript` result callback, all on the main dispatcher, and `listFiles` read every file in full on each listing (on every Files-tab open and after every save or delete) even though the browser never shows contents. With a few large .scm files that janks the UI and can ANR (issue #12). Every `FileRepository` operation is now a `suspend` function that does its disk access inside `withContext(Dispatchers.IO)`, including the lazily deferred mkdirs and temp-file sweep that used to run in the ViewModel factory on the main thread. `listFiles` returns name, path and mtime only: `SchemeFile` no longer has a `content` field, so a listing can never carry fabricated empty content and opening a file goes through `readFile`. An unreadable or non-UTF-8 file therefore stays listed and deletable, and its failure or lossy decoding surfaces when it is opened, not while listing. `FileBrowserViewModel` is suspend all the way through. MainActivity gains one small launcher per file action (`openFile`, `newFile`, `deleteFile`, `saveEditorCode`, `createEmptyFile`) on the composition scope; each keeps its existing try/catch and snackbar. Opening a file switches to the editor only after the read succeeded, so a failed open never shows an empty document under the file's name for the next save to persist. The save dialog captures the typed name before the JS round trip. The iOS actual is updated in lockstep with the expect declaration; the Swift app keeps its own model and is unaffected (see #14). Tests: `FileRepositoryTest` and `FileBrowserViewModelTest` run under `runBlocking`/`runTest`; new cases cover listing without contents, an unreadable file staying listed while `readFile` throws (skipped where permission bits are ignored), the sweep deferring to first use, and `readFile` through the ViewModel. Closes #12 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: Baiju Muthukadan <baiju.m.mail@gmail.com>
baijum
commented
Sep 13, 2026
baijum
left a comment
Member
Author
There was a problem hiding this comment.
Verified locally on f44515e:
./gradlew assembleDebug test :shared:testAndroidHostTest :app:detekt— all green (re-ran tests and detekt with--rerun-tasks, not from cache):shared:compileKotlinIosSimulatorArm64— compiles, confirming thekotlinx.coroutines.IOimport for the NativeDispatchers.IOextension- No stray
SchemeFile.contentconsumers left on the Kotlin side; the iOS app's SwiftSchemeFileis a separate struct, so it is unaffected as stated SchemeFileJSON is only exercised incommonTestround-trips — nothing persists it, so droppingcontentoutright is safe
Overall this is the right shape: the suspend boundary sits at the repository, both actuals shift their disk access to Dispatchers.IO, dir is lazy on both platforms so construction does no I/O, and the Activity owns error UX in one place. The listing-without-contents contract change is well documented in the expect KDoc and both docs pages, and the new tests pin the interesting behaviors (unreadable files stay listed and deletable, a failed open never plants an empty document under the file's name, construction touches no disk).
Only nits below — nothing blocking.
…shared suspend assert `newFile` launched a second coroutine from inside the one it had already launched when it fell through to `createEmptyFile`. Extract the body into a local suspend `createEmptyFileInternal` that both the overwrite dialog's `createEmptyFile` and `newFile` call, so the new-file decision and the write stay in one coroutine. The Android repository's lazy `dir` only keeps the one-time temp-file sweep from deleting a concurrent write's `.tmp` because `by lazy` defaults to `LazyThreadSafetyMode.SYNCHRONIZED`: every `writeFile` obtains `dir` before creating its temp file and so blocks until the initializer has finished. Record that in a comment so a future startup-time tweak of the mode does not silently drop the guarantee. `FileRepositoryTest`'s private `assertThrowsSuspend` helper moves to a shared `SuspendAsserts.kt`, and `FileBrowserViewModelTest` uses it instead of wrapping each call in `runBlocking` inside `assertThrows`. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: Baiju Muthukadan <baiju.m.mail@gmail.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #12
Problem
FileBrowserViewModelcalled the repository synchronously from aLaunchedEffect, from click handlers and from theevaluateJavascriptresult callback — all on the main dispatcher. Worse,
listFilesread everyfile in full on each listing (every Files-tab open and after every save or
delete) even though the browser never shows contents. With a few large
.scmfiles that janks the UI and can ANR.Changes
FileRepositoryis suspend, I/O onDispatchers.IO. Every operationin the
expectdeclaration is now asuspend fun; both actuals wrap theirbodies in
withContext(Dispatchers.IO). The Androiddiris lazy, so themkdirsand the stale-temp-file sweep also run on IO at first use insteadof in the ViewModel factory on the main thread. The contract KDoc gains an
"all I/O is off the main thread" bullet.
Listing never reads contents.
SchemeFileloses itscontentfield(
name,path,lastModifiedremain), so a listing can never carryfabricated empty content and opening a file goes through
readFile. ThelistFilescontract bullet is rewritten accordingly: an unreadable ornon-UTF-8 file stays listed and deletable, and its failure or lossy decoding
surfaces from
readFilewhen it is opened, not while listing.FileBrowserViewModelis suspend all the way through; failures stillpropagate as exceptions to the caller.
MainActivitygains one small launcher per file action —createEmptyFile,openFile,newFile,deleteFile,saveEditorCode—each
scope.launching with the same try/catch + snackbar it had before, andthe
FileBrowserScreencallbacks become function references. Two behaviourdetails worth a look:
openFileswitches to the editor only afterreadFilesucceeded, so afailed open never shows an empty document under the file's name (which
the next save would persist). It surfaces "Could not open file: …".
the callback now completes after the dialog has closed.
iOS actual is updated in lockstep (
suspend+withContext, nocontent). It needsimport kotlinx.coroutines.IO— on Kotlin/NativeDispatchers.IOis an extension property. Verified with:shared:compileKotlinIosSimulatorArm64and:shared:compileKotlinIosArm64locally, since per #14 nothing in CI compiles
iosMain. The Swift app keepsits own
SchemeFilemodel and is unaffected.Docs:
docs/architecture.mdcontract paragraph and thedocs/development.mdtest table/conventions.Testing
FileRepositoryTestandFileBrowserViewModelTestrun underrunBlocking/runTest. New cases:listFiles_returnsNamesAndMtimesWithoutContentslistFiles_keepsNonUtf8FilesVisible(replaces the lossy-contentassertion, which now lives in
readFile_decodesInvalidUtf8Lossily)listFiles_keepsUnreadableFilesVisible_andReadFileReportsThem—assumeFalse(canRead())so it is skipped rather than failing wherepermission bits are ignored (root, some CI file systems)
firstUse_sweepsStaleTempFilesButKeepsRealFiles(wasrepositoryInit_…;now also asserts construction does no I/O)
readFile_returnsContentsThatTheListingDoesNotCarry,readFile_propagatesMissingFilesInsteadOfReturningEmptyon the ViewModel./gradlew assembleDebug test :shared:testAndroidHostTest :app:detektpasses; detekt reports no findings and the baseline is untouched.
MainActivityhas no automated net (per the repo'stest layout); verified by build and reasoning.
Not in scope
SettingsRepositoryusesSharedPreferenceswithapply(), which isalready asynchronous; left as is.