diff --git a/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/ServerContext.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/ServerContext.swift index 31c68de..ae6e0f2 100644 --- a/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/ServerContext.swift +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/ServerContext.swift @@ -233,10 +233,34 @@ extension ServerContext { /// the remote path doesn't exist on this Mac. extension ServerContext { /// Read a UTF-8 text file. `nil` on any error (missing, transport down, - /// invalid encoding). + /// invalid encoding). Use this when the caller genuinely can't tell + /// the difference (e.g. "if a manifest exists, parse it, otherwise + /// use defaults"). Prefer `readTextThrowing` when the UI needs to + /// distinguish "file doesn't exist" from "transport failed" — pass-1 + /// M7 #8 showed that silent nils from transport errors masqueraded + /// as empty files in the Memory editor for ~1 minute before the + /// SFTP-tilde fix was found. public nonisolated func readText(_ path: String) -> String? { - guard let data = try? makeTransport().readFile(path) else { return nil } - return String(data: data, encoding: .utf8) + try? readTextThrowing(path) + } + + /// Read a UTF-8 text file. Throws on transport errors. Returns: + /// - `.some(content)` when the file was read successfully, + /// - `.none` when the file is genuinely absent (the transport's + /// `fileExists` returned false), + /// - throws the underlying transport error otherwise. + /// + /// This is the version to call from VMs that can surface a real + /// error to the UI — e.g. Memory, Settings, Cron. The nil-returning + /// shim above is fine for "probably there, probably not" cases. + public nonisolated func readTextThrowing(_ path: String) throws -> String? { + let transport = makeTransport() + guard transport.fileExists(path) else { return nil } + let data = try transport.readFile(path) + guard let text = String(data: data, encoding: .utf8) else { + throw TransportError.other(message: "File at \(path) is not valid UTF-8.") + } + return text } /// Read raw bytes. `nil` on any error. diff --git a/scarf/Packages/ScarfCore/Sources/ScarfCore/ViewModels/IOSMemoryViewModel.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/ViewModels/IOSMemoryViewModel.swift index 7c86372..da45e2f 100644 --- a/scarf/Packages/ScarfCore/Sources/ScarfCore/ViewModels/IOSMemoryViewModel.swift +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/ViewModels/IOSMemoryViewModel.swift @@ -93,24 +93,39 @@ public final class IOSMemoryViewModel { public func load() async { isLoading = true lastError = nil - // Run the file read on a detached task — `ServerContext.readText` + // Run the file read on a detached task — `readTextThrowing` // blocks on transport I/O, and we don't want the MainActor // hanging during a remote SFTP fetch. let ctx = context let path = kind.path(on: context) - let loaded: String? = await Task.detached { - ctx.readText(path) + let result: Result = await Task.detached { + do { + return .success(try ctx.readTextThrowing(path)) + } catch { + return .failure(error) + } }.value - if let loaded { + switch result { + case .success(.some(let loaded)): text = loaded originalText = loaded - } else { - // `readText` returns nil on missing file — treat as - // empty (the user is creating the file for the first - // time) rather than an error. + lastError = nil + case .success(.none): + // Genuinely absent file — treat as empty (first-time + // create). Distinguished from transport error by the + // fileExists check inside readTextThrowing (pass-1 M7 #7/#8). text = "" originalText = "" + lastError = nil + case .failure(let error): + // Transport error (SSH timeout, auth failure, SFTP + // protocol issue). Surface to the UI so the user + // understands this isn't just "empty file" — something's + // genuinely broken with the connection. + text = "" + originalText = "" + lastError = "Couldn't load \(kind.displayName) — \(error.localizedDescription)" } isLoading = false } diff --git a/scarf/Packages/ScarfCore/Tests/ScarfCoreTests/M0dViewModelsTests.swift b/scarf/Packages/ScarfCore/Tests/ScarfCoreTests/M0dViewModelsTests.swift index 4be1ee9..d6ec9ee 100644 --- a/scarf/Packages/ScarfCore/Tests/ScarfCoreTests/M0dViewModelsTests.swift +++ b/scarf/Packages/ScarfCore/Tests/ScarfCoreTests/M0dViewModelsTests.swift @@ -123,7 +123,7 @@ import Foundation @Test @MainActor func activityViewModelInits() { let vm = ActivityViewModel(context: .local) #expect(vm.context.id == ServerContext.local.id) - #expect(vm.entries.isEmpty) + #expect(vm.toolMessages.isEmpty) } @Test @MainActor func insightsViewModelInits() {