diff --git a/supacode/Features/DiffView/DiffWindowContentView.swift b/supacode/Features/DiffView/DiffWindowContentView.swift index 882b3821..7afeb91e 100644 --- a/supacode/Features/DiffView/DiffWindowContentView.swift +++ b/supacode/Features/DiffView/DiffWindowContentView.swift @@ -105,8 +105,13 @@ struct DiffWindowContentView: View { showsFileHeaders: false, ), onEvent: { event in - if case .didRender = event { + switch event { + case .didRender: state.markDiffRendered() + case .didFail(let error): + state.markDiffFailed(error) + default: + break } } ) @@ -117,9 +122,23 @@ struct DiffWindowContentView: View { .padding(12) .background(.regularMaterial, in: Circle()) .transition(.opacity) + } else if let renderError = state.renderError { + VStack(spacing: 6) { + Image(systemName: "exclamationmark.triangle") + .foregroundStyle(.orange) + .accessibilityHidden(true) + Text(renderError.message) + .font(.caption) + .multilineTextAlignment(.center) + } + .padding(12) + .frame(maxWidth: 240) + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 8)) + .transition(.opacity) } } .animation(.easeInOut(duration: 0.15), value: state.isRenderingDiff) + .animation(.easeInOut(duration: 0.15), value: state.renderError) } else if state.isLoadingFiles { ProgressView() .frame(maxWidth: .infinity, maxHeight: .infinity) diff --git a/supacode/Features/DiffView/DiffWindowState.swift b/supacode/Features/DiffView/DiffWindowState.swift index f373b402..49d63689 100644 --- a/supacode/Features/DiffView/DiffWindowState.swift +++ b/supacode/Features/DiffView/DiffWindowState.swift @@ -15,6 +15,10 @@ final class DiffWindowState { /// large files, since diffing/painting happens on the JS side. Cleared by /// `markDiffRendered()` once the view reports its `didRender` event. var isRenderingDiff = false + /// Set by `markDiffFailed(_:)` when `DiffView` reports a `.didFail` event, so + /// the render-in-progress indicator doesn't stay stuck forever. Cleared as soon + /// as a new document starts rendering. + var renderError: DiffError? private var documentCache: [String: DiffDocument] = [:] private var loadTask: Task? @@ -87,9 +91,19 @@ final class DiffWindowState { isRenderingDiff = false } + /// Called by the view once `DiffView` reports its `didFail` event, so the + /// loading indicator doesn't stay stuck forever when a render fails. + func markDiffFailed(_ error: DiffError) { + isRenderingDiff = false + renderError = error + } + private func updateDiffDocument(_ newDocument: DiffDocument?) { guard newDocument != diffDocument else { return } isRenderingDiff = newDocument != nil + if isRenderingDiff { + renderError = nil + } diffDocument = newDocument } diff --git a/supacodeTests/DiffWindowStateTests.swift b/supacodeTests/DiffWindowStateTests.swift index b40cfb3a..aa28c298 100644 --- a/supacodeTests/DiffWindowStateTests.swift +++ b/supacodeTests/DiffWindowStateTests.swift @@ -266,6 +266,45 @@ struct DiffWindowStateTests { #expect(state.diffDocument != docB) } + + @Test func markDiffFailedClearsRenderingAndStoresError() async { + let fileA = DiffChangedFile(status: .modified, oldPath: "a.swift", newPath: "a.swift") + let docA = DiffDocument(files: [], title: "a") + let state = DiffWindowState( + fetchChangedFiles: { _ in [fileA] }, + loadDiffDocument: { _, _ in docA } + ) + await state.loadAllFiles(worktreeURL: URL(fileURLWithPath: "/tmp")) + #expect(state.isRenderingDiff) + + let error = DiffError(code: "render_failed", message: "boom") + state.markDiffFailed(error) + + #expect(!state.isRenderingDiff) + #expect(state.renderError == error) + } + + @Test func selectingANewFileClearsAPriorRenderError() async { + let fileA = DiffChangedFile(status: .modified, oldPath: "a.swift", newPath: "a.swift") + let fileB = DiffChangedFile(status: .modified, oldPath: "b.swift", newPath: "b.swift") + let docA = DiffDocument(files: [], title: "a") + let docB = DiffDocument(files: [], title: "b") + let docs = ["a.swift": docA, "b.swift": docB] + let clock = TestClock() + let state = DiffWindowState( + fetchChangedFiles: { _ in [fileA, fileB] }, + loadDiffDocument: { file, _ in docs[file.id]! }, + clock: clock + ) + await state.loadAllFiles(worktreeURL: URL(fileURLWithPath: "/tmp")) + state.markDiffFailed(DiffError(code: "render_failed", message: "boom")) + #expect(state.renderError != nil) + + state.selectFile(fileB) + await advanceSelectDebounce(clock) + + #expect(state.renderError == nil) + } } @MainActor