From b6f1274f47ba15eb81010e3ae01291ef5532eeec Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 30 Jul 2026 22:50:58 +0200 Subject: [PATCH] Clarify changed and deleted files in the file viewer (#2670) * fix(app): distinguish changed and deleted files Successful reads now reconcile transient missing events without letting stale cached data mask a deletion. File notices expose only actions valid for the current buffer state. * fix(app): preserve file state ordering * fix(app): allow retrying file checks * fix(app): clear unchanged file conflicts * fix(app): keep file conflict recovery consistent Own disk observation and read ordering in one model so stale reads and failed subscriptions cannot produce contradictory actions. * fix(app): allow retrying file preview errors Preserve daemon read errors and expose retry for initial and non-editable file loads. --- .../app/e2e/file-change-conflicts.spec.ts | 262 ++++++++++++++++++ .../app/e2e/helpers/daemon-websocket-gate.ts | 104 +++++++ packages/app/src/file-pane/bar.tsx | 28 +- packages/app/src/file-pane/conflict-alert.tsx | 106 +++++-- .../app/src/file-pane/editor/model.test.ts | 74 +++++ packages/app/src/file-pane/editor/model.ts | 69 +++++ packages/app/src/file-pane/live-file.ts | 93 ------- packages/app/src/file-pane/live-file/hook.ts | 48 ++++ .../app/src/file-pane/live-file/model.test.ts | 255 +++++++++++++++++ packages/app/src/file-pane/live-file/model.ts | 257 +++++++++++++++++ packages/app/src/file-pane/pane.tsx | 148 +++++++--- packages/app/src/i18n/resources/ar.ts | 4 +- packages/app/src/i18n/resources/en.ts | 4 +- packages/app/src/i18n/resources/es.ts | 4 +- packages/app/src/i18n/resources/fr.ts | 4 +- packages/app/src/i18n/resources/ja.ts | 4 +- packages/app/src/i18n/resources/pt-BR.ts | 4 +- packages/app/src/i18n/resources/ru.ts | 4 +- packages/app/src/i18n/resources/zh-CN.ts | 4 +- 19 files changed, 1282 insertions(+), 194 deletions(-) create mode 100644 packages/app/e2e/file-change-conflicts.spec.ts delete mode 100644 packages/app/src/file-pane/live-file.ts create mode 100644 packages/app/src/file-pane/live-file/hook.ts create mode 100644 packages/app/src/file-pane/live-file/model.test.ts create mode 100644 packages/app/src/file-pane/live-file/model.ts diff --git a/packages/app/e2e/file-change-conflicts.spec.ts b/packages/app/e2e/file-change-conflicts.spec.ts new file mode 100644 index 000000000..202d273cc --- /dev/null +++ b/packages/app/e2e/file-change-conflicts.spec.ts @@ -0,0 +1,262 @@ +import { mkdir, readFile, rename, rm, unlink, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { expect, test, type Page } from "./fixtures"; +import { expectFileTabOpen, openFileExplorer, openFileFromExplorer } from "./helpers/file-explorer"; +import type { WithWorkspace } from "./helpers/with-workspace"; +import { installDaemonWebSocketGate } from "./helpers/daemon-websocket-gate"; + +function visibleEditor(page: Page) { + return page.getByTestId("file-source-editor").filter({ visible: true }).locator(".cm-content"); +} + +function filePane(page: Page) { + return page.getByTestId("workspace-file-pane").filter({ visible: true }); +} + +function fileCallout(page: Page) { + return filePane(page).getByRole("alert"); +} + +async function openFile(page: Page, filename: string): Promise { + await openFileExplorer(page); + await openFileFromExplorer(page, filename); + await expectFileTabOpen(page, filename); +} + +async function openTrackedFile( + page: Page, + withWorkspace: WithWorkspace, + input: { prefix: string; relativePath: string; content: string | Uint8Array }, +): Promise { + const workspace = await withWorkspace({ prefix: input.prefix }); + const filePath = path.join(workspace.repoPath, input.relativePath); + if (typeof input.content === "string") { + await writeFile(filePath, input.content, "utf8"); + } else { + await writeFile(filePath, input.content); + } + await workspace.navigateTo(); + await openFile(page, input.relativePath); + return filePath; +} + +async function replaceEditorText(page: Page, content: string): Promise { + const editor = visibleEditor(page); + await editor.click(); + await editor.press("Control+A"); + await editor.type(content); +} + +async function replaceFileAfterDeletionWasObserved(input: { + page: Page; + gate: Awaited>; + filePath: string; + relativePath: string; + content: string; +}): Promise { + const { page, gate, filePath, relativePath, content } = input; + // Recreate the observed race without faking file state: the daemon publishes the + // deletion, then a real read succeeds after the replacement while its ready event waits. + await gate.waitForFileSubscription(relativePath); + gate.holdFileReads(relativePath); + await unlink(filePath); + await gate.waitForHeldFileRead(); + await expectOnlyFileCallout(page, "File deleted on disk"); + gate.holdNextReadyFileUpdate(relativePath); + await writeFile(filePath, content, "utf8"); + gate.releaseHeldFileRead(); + await gate.waitForHeldReadyFileUpdate(); + await expect.poll(() => readFile(filePath, "utf8")).toBe(content); +} + +async function expectOnlyFileCallout(page: Page, title: string): Promise { + await expect(fileCallout(page)).toHaveCount(1); + await expect(fileCallout(page)).toContainText(title); + await expect(filePane(page).getByText(title, { exact: true })).toHaveCount(1); +} + +async function expectCleanReplacementCanReload(page: Page): Promise { + await expectOnlyFileCallout(page, "Changed on disk"); + await expect(filePane(page).getByText("File deleted on disk", { exact: true })).toHaveCount(0); + await expect(fileCallout(page).getByRole("button", { name: "Overwrite" })).toHaveCount(0); + await expect(fileCallout(page).getByRole("button", { name: "Reload" })).toBeEnabled(); + await fileCallout(page).getByRole("button", { name: "Reload" }).click(); + await expect(filePane(page).getByText("After", { exact: true })).toBeVisible(); + await expect(fileCallout(page)).toHaveCount(0); +} + +async function expectDeletedFileNotice(page: Page): Promise { + await expectOnlyFileCallout(page, "File deleted on disk"); + await expect(fileCallout(page)).toContainText("The open copy is preserved."); + await expect(filePane(page).getByText("Changed on disk", { exact: true })).toHaveCount(0); + await expect(fileCallout(page).getByRole("button", { name: "Overwrite" })).toHaveCount(0); + await expect(fileCallout(page).getByRole("button", { name: "Reload" })).toHaveCount(0); +} + +async function expectDirtyConflictCanReload(page: Page): Promise { + await expectOnlyFileCallout(page, "Changed on disk"); + await expect(fileCallout(page).getByRole("button", { name: "Overwrite" })).toBeEnabled(); + await expect(fileCallout(page).getByRole("button", { name: "Reload" })).toBeEnabled(); + page.once("dialog", (dialog) => dialog.accept()); + await fileCallout(page).getByRole("button", { name: "Reload" }).click(); + await expect(visibleEditor(page)).toContainText("const external = true;"); + await expect(fileCallout(page)).toHaveCount(0); +} + +test.describe("Workspace file change conflicts", () => { + test("a clean file replaced on disk offers one working reload action", async ({ + page, + withWorkspace, + }) => { + const gate = await installDaemonWebSocketGate(page); + const filePath = await openTrackedFile(page, withWorkspace, { + prefix: "file-clean-replacement-", + relativePath: "inventory.md", + content: "# Before\n", + }); + await expect(filePane(page).getByText("Before", { exact: true })).toBeVisible(); + await replaceFileAfterDeletionWasObserved({ + page, + gate, + filePath, + relativePath: "inventory.md", + content: "# After\n", + }); + await expectCleanReplacementCanReload(page); + gate.releaseHeldReadyFileUpdate(); + }); + + test("a deleted file shows one explanatory notice and no resolution actions", async ({ + page, + withWorkspace, + }) => { + const filePath = await openTrackedFile(page, withWorkspace, { + prefix: "file-deleted-", + relativePath: "deleted.md", + content: "# Present\n", + }); + await expect(filePane(page).getByText("Present", { exact: true })).toBeVisible(); + await unlink(filePath); + await expectDeletedFileNotice(page); + }); + + test("a file check error offers a working retry", async ({ page, withWorkspace }) => { + const gate = await installDaemonWebSocketGate(page); + const relativePath = "retry.md"; + const filePath = await openTrackedFile(page, withWorkspace, { + prefix: "file-check-retry-", + relativePath, + content: "# Before\n", + }); + await gate.waitForFileSubscription(relativePath); + await expect(filePane(page).getByText("Before", { exact: true })).toBeVisible(); + + await unlink(filePath); + await mkdir(filePath); + await expectOnlyFileCallout(page, "Couldn't check file on disk"); + await expect(fileCallout(page).getByRole("button", { name: "Retry" })).toBeEnabled(); + await expect(fileCallout(page).getByRole("button", { name: "Reload" })).toHaveCount(0); + await expect(fileCallout(page).getByRole("button", { name: "Overwrite" })).toHaveCount(0); + + gate.holdNextReadyFileUpdate(relativePath); + await rm(filePath, { recursive: true }); + await writeFile(filePath, "# After\n", "utf8"); + await gate.waitForHeldReadyFileUpdate(); + gate.holdFileReads(relativePath); + await fileCallout(page).getByRole("button", { name: "Retry" }).click(); + await gate.waitForHeldFileRead(); + await expect(fileCallout(page).getByRole("button", { name: "Retry" })).toBeDisabled(); + gate.releaseHeldFileRead(); + await expectCleanReplacementCanReload(page); + gate.releaseHeldReadyFileUpdate(); + }); + + test("retry clears a transient error when the file is unchanged", async ({ + page, + withWorkspace, + }) => { + const gate = await installDaemonWebSocketGate(page); + const relativePath = "unchanged.md"; + const filePath = await openTrackedFile(page, withWorkspace, { + prefix: "file-check-unchanged-", + relativePath, + content: "# Unchanged\n", + }); + const parkedPath = `${filePath}.parked`; + await gate.waitForFileSubscription(relativePath); + await expect(filePane(page).getByText("Unchanged", { exact: true })).toBeVisible(); + + await rename(filePath, parkedPath); + await mkdir(filePath); + await expectOnlyFileCallout(page, "Couldn't check file on disk"); + + gate.holdNextReadyFileUpdate(relativePath); + await rm(filePath, { recursive: true }); + await rename(parkedPath, filePath); + await gate.waitForHeldReadyFileUpdate(); + await fileCallout(page).getByRole("button", { name: "Retry" }).click(); + + await expect(fileCallout(page)).toHaveCount(0); + await expect(filePane(page).getByText("Changed on disk", { exact: true })).toHaveCount(0); + gate.releaseHeldReadyFileUpdate(); + }); + + test("a read-only file error preserves its message and offers retry", async ({ + page, + withWorkspace, + }) => { + const gate = await installDaemonWebSocketGate(page); + const relativePath = "artifact.bin"; + const binaryContent = new Uint8Array([0, 1, 2, 3, 255]); + const filePath = await openTrackedFile(page, withWorkspace, { + prefix: "file-read-only-retry-", + relativePath, + content: binaryContent, + }); + await gate.waitForFileSubscription(relativePath); + await expect( + filePane(page).getByText("Binary preview unavailable", { exact: true }), + ).toBeVisible(); + + await unlink(filePath); + await mkdir(filePath); + await expect( + filePane(page).getByText("Requested path is not a file", { exact: true }), + ).toBeVisible(); + await expect(filePane(page).getByRole("button", { name: "Retry" })).toBeEnabled(); + + gate.holdNextReadyFileUpdate(relativePath); + await rm(filePath, { recursive: true }); + await writeFile(filePath, binaryContent); + await gate.waitForHeldReadyFileUpdate(); + gate.holdFileReads(relativePath); + await filePane(page).getByRole("button", { name: "Retry" }).click(); + await gate.waitForHeldFileRead(); + await expect( + filePane(page).getByText("Requested path is not a file", { exact: true }), + ).toHaveCount(0); + gate.releaseHeldFileRead(); + + await expect( + filePane(page).getByText("Binary preview unavailable", { exact: true }), + ).toBeVisible(); + await expect( + filePane(page).getByText("Requested path is not a file", { exact: true }), + ).toHaveCount(0); + gate.releaseHeldReadyFileUpdate(); + }); + + test("a changed file with local edits offers overwrite and a working reload", async ({ + page, + withWorkspace, + }) => { + const filePath = await openTrackedFile(page, withWorkspace, { + prefix: "file-dirty-conflict-", + relativePath: "source.ts", + content: "const before = true;\n", + }); + await replaceEditorText(page, "const local = true;\n"); + await writeFile(filePath, "const external = true;\n", "utf8"); + await expectDirtyConflictCanReload(page); + }); +}); diff --git a/packages/app/e2e/helpers/daemon-websocket-gate.ts b/packages/app/e2e/helpers/daemon-websocket-gate.ts index a6c7f3867..860dae5be 100644 --- a/packages/app/e2e/helpers/daemon-websocket-gate.ts +++ b/packages/app/e2e/helpers/daemon-websocket-gate.ts @@ -16,6 +16,21 @@ interface ClientRequest { type?: unknown; subscribe?: unknown; page?: { cursor?: unknown }; + mode?: unknown; + path?: unknown; +} + +interface ServerMessage { + type?: unknown; + payload?: { + initial?: { + path?: unknown; + }; + version?: { + status?: unknown; + path?: unknown; + }; + }; } function readClientRequest(message: string | Buffer): ClientRequest | null { @@ -31,6 +46,18 @@ function readClientRequest(message: string | Buffer): ClientRequest | null { } } +function readServerMessage(message: string | Buffer): ServerMessage | null { + try { + const envelope = JSON.parse(typeof message === "string" ? message : message.toString()) as { + type?: unknown; + message?: ServerMessage; + }; + return envelope.type === "session" ? (envelope.message ?? null) : envelope; + } catch { + return null; + } +} + function directoryForRequest(request: ClientRequest): keyof DirectoryBootstrapCounts | null { if (request.page?.cursor) return null; if (request.type === "fetch_agents_request") return "agents"; @@ -47,6 +74,16 @@ export async function installDaemonWebSocketGate(page: Page) { total: { agents: 0, workspaces: 0 }, }; const clientRequestCounts = new Map(); + const subscribedFilePaths = new Set(); + const fileSubscriptionWaiters = new Map void>(); + let fileReadPathToHold: string | null = null; + let heldFileReads: Array<() => void> = []; + let resolveHeldFileRead: (() => void) | null = null; + let heldFileReadPromise = Promise.resolve(); + let readyFileUpdatePathToHold: string | null = null; + let heldReadyFileUpdate: (() => void) | null = null; + let resolveHeldReadyFileUpdate: (() => void) | null = null; + let heldReadyFileUpdatePromise = Promise.resolve(); await page.routeWebSocket(daemonWsRoutePattern(), (ws) => { if (!acceptingConnections) { @@ -69,6 +106,15 @@ export async function installDaemonWebSocketGate(page: Page) { directoryStarts.total[directory] += 1; } } + if ( + request?.type === "file_explorer_request" && + request.mode === "file" && + request.path === fileReadPathToHold + ) { + heldFileReads.push(() => server.send(message)); + resolveHeldFileRead?.(); + return; + } try { server.send(message); } catch { @@ -78,6 +124,26 @@ export async function installDaemonWebSocketGate(page: Page) { server.onMessage((message) => { if (!acceptingConnections) return; + const serverMessage = readServerMessage(message); + if ( + serverMessage?.type === "fs.file.subscribe.response" && + typeof serverMessage.payload?.initial?.path === "string" + ) { + const path = serverMessage.payload.initial.path; + subscribedFilePaths.add(path); + fileSubscriptionWaiters.get(path)?.(); + fileSubscriptionWaiters.delete(path); + } + if ( + serverMessage?.type === "fs.file.update" && + serverMessage.payload?.version?.status === "ready" && + serverMessage.payload.version.path === readyFileUpdatePathToHold && + !heldReadyFileUpdate + ) { + heldReadyFileUpdate = () => ws.send(message); + resolveHeldReadyFileUpdate?.(); + return; + } try { ws.send(message); } catch { @@ -87,6 +153,44 @@ export async function installDaemonWebSocketGate(page: Page) { }); return { + waitForFileSubscription(path: string): Promise { + if (subscribedFilePaths.has(path)) return Promise.resolve(); + return new Promise((resolve) => fileSubscriptionWaiters.set(path, resolve)); + }, + holdFileReads(path: string): void { + fileReadPathToHold = path; + heldFileReads = []; + heldFileReadPromise = new Promise((resolve) => { + resolveHeldFileRead = resolve; + }); + }, + waitForHeldFileRead(): Promise { + return heldFileReadPromise; + }, + releaseHeldFileRead(): void { + const forwards = heldFileReads; + heldFileReads = []; + fileReadPathToHold = null; + resolveHeldFileRead = null; + for (const forward of forwards) forward(); + }, + holdNextReadyFileUpdate(path: string): void { + readyFileUpdatePathToHold = path; + heldReadyFileUpdate = null; + heldReadyFileUpdatePromise = new Promise((resolve) => { + resolveHeldReadyFileUpdate = resolve; + }); + }, + waitForHeldReadyFileUpdate(): Promise { + return heldReadyFileUpdatePromise; + }, + releaseHeldReadyFileUpdate(): void { + const forward = heldReadyFileUpdate; + heldReadyFileUpdate = null; + readyFileUpdatePathToHold = null; + resolveHeldReadyFileUpdate = null; + forward?.(); + }, async drop(): Promise { acceptingConnections = false; const sockets = Array.from(activeSockets); diff --git a/packages/app/src/file-pane/bar.tsx b/packages/app/src/file-pane/bar.tsx index e4574eaca..5784c072f 100644 --- a/packages/app/src/file-pane/bar.tsx +++ b/packages/app/src/file-pane/bar.tsx @@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next"; import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { SegmentedControl } from "@/components/ui/segmented-control"; import type { Theme } from "@/styles/theme"; -import { FileConflictAlert } from "./conflict-alert"; +import { FileConflictAlert, type FileConflictAlertState } from "./conflict-alert"; import type { FileEditorStatus } from "./editor/model"; const ThemedSpinner = withUnistyles(LoadingSpinner); @@ -18,9 +18,7 @@ export function FilePanelBar({ editorStatus, cursor, vimMode, - conflictUnavailable, - onOverwrite, - onReload, + conflict, }: { size: number; lineCount?: number; @@ -29,9 +27,7 @@ export function FilePanelBar({ editorStatus?: FileEditorStatus; cursor?: { line: number; column: number }; vimMode?: string | null; - conflictUnavailable?: boolean; - onOverwrite?(): void; - onReload?(): void; + conflict?: FileConflictAlertState; }) { const { t } = useTranslation(); const markdownModes = [ @@ -84,9 +80,6 @@ export function FilePanelBar({ {editorStatus === "error" ? ( {t("panels.file.editor.saveFailed")} ) : null} - {editorStatus === "conflict" ? ( - {t("panels.file.editor.changedOnDisk")} - ) : null} {vimMode ? ( ) : null} - {editorStatus === "conflict" && onOverwrite && onReload ? ( - - - - ) : null} + {conflict ? : null} ); } @@ -137,8 +122,6 @@ const styles = StyleSheet.create((theme) => ({ chrome: { flexShrink: 0, backgroundColor: theme.colors.surface1, - borderBottomWidth: 1, - borderBottomColor: theme.colors.border, }, row: { minHeight: 32, @@ -146,6 +129,8 @@ const styles = StyleSheet.create((theme) => ({ alignItems: "center", gap: theme.spacing[3], paddingHorizontal: theme.spacing[3], + borderBottomWidth: 1, + borderBottomColor: theme.colors.border, }, metadata: { flex: 1, @@ -174,5 +159,4 @@ const styles = StyleSheet.create((theme) => ({ fontFamily: theme.fontFamily.mono, fontSize: theme.fontSize.xs, }, - notice: { paddingHorizontal: theme.spacing[3], paddingBottom: theme.spacing[3] }, })); diff --git a/packages/app/src/file-pane/conflict-alert.tsx b/packages/app/src/file-pane/conflict-alert.tsx index 3babdcefb..106e16a8f 100644 --- a/packages/app/src/file-pane/conflict-alert.tsx +++ b/packages/app/src/file-pane/conflict-alert.tsx @@ -1,34 +1,86 @@ -import { Alert } from "@/components/ui/alert"; import { Button } from "@/components/ui/button"; +import { ICON_SIZE, type Theme } from "@/styles/theme"; +import { AlertTriangle } from "lucide-react-native"; import { useTranslation } from "react-i18next"; +import { Text, View } from "react-native"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; -export function FileConflictAlert({ - unavailable, - onOverwrite, - onReload, -}: { - unavailable: boolean; - onOverwrite(): void; - onReload(): void; -}) { +const ThemedAlertTriangle = withUnistyles(AlertTriangle); +const warningIconMapping = (theme: Theme) => ({ color: theme.colors.palette.amber[500] }); + +export type FileConflictAlertState = + | { kind: "changed"; canOverwrite: boolean; onReload(): void; onOverwrite(): void } + | { kind: "deleted" } + | { kind: "checkFailed"; retrying: boolean; onRetry(): void }; + +export function FileConflictAlert({ state }: { state: FileConflictAlertState }) { const { t } = useTranslation(); + let title = t("panels.file.editor.changedOnDisk"); + if (state.kind === "deleted") title = t("panels.file.editor.deletedTitle"); + else if (state.kind === "checkFailed") title = t("panels.file.editor.checkFailedTitle"); + let description: string | undefined; + if (state.kind !== "changed") description = t("panels.file.editor.preservedDescription"); + else if (state.canOverwrite) description = t("panels.file.editor.conflictDescription"); + return ( - - - - + + + + {title} + {description ? {description} : null} + + {state.kind !== "deleted" ? ( + + {state.kind === "changed" && state.canOverwrite ? ( + + ) : null} + {state.kind === "changed" ? ( + + ) : null} + {state.kind === "checkFailed" ? ( + + ) : null} + + ) : null} + ); } + +const styles = StyleSheet.create((theme) => ({ + container: { + minHeight: 48, + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[3], + paddingHorizontal: theme.spacing[3], + paddingVertical: theme.spacing[2], + borderBottomWidth: 1, + borderBottomColor: theme.colors.palette.amber[500], + backgroundColor: theme.colors.surface1, + }, + message: { + flex: 1, + minWidth: 0, + gap: theme.spacing[1], + }, + title: { + color: theme.colors.palette.amber[500], + fontSize: theme.fontSize.sm, + fontWeight: theme.fontWeight.medium, + }, + description: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.xs, + }, + actions: { + flexShrink: 0, + flexDirection: "row", + gap: theme.spacing[2], + }, +})); diff --git a/packages/app/src/file-pane/editor/model.test.ts b/packages/app/src/file-pane/editor/model.test.ts index 026e8bc44..966537c85 100644 --- a/packages/app/src/file-pane/editor/model.test.ts +++ b/packages/app/src/file-pane/editor/model.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from "vitest"; import type { FileVersion, FileWriteResult } from "@getpaseo/protocol/messages"; import { FileEditorModel, + getFileConflictCallout, type FileEditorClock, type FileEditorFile, type FileEditorSession, @@ -335,6 +336,45 @@ describe("FileEditorModel", () => { expect(session.writes).toEqual([]); }); + test("clears a transient check error when the recovered file is unchanged", () => { + const { model } = makeModel(); + model.receiveFileVersion({ + status: "error", + cwd: "/workspace", + path: "file.ts", + error: "Requested path is not a file", + }); + + model.receiveFileVersion(ready()); + + expect(model.getSnapshot()).toMatchObject({ + status: "clean", + modified: false, + observedVersion: { status: "ready" }, + error: null, + }); + }); + + test("resumes autosave when a dirty file recovers unchanged from a check error", async () => { + const { model, session, clock } = makeModel(); + model.edit("local"); + model.receiveFileVersion({ + status: "error", + cwd: "/workspace", + path: "file.ts", + error: "Requested path is not a file", + }); + + model.receiveFileVersion(ready()); + clock.fire(); + await Promise.resolve(); + + expect(model.getSnapshot()).toMatchObject({ status: "clean", modified: false }); + expect(session.writes).toEqual([ + { content: "local", expectedModifiedAt: "2026-07-18T00:00:00.000Z" }, + ]); + }); + test("dispose cancels pending autosave", () => { const { model, session, clock } = makeModel(); model.edit("local"); @@ -358,4 +398,38 @@ describe("FileEditorModel", () => { await Promise.resolve(); expect(session.writes).toHaveLength(1); }); + + test("maps conflict versions to one exhaustive callout state", () => { + const { model } = makeModel(); + const snapshot = model.getSnapshot(); + + expect(getFileConflictCallout(snapshot)).toBeNull(); + expect( + getFileConflictCallout({ + ...snapshot, + status: "conflict", + modified: true, + observedVersion: ready("2026-07-18T00:00:01.000Z"), + }), + ).toEqual({ kind: "changed", canOverwrite: true }); + expect( + getFileConflictCallout({ + ...snapshot, + status: "conflict", + observedVersion: { status: "missing", cwd: "/workspace", path: "file.ts" }, + }), + ).toEqual({ kind: "deleted" }); + expect( + getFileConflictCallout({ + ...snapshot, + status: "conflict", + observedVersion: { + status: "error", + cwd: "/workspace", + path: "file.ts", + error: "unreadable", + }, + }), + ).toEqual({ kind: "checkFailed" }); + }); }); diff --git a/packages/app/src/file-pane/editor/model.ts b/packages/app/src/file-pane/editor/model.ts index 48cc77e95..79b736899 100644 --- a/packages/app/src/file-pane/editor/model.ts +++ b/packages/app/src/file-pane/editor/model.ts @@ -13,6 +13,11 @@ export interface FileEditorSnapshot { error: string | null; } +export type FileConflictCallout = + | { kind: "changed"; canOverwrite: boolean } + | { kind: "deleted" } + | { kind: "checkFailed" }; + export interface FileEditorFile { content: string; hasBom: boolean; @@ -28,6 +33,11 @@ export interface FileEditorSession { }): Promise; } +export interface FileVersionSource { + subscribe(listener: () => void): () => void; + getVersion(): FileVersion | null; +} + export interface FileEditorClock { setTimeout(callback: () => void, delayMs: number): ReturnType; clearTimeout(handle: ReturnType): void; @@ -53,6 +63,7 @@ export class FileEditorModel { private observedWhileSaving: FileVersion | null = null; private persistedContent: string; private hasBom: boolean; + private unsubscribeVersionSource: (() => void) | null = null; constructor(input: { file: FileEditorFile; @@ -81,6 +92,21 @@ export class FileEditorModel { getSnapshot = (): FileEditorSnapshot => this.snapshot; + connectFileVersions(source: FileVersionSource): void { + this.disconnectFileVersions(); + const receiveVersion = () => { + const version = source.getVersion(); + if (version) this.receiveFileVersion(version); + }; + this.unsubscribeVersionSource = source.subscribe(receiveVersion); + receiveVersion(); + } + + disconnectFileVersions(): void { + this.unsubscribeVersionSource?.(); + this.unsubscribeVersionSource = null; + } + edit(content: string): void { if (this.disposed || content === this.snapshot.content) return; const modified = content !== this.persistedContent; @@ -124,6 +150,7 @@ export class FileEditorModel { } return; } + if (this.restoreUnchangedConflict(version)) return; this.setSnapshot({ ...this.snapshot, observedVersion: version }); if (this.snapshot.status === "saving") { this.observedWhileSaving = version; @@ -151,6 +178,7 @@ export class FileEditorModel { this.disposed = true; this.saveSequence += 1; this.clearAutosave(); + this.disconnectFileVersions(); this.listeners.clear(); } @@ -279,6 +307,29 @@ export class FileEditorModel { }); } + private restoreUnchangedConflict(version: FileVersion): boolean { + if ( + this.snapshot.status !== "conflict" || + version.status !== "ready" || + this.snapshot.version.status !== "ready" || + !sameVersion(version, this.snapshot.version) + ) { + return false; + } + const modified = this.snapshot.content !== this.persistedContent; + this.setSnapshot({ + ...this.snapshot, + status: modified ? "dirty" : "clean", + modified, + version, + observedVersion: version, + error: null, + }); + if (modified) this.scheduleAutosave(); + else this.clearAutosave(); + return true; + } + private scheduleAutosave(): void { this.clearAutosave(); this.autosave = this.clock.setTimeout(() => { @@ -299,6 +350,24 @@ export class FileEditorModel { } } +export function getFileConflictCallout(snapshot: FileEditorSnapshot): FileConflictCallout | null { + if (snapshot.status !== "conflict") return null; + switch (snapshot.observedVersion.status) { + case "ready": + return { kind: "changed", canOverwrite: snapshot.modified }; + case "missing": + return { kind: "deleted" }; + case "error": + return { kind: "checkFailed" }; + default: + return assertNever(snapshot.observedVersion); + } +} + +function assertNever(value: never): never { + throw new Error(`Unexpected file version: ${JSON.stringify(value)}`); +} + function detectLineSeparator(content: string): FileLineSeparator { for (let index = 0; index < content.length; index += 1) { const character = content.charCodeAt(index); diff --git a/packages/app/src/file-pane/live-file.ts b/packages/app/src/file-pane/live-file.ts deleted file mode 100644 index c76d66afb..000000000 --- a/packages/app/src/file-pane/live-file.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { useEffect, useMemo, useRef, useState } from "react"; -import { useQueryClient } from "@tanstack/react-query"; -import type { DaemonClient, FileReadResult } from "@getpaseo/client/internal/daemon-client"; -import type { FileVersion } from "@getpaseo/protocol/messages"; -import { useFetchQuery } from "@/data/query"; - -export function useLiveFile(input: { - client: DaemonClient | null; - serverId: string; - cwd: string | null; - path: string | null; - enabled: boolean; - liveUpdates: boolean; -}) { - const queryClient = useQueryClient(); - const [subscriptionReady, setSubscriptionReady] = useState(!input.liveUpdates); - const [version, setVersion] = useState(null); - const latestVersion = useRef(null); - const queryKey = useMemo( - () => ["workspaceFile", input.serverId, input.cwd, input.path] as const, - [input.cwd, input.path, input.serverId], - ); - - useEffect(() => { - latestVersion.current = null; - setVersion(null); - const { client, cwd, path } = input; - if (!input.liveUpdates || !client || !cwd || !path || !input.enabled) { - setSubscriptionReady(!input.liveUpdates); - return; - } - let disposed = false; - let unsubscribe: (() => void) | null = null; - setSubscriptionReady(false); - void (async () => { - try { - const subscription = await client.subscribeFile({ cwd, path }, (next) => { - if (disposed) return; - latestVersion.current = next; - setVersion(next); - void queryClient.invalidateQueries({ queryKey }); - }); - if (disposed) { - subscription.unsubscribe(); - return; - } - unsubscribe = subscription.unsubscribe; - latestVersion.current = subscription.initial; - setVersion(subscription.initial); - setSubscriptionReady(true); - } catch { - if (!disposed) setSubscriptionReady(true); - } - })(); - return () => { - disposed = true; - unsubscribe?.(); - }; - }, [ - input.client, - input.cwd, - input.enabled, - input.liveUpdates, - input.path, - queryClient, - queryKey, - input.serverId, - ]); - - const query = useFetchQuery({ - queryKey, - enabled: input.enabled && Boolean(input.client && input.cwd && input.path) && subscriptionReady, - queryFn: async (): Promise => { - if (!input.client || !input.cwd || !input.path) throw new Error("File unavailable."); - return input.client.readFile(input.cwd, input.path); - }, - dataShape: "value", - staleTimeMs: 5_000, - }); - - useEffect(() => { - const observed = latestVersion.current; - if ( - query.data && - observed?.status === "ready" && - query.data.modifiedAt !== observed.modifiedAt - ) { - void queryClient.invalidateQueries({ queryKey }); - } - }, [query.data, queryClient, queryKey]); - - return { query, version }; -} diff --git a/packages/app/src/file-pane/live-file/hook.ts b/packages/app/src/file-pane/live-file/hook.ts new file mode 100644 index 000000000..b8b4bf025 --- /dev/null +++ b/packages/app/src/file-pane/live-file/hook.ts @@ -0,0 +1,48 @@ +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import { useEffect, useMemo, useState, useSyncExternalStore } from "react"; +import { LiveFileModel, type LiveFileSession } from "./model"; + +export function useLiveFile(input: { + client: DaemonClient | null; + cwd: string | null; + path: string | null; + enabled: boolean; + liveUpdates: boolean; +}) { + const [model] = useState(() => new LiveFileModel()); + const session = useMemo(() => { + if (!input.client) return null; + const client = input.client; + return { + subscribe(target, onVersion) { + return client.subscribeFile(target, onVersion); + }, + read(target) { + return client.readFile(target.cwd, target.path); + }, + }; + }, [input.client]); + + useEffect(() => { + if (!input.enabled || !session || !input.cwd || !input.path) { + model.close(); + return; + } + model.open({ + session, + target: { cwd: input.cwd, path: input.path }, + liveUpdates: input.liveUpdates, + }); + return () => model.close(); + }, [input.cwd, input.enabled, input.liveUpdates, input.path, model, session]); + + const snapshot = useSyncExternalStore(model.subscribe, model.getSnapshot, model.getSnapshot); + return { + file: snapshot.file, + error: snapshot.read.status === "error" ? snapshot.read.error : null, + isFetching: snapshot.read.status === "pending", + isRetrying: snapshot.read.status === "pending" && snapshot.read.requested, + refresh: model.refresh, + model, + }; +} diff --git a/packages/app/src/file-pane/live-file/model.test.ts b/packages/app/src/file-pane/live-file/model.test.ts new file mode 100644 index 000000000..d29a7d5f2 --- /dev/null +++ b/packages/app/src/file-pane/live-file/model.test.ts @@ -0,0 +1,255 @@ +import type { FileReadResult } from "@getpaseo/client/internal/daemon-client"; +import type { FileVersion } from "@getpaseo/protocol/messages"; +import { describe, expect, it, vi } from "vitest"; +import { LiveFileModel, type LiveFileSession, type LiveFileSubscription } from "./model"; + +interface Deferred { + promise: Promise; + resolve(value: T): void; + reject(error: Error): void; +} + +function deferred(): Deferred { + let resolve!: (value: T) => void; + let reject!: (error: Error) => void; + const promise = new Promise((onResolve, onReject) => { + resolve = onResolve; + reject = onReject; + }); + return { promise, resolve, reject }; +} + +function ready(revision = "revision-1"): Extract { + return { + status: "ready", + cwd: "/workspace", + path: "file.ts", + size: 3, + modifiedAt: "2026-07-30T00:00:00.000Z", + revision, + }; +} + +function file(revision = "revision-1", content = "one"): FileReadResult { + return { + bytes: new TextEncoder().encode(content), + mime: "text/plain", + size: content.length, + path: "file.ts", + kind: "text", + modifiedAt: "2026-07-30T00:00:00.000Z", + revision, + }; +} + +class TestLiveFileSession implements LiveFileSession { + readonly reads: Deferred[] = []; + subscribeAttempts = 0; + private onVersion: ((version: FileVersion) => void) | null = null; + private readonly initial: FileVersion; + + constructor(initial: FileVersion = ready()) { + this.initial = initial; + } + + async subscribe( + _target: { cwd: string; path: string }, + onVersion: (version: FileVersion) => void, + ): Promise { + this.subscribeAttempts += 1; + this.onVersion = onVersion; + return { initial: this.initial, unsubscribe: () => (this.onVersion = null) }; + } + + read(): Promise { + const request = deferred(); + this.reads.push(request); + return request.promise; + } + + emit(version: FileVersion): void { + this.onVersion?.(version); + } +} + +class DeferredSubscribeSession implements LiveFileSession { + readonly reads: Deferred[] = []; + readonly subscription = deferred(); + private onVersion: ((version: FileVersion) => void) | null = null; + + subscribe( + _target: { cwd: string; path: string }, + onVersion: (version: FileVersion) => void, + ): Promise { + this.onVersion = onVersion; + return this.subscription.promise; + } + + read(): Promise { + const request = deferred(); + this.reads.push(request); + return request.promise; + } + + emit(version: FileVersion): void { + this.onVersion?.(version); + } +} + +class FlakySubscriptionSession extends TestLiveFileSession { + override async subscribe( + target: { cwd: string; path: string }, + onVersion: (version: FileVersion) => void, + ): Promise { + if (this.subscribeAttempts === 0) { + this.subscribeAttempts += 1; + throw new Error("Connection closed."); + } + return super.subscribe(target, onVersion); + } +} + +async function waitForRead( + session: { reads: Deferred[] }, + count: number, +): Promise { + await vi.waitFor(() => expect(session.reads).toHaveLength(count)); +} + +describe("LiveFileModel", () => { + it("recovers only from a read begun after a missing observation", async () => { + const session = new TestLiveFileSession(); + const model = new LiveFileModel(); + model.open({ + session, + target: { cwd: "/workspace", path: "file.ts" }, + liveUpdates: true, + }); + await waitForRead(session, 1); + session.reads[0].resolve(file()); + await vi.waitFor(() => expect(model.getSnapshot().file).toEqual(file())); + + model.refresh(); + await waitForRead(session, 2); + session.emit({ status: "missing", cwd: "/workspace", path: "file.ts" }); + session.reads[1].resolve(file("revision-2", "two")); + + await waitForRead(session, 3); + expect(model.getSnapshot().version?.status).toBe("missing"); + expect(model.getSnapshot().read).toEqual({ status: "pending", requested: true }); + + session.reads[2].resolve(file("revision-2", "two")); + await vi.waitFor(() => { + expect(model.getSnapshot()).toMatchObject({ + version: { status: "ready", revision: "revision-2" }, + file: { revision: "revision-2" }, + read: { status: "idle" }, + }); + }); + }); + + it("keeps an actionable error visible while refresh fails", async () => { + const session = new TestLiveFileSession(); + const model = new LiveFileModel(); + model.open({ + session, + target: { cwd: "/workspace", path: "file.ts" }, + liveUpdates: true, + }); + await waitForRead(session, 1); + session.reads[0].resolve(file()); + await vi.waitFor(() => expect(model.getSnapshot().file).toEqual(file())); + + session.emit({ + status: "error", + cwd: "/workspace", + path: "file.ts", + error: "Requested path is not a file", + }); + await waitForRead(session, 2); + expect(model.getSnapshot().read).toEqual({ status: "pending", requested: false }); + session.reads[1].reject(new Error("File unavailable.")); + + await vi.waitFor(() => { + expect(model.getSnapshot()).toMatchObject({ + version: { status: "error", error: "Requested path is not a file" }, + read: { status: "error", error: "File unavailable." }, + }); + }); + + model.refresh(); + await waitForRead(session, 3); + expect(model.getSnapshot().read).toEqual({ status: "pending", requested: true }); + }); + + it("keeps an update that arrives before the subscription response", async () => { + const session = new DeferredSubscribeSession(); + const model = new LiveFileModel(); + model.open({ + session, + target: { cwd: "/workspace", path: "file.ts" }, + liveUpdates: true, + }); + + session.emit(ready("revision-2")); + session.subscription.resolve({ initial: ready("revision-1"), unsubscribe() {} }); + + await waitForRead(session, 1); + expect(model.getVersion()).toMatchObject({ status: "ready", revision: "revision-2" }); + }); + + it("retries a failed subscription when the user refreshes", async () => { + const session = new FlakySubscriptionSession(); + const model = new LiveFileModel(); + model.open({ + session, + target: { cwd: "/workspace", path: "file.ts" }, + liveUpdates: true, + }); + await waitForRead(session, 1); + expect(session.subscribeAttempts).toBe(1); + + session.reads[0].resolve(file()); + await vi.waitFor(() => expect(model.getVersion()?.status).toBe("ready")); + model.refresh(); + + await waitForRead(session, 2); + expect(session.subscribeAttempts).toBe(2); + expect(model.getSnapshot().read).toEqual({ status: "pending", requested: true }); + }); + + it("reads once without subscribing when live updates are disabled", async () => { + const session = new TestLiveFileSession(); + const model = new LiveFileModel(); + model.open({ + session, + target: { cwd: "/workspace", path: "file.ts" }, + liveUpdates: false, + }); + + await waitForRead(session, 1); + expect(session.subscribeAttempts).toBe(0); + expect(model.getSnapshot().read).toEqual({ status: "pending", requested: false }); + }); + + it("ignores a read that completes after the model closes", async () => { + const session = new TestLiveFileSession(); + const model = new LiveFileModel(); + model.open({ + session, + target: { cwd: "/workspace", path: "file.ts" }, + liveUpdates: true, + }); + await waitForRead(session, 1); + + model.close(); + session.reads[0].resolve(file()); + await Promise.resolve(); + + expect(model.getSnapshot()).toEqual({ + file: null, + version: null, + read: { status: "idle" }, + }); + }); +}); diff --git a/packages/app/src/file-pane/live-file/model.ts b/packages/app/src/file-pane/live-file/model.ts new file mode 100644 index 000000000..478742329 --- /dev/null +++ b/packages/app/src/file-pane/live-file/model.ts @@ -0,0 +1,257 @@ +import type { FileReadResult } from "@getpaseo/client/internal/daemon-client"; +import type { FileVersion } from "@getpaseo/protocol/messages"; + +export interface LiveFileTarget { + cwd: string; + path: string; +} + +export interface LiveFileSubscription { + initial: FileVersion; + unsubscribe(): void; +} + +export interface LiveFileSession { + subscribe( + target: LiveFileTarget, + onVersion: (version: FileVersion) => void, + ): Promise; + read(target: LiveFileTarget): Promise; +} + +export type LiveFileReadState = + | { status: "idle" } + | { status: "pending"; requested: boolean } + | { status: "error"; error: string }; + +export interface LiveFileSnapshot { + file: FileReadResult | null; + version: FileVersion | null; + read: LiveFileReadState; +} + +const idleSnapshot: LiveFileSnapshot = { + file: null, + version: null, + read: { status: "idle" }, +}; + +export class LiveFileModel { + private readonly listeners = new Set<() => void>(); + private snapshot: LiveFileSnapshot = idleSnapshot; + private session: LiveFileSession | null = null; + private target: LiveFileTarget | null = null; + private liveUpdates = false; + private unsubscribe: (() => void) | null = null; + private subscriptionPending = false; + private generation = 0; + private issuedReadSequence = 0; + private activeReadSequence: number | null = null; + private activeReadRequested = false; + private observedAfterReadSequence: number | null = null; + private refreshQueued = false; + private requestedRefreshQueued = false; + + subscribe = (listener: () => void): (() => void) => { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + }; + + getSnapshot = (): LiveFileSnapshot => this.snapshot; + + getVersion = (): FileVersion | null => this.snapshot.version; + + open(input: { session: LiveFileSession; target: LiveFileTarget; liveUpdates: boolean }): void { + this.reset(); + this.session = input.session; + this.target = input.target; + this.liveUpdates = input.liveUpdates; + const generation = this.generation; + if (!input.liveUpdates) { + this.scheduleRead(false); + return; + } + void this.startSubscription(generation); + } + + close(): void { + this.reset(); + } + + refresh = (): void => { + if (!this.session || !this.target) return; + if (this.liveUpdates && !this.unsubscribe && !this.subscriptionPending) { + void this.startSubscription(this.generation); + } + this.scheduleRead(true); + }; + + private scheduleRead(requested: boolean): void { + if (this.activeReadSequence !== null) { + this.refreshQueued = true; + this.activeReadRequested ||= requested; + this.requestedRefreshQueued ||= requested; + if (requested && this.snapshot.read.status === "pending") { + this.setSnapshot({ ...this.snapshot, read: { status: "pending", requested: true } }); + } + return; + } + this.startRead(requested); + } + + private reset(): void { + this.generation += 1; + this.unsubscribe?.(); + this.unsubscribe = null; + this.session = null; + this.target = null; + this.liveUpdates = false; + this.subscriptionPending = false; + this.issuedReadSequence = 0; + this.activeReadSequence = null; + this.activeReadRequested = false; + this.observedAfterReadSequence = null; + this.refreshQueued = false; + this.requestedRefreshQueued = false; + this.setSnapshot(idleSnapshot); + } + + private async startSubscription(generation: number): Promise { + const session = this.session; + const target = this.target; + if (!session || !target) return; + this.subscriptionPending = true; + let receivedUpdate = false; + try { + const subscription = await session.subscribe(target, (version) => { + if (generation !== this.generation) return; + receivedUpdate = true; + this.observeVersion(version); + }); + if (generation !== this.generation) { + subscription.unsubscribe(); + return; + } + this.subscriptionPending = false; + this.unsubscribe = subscription.unsubscribe; + if (!receivedUpdate) this.observeVersion(subscription.initial); + } catch { + if (generation !== this.generation) return; + this.subscriptionPending = false; + this.scheduleRead(false); + } + } + + private observeVersion(version: FileVersion): void { + this.observedAfterReadSequence = this.issuedReadSequence; + this.setSnapshot({ ...this.snapshot, version }); + this.scheduleRead(false); + } + + private startRead(requested: boolean): void { + const session = this.session; + const target = this.target; + if (!session || !target) return; + const generation = this.generation; + const sequence = ++this.issuedReadSequence; + this.activeReadSequence = sequence; + this.activeReadRequested = requested; + this.refreshQueued = false; + this.requestedRefreshQueued = false; + this.setSnapshot({ ...this.snapshot, read: { status: "pending", requested } }); + void session.read(target).then( + (file) => this.finishRead({ generation, sequence, target, file }), + (error) => this.failRead({ generation, sequence, target, error }), + ); + } + + private finishRead(input: { + generation: number; + sequence: number; + target: LiveFileTarget; + file: FileReadResult; + }): void { + if (!this.isActiveRead(input.generation, input.sequence)) return; + const requested = this.activeReadRequested; + this.activeReadSequence = null; + this.activeReadRequested = false; + if (this.readPredatesLatestObservation(input.sequence)) { + this.startRead(requested || this.takeQueuedRequested()); + return; + } + this.observedAfterReadSequence = null; + this.setSnapshot({ + file: input.file, + version: readyVersion(input.target, input.file), + read: { status: "idle" }, + }); + this.startQueuedRead(); + } + + private failRead(input: { + generation: number; + sequence: number; + target: LiveFileTarget; + error: unknown; + }): void { + if (!this.isActiveRead(input.generation, input.sequence)) return; + const requested = this.activeReadRequested; + this.activeReadSequence = null; + this.activeReadRequested = false; + if (this.readPredatesLatestObservation(input.sequence)) { + this.startRead(requested || this.takeQueuedRequested()); + return; + } + const message = errorMessage(input.error); + let version = this.snapshot.version; + if (version?.status !== "missing" && version?.status !== "error") { + version = { status: "error", cwd: input.target.cwd, path: input.target.path, error: message }; + } + this.setSnapshot({ ...this.snapshot, version, read: { status: "error", error: message } }); + this.startQueuedRead(); + } + + private isActiveRead(generation: number, sequence: number): boolean { + return generation === this.generation && sequence === this.activeReadSequence; + } + + private readPredatesLatestObservation(sequence: number): boolean { + return this.observedAfterReadSequence !== null && sequence <= this.observedAfterReadSequence; + } + + private startQueuedRead(): void { + if (!this.refreshQueued) return; + this.startRead(this.takeQueuedRequested()); + } + + private takeQueuedRequested(): boolean { + const requested = this.requestedRefreshQueued; + this.refreshQueued = false; + this.requestedRefreshQueued = false; + return requested; + } + + private setSnapshot(snapshot: LiveFileSnapshot): void { + if (snapshot === this.snapshot) return; + this.snapshot = snapshot; + for (const listener of this.listeners) listener(); + } +} + +function readyVersion( + target: LiveFileTarget, + file: FileReadResult, +): Extract { + return { + status: "ready", + cwd: target.cwd, + path: target.path, + size: file.size, + modifiedAt: file.modifiedAt, + revision: file.revision, + }; +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/packages/app/src/file-pane/pane.tsx b/packages/app/src/file-pane/pane.tsx index eb902eb33..f042d6628 100644 --- a/packages/app/src/file-pane/pane.tsx +++ b/packages/app/src/file-pane/pane.tsx @@ -1,3 +1,4 @@ +import { Button } from "@/components/ui/button"; import { LoadingSpinner } from "@/components/ui/loading-spinner"; import React, { useCallback, @@ -8,7 +9,6 @@ import React, { useSyncExternalStore, } from "react"; import type { DaemonClient, FileReadResult } from "@getpaseo/client/internal/daemon-client"; -import type { FileVersion } from "@getpaseo/protocol/messages"; import { Image as RNImage, ScrollView as RNScrollView, Text, View } from "react-native"; import { StyleSheet, UnistylesRuntime, withUnistyles } from "react-native-unistyles"; import { useTranslation } from "react-i18next"; @@ -33,10 +33,17 @@ import { useAppActivelyVisible } from "@/hooks/use-app-visible"; import { isFileQueryEnabled } from "@/components/file-pane-enabled"; import { isWeb } from "@/constants/platform"; import { useAppSettings } from "@/hooks/use-settings"; -import { useLiveFile } from "./live-file"; +import { useLiveFile } from "./live-file/hook"; import { FilePanelBar } from "./bar"; -import { FileEditorModel, type FileEditorFile } from "./editor/model"; +import { + FileEditorModel, + getFileConflictCallout, + type FileConflictCallout, + type FileEditorFile, +} from "./editor/model"; import { FileEditorView } from "./editor/view"; +import type { FileConflictAlertState } from "./conflict-alert"; +import type { LiveFileModel } from "./live-file/model"; import { confirmDialog } from "@/utils/confirm-dialog"; import { usePublishPanelInstanceAttributes } from "@/panels/panel-instance-attributes"; import type { Theme } from "@/styles/theme"; @@ -427,9 +434,8 @@ export function FilePane({ isTabActive: isActive, isAppVisible, }); - const { query, version } = useLiveFile({ + const liveFile = useLiveFile({ client, - serverId, cwd: readTarget?.cwd ?? null, path: readTarget?.path ?? null, enabled, @@ -437,16 +443,17 @@ export function FilePane({ }); useEffect(() => { + if (!liveFile.file) return; let active = true; const key = readTarget ? `${readTarget.cwd}:${readTarget.path}` : null; void (async () => { - const nextPreview = await createFilePanePreview(query.data ?? null); + const nextPreview = await createFilePanePreview(liveFile.file); if (active) setResolvedPreview({ key, ...nextPreview }); })(); return () => { active = false; }; - }, [query.data, readTarget]); + }, [liveFile.file, readTarget]); useEffect(() => setMarkdownMode("preview"), [readTarget?.path]); @@ -463,7 +470,7 @@ export function FilePane({ const canToggleMarkdownMode = isMarkdown && editable; const lineCount = preview?.kind === "text" ? (preview.content ?? "").split("\n").length : undefined; - const errorMessage = getFileErrorMessage(query.error, t("panels.file.failedToLoad")); + const errorMessage = getFileErrorMessage(liveFile.error, t("panels.file.failedToLoad")); return ( void; + retryingRead: boolean; + retryLabel: string; filename: string; markdownMode?: "preview" | "source"; onMarkdownModeChange?: (mode: "preview" | "source") => void; @@ -564,7 +581,9 @@ function FilePanePresentation({ cwd={readTarget.cwd} path={readTarget.path} preview={preview as TextExplorerFile} - version={version} + liveFile={liveFile} + onRetryRead={onRetryRead} + retryingRead={retryingRead} filename={filename} mode={markdownMode} onModeChange={onMarkdownModeChange} @@ -576,6 +595,19 @@ function FilePanePresentation({ ); } + if (errorMessage) { + return ( + + + {errorMessage} + + + + ); + } + return ( {preview ? ( @@ -586,12 +618,6 @@ function FilePanePresentation({ onModeChange={onMarkdownModeChange} /> ) : null} - {errorMessage ? ( - - {errorMessage} - - ) : null} - void; + retryingRead: boolean; filename: string; mode?: "preview" | "source"; onModeChange?: (mode: "preview" | "source") => void; @@ -662,23 +692,26 @@ function EditableFilePane({ }), [client, cwd, path], ); - const [model] = useState( - () => - new FileEditorModel({ - file: { - content: preview.content ?? "", - hasBom: preview.hasBom, - version: { - status: "ready", - cwd, - path, - size: preview.size, - modifiedAt: preview.modifiedAt, - }, + const [model] = useState(() => { + return new FileEditorModel({ + file: { + content: preview.content ?? "", + hasBom: preview.hasBom, + version: { + status: "ready", + cwd, + path, + size: preview.size, + modifiedAt: preview.modifiedAt, }, - session, - }), - ); + }, + session, + }); + }); + useEffect(() => { + model.connectFileVersions(liveFile); + return () => model.disconnectFileVersions(); + }, [liveFile, model]); const snapshot = useSyncExternalStore(model.subscribe, model.getSnapshot, model.getSnapshot); const suspendPendingSave = useCallback(() => model.suspendAutosave(), [model]); usePublishPanelInstanceAttributes({ modified: snapshot.modified, suspendPendingSave }); @@ -711,11 +744,12 @@ function EditableFilePane({ ); useEffect(() => () => model.dispose(), [model]); - useEffect(() => { - if (version) model.receiveFileVersion(version); - }, [model, version]); const handleReload = useCallback(() => { + if (!snapshot.modified) { + void model.reload(); + return; + } void (async () => { const confirmed = await confirmDialog({ title: t("panels.file.editor.reloadTitle"), @@ -725,8 +759,15 @@ function EditableFilePane({ }); if (confirmed) void model.reload(); })(); - }, [model, t]); + }, [model, snapshot.modified, t]); const handleOverwrite = useCallback(() => void model.overwrite(), [model]); + const conflict = fileConflictAlertState({ + callout: getFileConflictCallout(snapshot), + onOverwrite: handleOverwrite, + onReload: handleReload, + onRetry: onRetryRead, + retrying: retryingRead, + }); const handleVimModeChange = useCallback((nextMode: string | null) => setVimMode(nextMode), []); const renderedPreview = useMemo( () => ({ @@ -750,9 +791,7 @@ function EditableFilePane({ editorStatus={snapshot.status} cursor={showSource ? cursor : undefined} vimMode={showSource ? vimMode : null} - conflictUnavailable={snapshot.observedVersion.status !== "ready"} - onOverwrite={handleOverwrite} - onReload={handleReload} + conflict={conflict} mode={mode} onModeChange={onModeChange} /> @@ -781,6 +820,26 @@ function EditableFilePane({ ); } +function fileConflictAlertState(input: { + callout: FileConflictCallout | null; + onOverwrite(): void; + onReload(): void; + onRetry(): void; + retrying: boolean; +}): FileConflictAlertState | undefined { + if (!input.callout) return undefined; + if (input.callout.kind === "deleted") return { kind: "deleted" }; + if (input.callout.kind === "checkFailed") { + return { kind: "checkFailed", retrying: input.retrying, onRetry: input.onRetry }; + } + return { + kind: "changed", + canOverwrite: input.callout.canOverwrite, + onReload: input.onReload, + onOverwrite: input.onOverwrite, + }; +} + const styles = StyleSheet.create((theme) => ({ container: { flex: 1, @@ -791,6 +850,7 @@ const styles = StyleSheet.create((theme) => ({ flex: 1, alignItems: "center", justifyContent: "center", + gap: theme.spacing[3], padding: theme.spacing[4], }, loadingText: { diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index 48d82e425..c60d7ea16 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -1491,7 +1491,9 @@ export const ar: TranslationResources = { cursor: "السطر {{line}}، العمود {{column}}", preview: "معاينة", source: "المصدر", - unavailableTitle: "الملف غير متاح على القرص", + deletedTitle: "تم حذف الملف من القرص", + checkFailedTitle: "تعذّر التحقق من الملف على القرص", + preservedDescription: "تم الاحتفاظ بالنسخة المفتوحة.", conflictDescription: "تم الاحتفاظ بالمحتوى المحلي. اختر الإصدار الذي تريد إبقاءه.", overwrite: "استبدال", reload: "إعادة تحميل", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index a7fd2d1aa..b3407ebf3 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -1502,7 +1502,9 @@ export const en = { cursor: "Line {{line}}, column {{column}}", preview: "Preview", source: "Source", - unavailableTitle: "File unavailable on disk", + deletedTitle: "File deleted on disk", + checkFailedTitle: "Couldn't check file on disk", + preservedDescription: "The open copy is preserved.", conflictDescription: "The local buffer was preserved. Choose which version to keep.", overwrite: "Overwrite", reload: "Reload", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 1abd7cc05..7c9f20eab 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -1534,7 +1534,9 @@ export const es: TranslationResources = { cursor: "Línea {{line}}, columna {{column}}", preview: "Vista previa", source: "Código fuente", - unavailableTitle: "Archivo no disponible en disco", + deletedTitle: "Archivo eliminado del disco", + checkFailedTitle: "No se pudo comprobar el archivo en disco", + preservedDescription: "La copia abierta se ha conservado.", conflictDescription: "Se conservó el búfer local. Elige qué versión mantener.", overwrite: "Sobrescribir", reload: "Recargar", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 58dc392cf..6b6b13885 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -1537,7 +1537,9 @@ export const fr: TranslationResources = { cursor: "Ligne {{line}}, colonne {{column}}", preview: "Aperçu", source: "Source", - unavailableTitle: "Fichier indisponible sur le disque", + deletedTitle: "Fichier supprimé du disque", + checkFailedTitle: "Impossible de vérifier le fichier sur le disque", + preservedDescription: "La copie ouverte est conservée.", conflictDescription: "Le contenu local a été conservé. Choisissez la version à garder.", overwrite: "Écraser", reload: "Recharger", diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index 0be4e6e98..21bc2106e 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -1507,7 +1507,9 @@ export const ja: TranslationResources = { cursor: "{{line}} 行、{{column}} 列", preview: "プレビュー", source: "ソース", - unavailableTitle: "ディスク上のファイルを利用できません", + deletedTitle: "ファイルはディスク上から削除されました", + checkFailedTitle: "ディスク上のファイルを確認できませんでした", + preservedDescription: "開いているコピーは保持されています。", conflictDescription: "ローカルの内容は保持されています。残すバージョンを選択してください。", overwrite: "上書き", reload: "再読み込み", diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index 51c9553fa..2eeae3863 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -1520,7 +1520,9 @@ export const ptBR: TranslationResources = { cursor: "Linha {{line}}, coluna {{column}}", preview: "Prévia", source: "Fonte", - unavailableTitle: "Arquivo indisponível no disco", + deletedTitle: "Arquivo excluído do disco", + checkFailedTitle: "Não foi possível verificar o arquivo no disco", + preservedDescription: "A cópia aberta foi preservada.", conflictDescription: "O conteúdo local foi preservado. Escolha qual versão manter.", overwrite: "Sobrescrever", reload: "Recarregar", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index d546a9218..355c8657f 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -1525,7 +1525,9 @@ export const ru: TranslationResources = { cursor: "Строка {{line}}, столбец {{column}}", preview: "Просмотр", source: "Исходник", - unavailableTitle: "Файл недоступен на диске", + deletedTitle: "Файл удалён с диска", + checkFailedTitle: "Не удалось проверить файл на диске", + preservedDescription: "Открытая копия сохранена.", conflictDescription: "Локальный буфер сохранён. Выберите версию, которую нужно оставить.", overwrite: "Перезаписать", reload: "Перезагрузить", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index eb22734bd..645d84076 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -1472,7 +1472,9 @@ export const zhCN: TranslationResources = { cursor: "第 {{line}} 行,第 {{column}} 列", preview: "预览", source: "源代码", - unavailableTitle: "磁盘上的文件不可用", + deletedTitle: "文件已从磁盘删除", + checkFailedTitle: "无法检查磁盘上的文件", + preservedDescription: "打开的副本已保留。", conflictDescription: "本地内容已保留。请选择要保留的版本。", overwrite: "覆盖", reload: "重新加载",