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.
This commit is contained in:
@@ -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<void> {
|
||||||
|
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<string> {
|
||||||
|
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<void> {
|
||||||
|
const editor = visibleEditor(page);
|
||||||
|
await editor.click();
|
||||||
|
await editor.press("Control+A");
|
||||||
|
await editor.type(content);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function replaceFileAfterDeletionWasObserved(input: {
|
||||||
|
page: Page;
|
||||||
|
gate: Awaited<ReturnType<typeof installDaemonWebSocketGate>>;
|
||||||
|
filePath: string;
|
||||||
|
relativePath: string;
|
||||||
|
content: string;
|
||||||
|
}): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -16,6 +16,21 @@ interface ClientRequest {
|
|||||||
type?: unknown;
|
type?: unknown;
|
||||||
subscribe?: unknown;
|
subscribe?: unknown;
|
||||||
page?: { cursor?: 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 {
|
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 {
|
function directoryForRequest(request: ClientRequest): keyof DirectoryBootstrapCounts | null {
|
||||||
if (request.page?.cursor) return null;
|
if (request.page?.cursor) return null;
|
||||||
if (request.type === "fetch_agents_request") return "agents";
|
if (request.type === "fetch_agents_request") return "agents";
|
||||||
@@ -47,6 +74,16 @@ export async function installDaemonWebSocketGate(page: Page) {
|
|||||||
total: { agents: 0, workspaces: 0 },
|
total: { agents: 0, workspaces: 0 },
|
||||||
};
|
};
|
||||||
const clientRequestCounts = new Map<string, number>();
|
const clientRequestCounts = new Map<string, number>();
|
||||||
|
const subscribedFilePaths = new Set<string>();
|
||||||
|
const fileSubscriptionWaiters = new Map<string, () => 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) => {
|
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
|
||||||
if (!acceptingConnections) {
|
if (!acceptingConnections) {
|
||||||
@@ -69,6 +106,15 @@ export async function installDaemonWebSocketGate(page: Page) {
|
|||||||
directoryStarts.total[directory] += 1;
|
directoryStarts.total[directory] += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
request?.type === "file_explorer_request" &&
|
||||||
|
request.mode === "file" &&
|
||||||
|
request.path === fileReadPathToHold
|
||||||
|
) {
|
||||||
|
heldFileReads.push(() => server.send(message));
|
||||||
|
resolveHeldFileRead?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
server.send(message);
|
server.send(message);
|
||||||
} catch {
|
} catch {
|
||||||
@@ -78,6 +124,26 @@ export async function installDaemonWebSocketGate(page: Page) {
|
|||||||
|
|
||||||
server.onMessage((message) => {
|
server.onMessage((message) => {
|
||||||
if (!acceptingConnections) return;
|
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 {
|
try {
|
||||||
ws.send(message);
|
ws.send(message);
|
||||||
} catch {
|
} catch {
|
||||||
@@ -87,6 +153,44 @@ export async function installDaemonWebSocketGate(page: Page) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
waitForFileSubscription(path: string): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
return heldReadyFileUpdatePromise;
|
||||||
|
},
|
||||||
|
releaseHeldReadyFileUpdate(): void {
|
||||||
|
const forward = heldReadyFileUpdate;
|
||||||
|
heldReadyFileUpdate = null;
|
||||||
|
readyFileUpdatePathToHold = null;
|
||||||
|
resolveHeldReadyFileUpdate = null;
|
||||||
|
forward?.();
|
||||||
|
},
|
||||||
async drop(): Promise<void> {
|
async drop(): Promise<void> {
|
||||||
acceptingConnections = false;
|
acceptingConnections = false;
|
||||||
const sockets = Array.from(activeSockets);
|
const sockets = Array.from(activeSockets);
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||||
import { SegmentedControl } from "@/components/ui/segmented-control";
|
import { SegmentedControl } from "@/components/ui/segmented-control";
|
||||||
import type { Theme } from "@/styles/theme";
|
import type { Theme } from "@/styles/theme";
|
||||||
import { FileConflictAlert } from "./conflict-alert";
|
import { FileConflictAlert, type FileConflictAlertState } from "./conflict-alert";
|
||||||
import type { FileEditorStatus } from "./editor/model";
|
import type { FileEditorStatus } from "./editor/model";
|
||||||
|
|
||||||
const ThemedSpinner = withUnistyles(LoadingSpinner);
|
const ThemedSpinner = withUnistyles(LoadingSpinner);
|
||||||
@@ -18,9 +18,7 @@ export function FilePanelBar({
|
|||||||
editorStatus,
|
editorStatus,
|
||||||
cursor,
|
cursor,
|
||||||
vimMode,
|
vimMode,
|
||||||
conflictUnavailable,
|
conflict,
|
||||||
onOverwrite,
|
|
||||||
onReload,
|
|
||||||
}: {
|
}: {
|
||||||
size: number;
|
size: number;
|
||||||
lineCount?: number;
|
lineCount?: number;
|
||||||
@@ -29,9 +27,7 @@ export function FilePanelBar({
|
|||||||
editorStatus?: FileEditorStatus;
|
editorStatus?: FileEditorStatus;
|
||||||
cursor?: { line: number; column: number };
|
cursor?: { line: number; column: number };
|
||||||
vimMode?: string | null;
|
vimMode?: string | null;
|
||||||
conflictUnavailable?: boolean;
|
conflict?: FileConflictAlertState;
|
||||||
onOverwrite?(): void;
|
|
||||||
onReload?(): void;
|
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const markdownModes = [
|
const markdownModes = [
|
||||||
@@ -84,9 +80,6 @@ export function FilePanelBar({
|
|||||||
{editorStatus === "error" ? (
|
{editorStatus === "error" ? (
|
||||||
<Text style={styles.error}>{t("panels.file.editor.saveFailed")}</Text>
|
<Text style={styles.error}>{t("panels.file.editor.saveFailed")}</Text>
|
||||||
) : null}
|
) : null}
|
||||||
{editorStatus === "conflict" ? (
|
|
||||||
<Text style={styles.error}>{t("panels.file.editor.changedOnDisk")}</Text>
|
|
||||||
) : null}
|
|
||||||
{vimMode ? (
|
{vimMode ? (
|
||||||
<Text
|
<Text
|
||||||
style={styles.vim}
|
style={styles.vim}
|
||||||
@@ -114,15 +107,7 @@ export function FilePanelBar({
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
{editorStatus === "conflict" && onOverwrite && onReload ? (
|
{conflict ? <FileConflictAlert state={conflict} /> : null}
|
||||||
<View style={styles.notice}>
|
|
||||||
<FileConflictAlert
|
|
||||||
unavailable={conflictUnavailable ?? false}
|
|
||||||
onOverwrite={onOverwrite}
|
|
||||||
onReload={onReload}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
) : null}
|
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -137,8 +122,6 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
chrome: {
|
chrome: {
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
backgroundColor: theme.colors.surface1,
|
backgroundColor: theme.colors.surface1,
|
||||||
borderBottomWidth: 1,
|
|
||||||
borderBottomColor: theme.colors.border,
|
|
||||||
},
|
},
|
||||||
row: {
|
row: {
|
||||||
minHeight: 32,
|
minHeight: 32,
|
||||||
@@ -146,6 +129,8 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
gap: theme.spacing[3],
|
gap: theme.spacing[3],
|
||||||
paddingHorizontal: theme.spacing[3],
|
paddingHorizontal: theme.spacing[3],
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: theme.colors.border,
|
||||||
},
|
},
|
||||||
metadata: {
|
metadata: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
@@ -174,5 +159,4 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
fontFamily: theme.fontFamily.mono,
|
fontFamily: theme.fontFamily.mono,
|
||||||
fontSize: theme.fontSize.xs,
|
fontSize: theme.fontSize.xs,
|
||||||
},
|
},
|
||||||
notice: { paddingHorizontal: theme.spacing[3], paddingBottom: theme.spacing[3] },
|
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -1,34 +1,86 @@
|
|||||||
import { Alert } from "@/components/ui/alert";
|
|
||||||
import { Button } from "@/components/ui/button";
|
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 { useTranslation } from "react-i18next";
|
||||||
|
import { Text, View } from "react-native";
|
||||||
|
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||||
|
|
||||||
export function FileConflictAlert({
|
const ThemedAlertTriangle = withUnistyles(AlertTriangle);
|
||||||
unavailable,
|
const warningIconMapping = (theme: Theme) => ({ color: theme.colors.palette.amber[500] });
|
||||||
onOverwrite,
|
|
||||||
onReload,
|
export type FileConflictAlertState =
|
||||||
}: {
|
| { kind: "changed"; canOverwrite: boolean; onReload(): void; onOverwrite(): void }
|
||||||
unavailable: boolean;
|
| { kind: "deleted" }
|
||||||
onOverwrite(): void;
|
| { kind: "checkFailed"; retrying: boolean; onRetry(): void };
|
||||||
onReload(): void;
|
|
||||||
}) {
|
export function FileConflictAlert({ state }: { state: FileConflictAlertState }) {
|
||||||
const { t } = useTranslation();
|
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 (
|
return (
|
||||||
<Alert
|
<View style={styles.container} testID="file-conflict-alert" accessibilityRole="alert">
|
||||||
variant="warning"
|
<ThemedAlertTriangle size={ICON_SIZE.sm} uniProps={warningIconMapping} />
|
||||||
title={
|
<View style={styles.message}>
|
||||||
unavailable
|
<Text style={styles.title}>{title}</Text>
|
||||||
? t("panels.file.editor.unavailableTitle")
|
{description ? <Text style={styles.description}>{description}</Text> : null}
|
||||||
: t("panels.file.editor.changedOnDisk")
|
</View>
|
||||||
}
|
{state.kind !== "deleted" ? (
|
||||||
description={t("panels.file.editor.conflictDescription")}
|
<View style={styles.actions}>
|
||||||
testID="file-conflict-alert"
|
{state.kind === "changed" && state.canOverwrite ? (
|
||||||
>
|
<Button variant="outline" size="sm" onPress={state.onOverwrite}>
|
||||||
<Button variant="outline" size="sm" onPress={onOverwrite} disabled={unavailable}>
|
{t("panels.file.editor.overwrite")}
|
||||||
{t("panels.file.editor.overwrite")}
|
</Button>
|
||||||
</Button>
|
) : null}
|
||||||
<Button variant="outline" size="sm" onPress={onReload} disabled={unavailable}>
|
{state.kind === "changed" ? (
|
||||||
{t("panels.file.editor.reload")}
|
<Button variant="outline" size="sm" onPress={state.onReload}>
|
||||||
</Button>
|
{t("panels.file.editor.reload")}
|
||||||
</Alert>
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{state.kind === "checkFailed" ? (
|
||||||
|
<Button variant="outline" size="sm" onPress={state.onRetry} loading={state.retrying}>
|
||||||
|
{t("common.actions.retry")}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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],
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { describe, expect, test } from "vitest";
|
|||||||
import type { FileVersion, FileWriteResult } from "@getpaseo/protocol/messages";
|
import type { FileVersion, FileWriteResult } from "@getpaseo/protocol/messages";
|
||||||
import {
|
import {
|
||||||
FileEditorModel,
|
FileEditorModel,
|
||||||
|
getFileConflictCallout,
|
||||||
type FileEditorClock,
|
type FileEditorClock,
|
||||||
type FileEditorFile,
|
type FileEditorFile,
|
||||||
type FileEditorSession,
|
type FileEditorSession,
|
||||||
@@ -335,6 +336,45 @@ describe("FileEditorModel", () => {
|
|||||||
expect(session.writes).toEqual([]);
|
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", () => {
|
test("dispose cancels pending autosave", () => {
|
||||||
const { model, session, clock } = makeModel();
|
const { model, session, clock } = makeModel();
|
||||||
model.edit("local");
|
model.edit("local");
|
||||||
@@ -358,4 +398,38 @@ describe("FileEditorModel", () => {
|
|||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
expect(session.writes).toHaveLength(1);
|
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" });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -13,6 +13,11 @@ export interface FileEditorSnapshot {
|
|||||||
error: string | null;
|
error: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type FileConflictCallout =
|
||||||
|
| { kind: "changed"; canOverwrite: boolean }
|
||||||
|
| { kind: "deleted" }
|
||||||
|
| { kind: "checkFailed" };
|
||||||
|
|
||||||
export interface FileEditorFile {
|
export interface FileEditorFile {
|
||||||
content: string;
|
content: string;
|
||||||
hasBom: boolean;
|
hasBom: boolean;
|
||||||
@@ -28,6 +33,11 @@ export interface FileEditorSession {
|
|||||||
}): Promise<FileWriteResult>;
|
}): Promise<FileWriteResult>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface FileVersionSource {
|
||||||
|
subscribe(listener: () => void): () => void;
|
||||||
|
getVersion(): FileVersion | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface FileEditorClock {
|
export interface FileEditorClock {
|
||||||
setTimeout(callback: () => void, delayMs: number): ReturnType<typeof setTimeout>;
|
setTimeout(callback: () => void, delayMs: number): ReturnType<typeof setTimeout>;
|
||||||
clearTimeout(handle: ReturnType<typeof setTimeout>): void;
|
clearTimeout(handle: ReturnType<typeof setTimeout>): void;
|
||||||
@@ -53,6 +63,7 @@ export class FileEditorModel {
|
|||||||
private observedWhileSaving: FileVersion | null = null;
|
private observedWhileSaving: FileVersion | null = null;
|
||||||
private persistedContent: string;
|
private persistedContent: string;
|
||||||
private hasBom: boolean;
|
private hasBom: boolean;
|
||||||
|
private unsubscribeVersionSource: (() => void) | null = null;
|
||||||
|
|
||||||
constructor(input: {
|
constructor(input: {
|
||||||
file: FileEditorFile;
|
file: FileEditorFile;
|
||||||
@@ -81,6 +92,21 @@ export class FileEditorModel {
|
|||||||
|
|
||||||
getSnapshot = (): FileEditorSnapshot => this.snapshot;
|
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 {
|
edit(content: string): void {
|
||||||
if (this.disposed || content === this.snapshot.content) return;
|
if (this.disposed || content === this.snapshot.content) return;
|
||||||
const modified = content !== this.persistedContent;
|
const modified = content !== this.persistedContent;
|
||||||
@@ -124,6 +150,7 @@ export class FileEditorModel {
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (this.restoreUnchangedConflict(version)) return;
|
||||||
this.setSnapshot({ ...this.snapshot, observedVersion: version });
|
this.setSnapshot({ ...this.snapshot, observedVersion: version });
|
||||||
if (this.snapshot.status === "saving") {
|
if (this.snapshot.status === "saving") {
|
||||||
this.observedWhileSaving = version;
|
this.observedWhileSaving = version;
|
||||||
@@ -151,6 +178,7 @@ export class FileEditorModel {
|
|||||||
this.disposed = true;
|
this.disposed = true;
|
||||||
this.saveSequence += 1;
|
this.saveSequence += 1;
|
||||||
this.clearAutosave();
|
this.clearAutosave();
|
||||||
|
this.disconnectFileVersions();
|
||||||
this.listeners.clear();
|
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 {
|
private scheduleAutosave(): void {
|
||||||
this.clearAutosave();
|
this.clearAutosave();
|
||||||
this.autosave = this.clock.setTimeout(() => {
|
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 {
|
function detectLineSeparator(content: string): FileLineSeparator {
|
||||||
for (let index = 0; index < content.length; index += 1) {
|
for (let index = 0; index < content.length; index += 1) {
|
||||||
const character = content.charCodeAt(index);
|
const character = content.charCodeAt(index);
|
||||||
|
|||||||
@@ -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<FileVersion | null>(null);
|
|
||||||
const latestVersion = useRef<FileVersion | null>(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<FileReadResult> => {
|
|
||||||
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 };
|
|
||||||
}
|
|
||||||
@@ -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<LiveFileSession | null>(() => {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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<T> {
|
||||||
|
promise: Promise<T>;
|
||||||
|
resolve(value: T): void;
|
||||||
|
reject(error: Error): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function deferred<T>(): Deferred<T> {
|
||||||
|
let resolve!: (value: T) => void;
|
||||||
|
let reject!: (error: Error) => void;
|
||||||
|
const promise = new Promise<T>((onResolve, onReject) => {
|
||||||
|
resolve = onResolve;
|
||||||
|
reject = onReject;
|
||||||
|
});
|
||||||
|
return { promise, resolve, reject };
|
||||||
|
}
|
||||||
|
|
||||||
|
function ready(revision = "revision-1"): Extract<FileVersion, { status: "ready" }> {
|
||||||
|
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<FileReadResult>[] = [];
|
||||||
|
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<LiveFileSubscription> {
|
||||||
|
this.subscribeAttempts += 1;
|
||||||
|
this.onVersion = onVersion;
|
||||||
|
return { initial: this.initial, unsubscribe: () => (this.onVersion = null) };
|
||||||
|
}
|
||||||
|
|
||||||
|
read(): Promise<FileReadResult> {
|
||||||
|
const request = deferred<FileReadResult>();
|
||||||
|
this.reads.push(request);
|
||||||
|
return request.promise;
|
||||||
|
}
|
||||||
|
|
||||||
|
emit(version: FileVersion): void {
|
||||||
|
this.onVersion?.(version);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class DeferredSubscribeSession implements LiveFileSession {
|
||||||
|
readonly reads: Deferred<FileReadResult>[] = [];
|
||||||
|
readonly subscription = deferred<LiveFileSubscription>();
|
||||||
|
private onVersion: ((version: FileVersion) => void) | null = null;
|
||||||
|
|
||||||
|
subscribe(
|
||||||
|
_target: { cwd: string; path: string },
|
||||||
|
onVersion: (version: FileVersion) => void,
|
||||||
|
): Promise<LiveFileSubscription> {
|
||||||
|
this.onVersion = onVersion;
|
||||||
|
return this.subscription.promise;
|
||||||
|
}
|
||||||
|
|
||||||
|
read(): Promise<FileReadResult> {
|
||||||
|
const request = deferred<FileReadResult>();
|
||||||
|
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<LiveFileSubscription> {
|
||||||
|
if (this.subscribeAttempts === 0) {
|
||||||
|
this.subscribeAttempts += 1;
|
||||||
|
throw new Error("Connection closed.");
|
||||||
|
}
|
||||||
|
return super.subscribe(target, onVersion);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForRead(
|
||||||
|
session: { reads: Deferred<FileReadResult>[] },
|
||||||
|
count: number,
|
||||||
|
): Promise<void> {
|
||||||
|
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" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<LiveFileSubscription>;
|
||||||
|
read(target: LiveFileTarget): Promise<FileReadResult>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<void> {
|
||||||
|
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<FileVersion, { status: "ready" }> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Button } from "@/components/ui/button";
|
||||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||||
import React, {
|
import React, {
|
||||||
useCallback,
|
useCallback,
|
||||||
@@ -8,7 +9,6 @@ import React, {
|
|||||||
useSyncExternalStore,
|
useSyncExternalStore,
|
||||||
} from "react";
|
} from "react";
|
||||||
import type { DaemonClient, FileReadResult } from "@getpaseo/client/internal/daemon-client";
|
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 { Image as RNImage, ScrollView as RNScrollView, Text, View } from "react-native";
|
||||||
import { StyleSheet, UnistylesRuntime, withUnistyles } from "react-native-unistyles";
|
import { StyleSheet, UnistylesRuntime, withUnistyles } from "react-native-unistyles";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -33,10 +33,17 @@ import { useAppActivelyVisible } from "@/hooks/use-app-visible";
|
|||||||
import { isFileQueryEnabled } from "@/components/file-pane-enabled";
|
import { isFileQueryEnabled } from "@/components/file-pane-enabled";
|
||||||
import { isWeb } from "@/constants/platform";
|
import { isWeb } from "@/constants/platform";
|
||||||
import { useAppSettings } from "@/hooks/use-settings";
|
import { useAppSettings } from "@/hooks/use-settings";
|
||||||
import { useLiveFile } from "./live-file";
|
import { useLiveFile } from "./live-file/hook";
|
||||||
import { FilePanelBar } from "./bar";
|
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 { FileEditorView } from "./editor/view";
|
||||||
|
import type { FileConflictAlertState } from "./conflict-alert";
|
||||||
|
import type { LiveFileModel } from "./live-file/model";
|
||||||
import { confirmDialog } from "@/utils/confirm-dialog";
|
import { confirmDialog } from "@/utils/confirm-dialog";
|
||||||
import { usePublishPanelInstanceAttributes } from "@/panels/panel-instance-attributes";
|
import { usePublishPanelInstanceAttributes } from "@/panels/panel-instance-attributes";
|
||||||
import type { Theme } from "@/styles/theme";
|
import type { Theme } from "@/styles/theme";
|
||||||
@@ -427,9 +434,8 @@ export function FilePane({
|
|||||||
isTabActive: isActive,
|
isTabActive: isActive,
|
||||||
isAppVisible,
|
isAppVisible,
|
||||||
});
|
});
|
||||||
const { query, version } = useLiveFile({
|
const liveFile = useLiveFile({
|
||||||
client,
|
client,
|
||||||
serverId,
|
|
||||||
cwd: readTarget?.cwd ?? null,
|
cwd: readTarget?.cwd ?? null,
|
||||||
path: readTarget?.path ?? null,
|
path: readTarget?.path ?? null,
|
||||||
enabled,
|
enabled,
|
||||||
@@ -437,16 +443,17 @@ export function FilePane({
|
|||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!liveFile.file) return;
|
||||||
let active = true;
|
let active = true;
|
||||||
const key = readTarget ? `${readTarget.cwd}:${readTarget.path}` : null;
|
const key = readTarget ? `${readTarget.cwd}:${readTarget.path}` : null;
|
||||||
void (async () => {
|
void (async () => {
|
||||||
const nextPreview = await createFilePanePreview(query.data ?? null);
|
const nextPreview = await createFilePanePreview(liveFile.file);
|
||||||
if (active) setResolvedPreview({ key, ...nextPreview });
|
if (active) setResolvedPreview({ key, ...nextPreview });
|
||||||
})();
|
})();
|
||||||
return () => {
|
return () => {
|
||||||
active = false;
|
active = false;
|
||||||
};
|
};
|
||||||
}, [query.data, readTarget]);
|
}, [liveFile.file, readTarget]);
|
||||||
|
|
||||||
useEffect(() => setMarkdownMode("preview"), [readTarget?.path]);
|
useEffect(() => setMarkdownMode("preview"), [readTarget?.path]);
|
||||||
|
|
||||||
@@ -463,7 +470,7 @@ export function FilePane({
|
|||||||
const canToggleMarkdownMode = isMarkdown && editable;
|
const canToggleMarkdownMode = isMarkdown && editable;
|
||||||
const lineCount =
|
const lineCount =
|
||||||
preview?.kind === "text" ? (preview.content ?? "").split("\n").length : undefined;
|
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 (
|
return (
|
||||||
<FilePanePresentation
|
<FilePanePresentation
|
||||||
@@ -471,7 +478,10 @@ export function FilePane({
|
|||||||
client={client}
|
client={client}
|
||||||
readTarget={readTarget}
|
readTarget={readTarget}
|
||||||
preview={preview}
|
preview={preview}
|
||||||
version={version}
|
liveFile={liveFile.model}
|
||||||
|
onRetryRead={liveFile.refresh}
|
||||||
|
retryingRead={liveFile.isRetrying}
|
||||||
|
retryLabel={t("common.actions.retry")}
|
||||||
filename={getFileNameFromPath(location.path) ?? location.path}
|
filename={getFileNameFromPath(location.path) ?? location.path}
|
||||||
markdownMode={canToggleMarkdownMode ? markdownMode : undefined}
|
markdownMode={canToggleMarkdownMode ? markdownMode : undefined}
|
||||||
onMarkdownModeChange={canToggleMarkdownMode ? setMarkdownMode : undefined}
|
onMarkdownModeChange={canToggleMarkdownMode ? setMarkdownMode : undefined}
|
||||||
@@ -479,7 +489,7 @@ export function FilePane({
|
|||||||
editable={editable}
|
editable={editable}
|
||||||
disconnectedMessage={t("workspace.terminal.hostDisconnected")}
|
disconnectedMessage={t("workspace.terminal.hostDisconnected")}
|
||||||
errorMessage={errorMessage}
|
errorMessage={errorMessage}
|
||||||
isLoading={query.isFetching}
|
isLoading={liveFile.isFetching}
|
||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
location={location}
|
location={location}
|
||||||
navigationRevision={navigationRevision}
|
navigationRevision={navigationRevision}
|
||||||
@@ -494,6 +504,7 @@ function isMarkdownPreview(preview: ExplorerFile | null, path: string): boolean
|
|||||||
|
|
||||||
function getFileErrorMessage(error: unknown, fallback: string): string | null {
|
function getFileErrorMessage(error: unknown, fallback: string): string | null {
|
||||||
if (!error) return null;
|
if (!error) return null;
|
||||||
|
if (typeof error === "string") return error;
|
||||||
return error instanceof Error ? error.message : fallback;
|
return error instanceof Error ? error.message : fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -514,7 +525,10 @@ function FilePanePresentation({
|
|||||||
client,
|
client,
|
||||||
readTarget,
|
readTarget,
|
||||||
preview,
|
preview,
|
||||||
version,
|
liveFile,
|
||||||
|
onRetryRead,
|
||||||
|
retryingRead,
|
||||||
|
retryLabel,
|
||||||
filename,
|
filename,
|
||||||
markdownMode,
|
markdownMode,
|
||||||
onMarkdownModeChange,
|
onMarkdownModeChange,
|
||||||
@@ -532,7 +546,10 @@ function FilePanePresentation({
|
|||||||
client: DaemonClient | null;
|
client: DaemonClient | null;
|
||||||
readTarget: { cwd: string; path: string } | null;
|
readTarget: { cwd: string; path: string } | null;
|
||||||
preview: ExplorerFile | null;
|
preview: ExplorerFile | null;
|
||||||
version: FileVersion | null;
|
liveFile: LiveFileModel;
|
||||||
|
onRetryRead: () => void;
|
||||||
|
retryingRead: boolean;
|
||||||
|
retryLabel: string;
|
||||||
filename: string;
|
filename: string;
|
||||||
markdownMode?: "preview" | "source";
|
markdownMode?: "preview" | "source";
|
||||||
onMarkdownModeChange?: (mode: "preview" | "source") => void;
|
onMarkdownModeChange?: (mode: "preview" | "source") => void;
|
||||||
@@ -564,7 +581,9 @@ function FilePanePresentation({
|
|||||||
cwd={readTarget.cwd}
|
cwd={readTarget.cwd}
|
||||||
path={readTarget.path}
|
path={readTarget.path}
|
||||||
preview={preview as TextExplorerFile}
|
preview={preview as TextExplorerFile}
|
||||||
version={version}
|
liveFile={liveFile}
|
||||||
|
onRetryRead={onRetryRead}
|
||||||
|
retryingRead={retryingRead}
|
||||||
filename={filename}
|
filename={filename}
|
||||||
mode={markdownMode}
|
mode={markdownMode}
|
||||||
onModeChange={onMarkdownModeChange}
|
onModeChange={onMarkdownModeChange}
|
||||||
@@ -576,6 +595,19 @@ function FilePanePresentation({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (errorMessage) {
|
||||||
|
return (
|
||||||
|
<View style={styles.container} testID="workspace-file-pane">
|
||||||
|
<View style={styles.centerState}>
|
||||||
|
<Text style={styles.errorText}>{errorMessage}</Text>
|
||||||
|
<Button variant="outline" size="sm" onPress={onRetryRead} loading={retryingRead}>
|
||||||
|
{retryLabel}
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container} testID="workspace-file-pane">
|
<View style={styles.container} testID="workspace-file-pane">
|
||||||
{preview ? (
|
{preview ? (
|
||||||
@@ -586,12 +618,6 @@ function FilePanePresentation({
|
|||||||
onModeChange={onMarkdownModeChange}
|
onModeChange={onMarkdownModeChange}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{errorMessage ? (
|
|
||||||
<View style={styles.centerState}>
|
|
||||||
<Text style={styles.errorText}>{errorMessage}</Text>
|
|
||||||
</View>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<FilePreviewBody
|
<FilePreviewBody
|
||||||
preview={preview}
|
preview={preview}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
@@ -609,7 +635,9 @@ function EditableFilePane({
|
|||||||
cwd,
|
cwd,
|
||||||
path,
|
path,
|
||||||
preview,
|
preview,
|
||||||
version,
|
liveFile,
|
||||||
|
onRetryRead,
|
||||||
|
retryingRead,
|
||||||
filename,
|
filename,
|
||||||
mode,
|
mode,
|
||||||
onModeChange,
|
onModeChange,
|
||||||
@@ -622,7 +650,9 @@ function EditableFilePane({
|
|||||||
cwd: string;
|
cwd: string;
|
||||||
path: string;
|
path: string;
|
||||||
preview: TextExplorerFile;
|
preview: TextExplorerFile;
|
||||||
version: FileVersion | null;
|
liveFile: LiveFileModel;
|
||||||
|
onRetryRead: () => void;
|
||||||
|
retryingRead: boolean;
|
||||||
filename: string;
|
filename: string;
|
||||||
mode?: "preview" | "source";
|
mode?: "preview" | "source";
|
||||||
onModeChange?: (mode: "preview" | "source") => void;
|
onModeChange?: (mode: "preview" | "source") => void;
|
||||||
@@ -662,23 +692,26 @@ function EditableFilePane({
|
|||||||
}),
|
}),
|
||||||
[client, cwd, path],
|
[client, cwd, path],
|
||||||
);
|
);
|
||||||
const [model] = useState(
|
const [model] = useState(() => {
|
||||||
() =>
|
return new FileEditorModel({
|
||||||
new FileEditorModel({
|
file: {
|
||||||
file: {
|
content: preview.content ?? "",
|
||||||
content: preview.content ?? "",
|
hasBom: preview.hasBom,
|
||||||
hasBom: preview.hasBom,
|
version: {
|
||||||
version: {
|
status: "ready",
|
||||||
status: "ready",
|
cwd,
|
||||||
cwd,
|
path,
|
||||||
path,
|
size: preview.size,
|
||||||
size: preview.size,
|
modifiedAt: preview.modifiedAt,
|
||||||
modifiedAt: preview.modifiedAt,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
session,
|
},
|
||||||
}),
|
session,
|
||||||
);
|
});
|
||||||
|
});
|
||||||
|
useEffect(() => {
|
||||||
|
model.connectFileVersions(liveFile);
|
||||||
|
return () => model.disconnectFileVersions();
|
||||||
|
}, [liveFile, model]);
|
||||||
const snapshot = useSyncExternalStore(model.subscribe, model.getSnapshot, model.getSnapshot);
|
const snapshot = useSyncExternalStore(model.subscribe, model.getSnapshot, model.getSnapshot);
|
||||||
const suspendPendingSave = useCallback(() => model.suspendAutosave(), [model]);
|
const suspendPendingSave = useCallback(() => model.suspendAutosave(), [model]);
|
||||||
usePublishPanelInstanceAttributes({ modified: snapshot.modified, suspendPendingSave });
|
usePublishPanelInstanceAttributes({ modified: snapshot.modified, suspendPendingSave });
|
||||||
@@ -711,11 +744,12 @@ function EditableFilePane({
|
|||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => () => model.dispose(), [model]);
|
useEffect(() => () => model.dispose(), [model]);
|
||||||
useEffect(() => {
|
|
||||||
if (version) model.receiveFileVersion(version);
|
|
||||||
}, [model, version]);
|
|
||||||
|
|
||||||
const handleReload = useCallback(() => {
|
const handleReload = useCallback(() => {
|
||||||
|
if (!snapshot.modified) {
|
||||||
|
void model.reload();
|
||||||
|
return;
|
||||||
|
}
|
||||||
void (async () => {
|
void (async () => {
|
||||||
const confirmed = await confirmDialog({
|
const confirmed = await confirmDialog({
|
||||||
title: t("panels.file.editor.reloadTitle"),
|
title: t("panels.file.editor.reloadTitle"),
|
||||||
@@ -725,8 +759,15 @@ function EditableFilePane({
|
|||||||
});
|
});
|
||||||
if (confirmed) void model.reload();
|
if (confirmed) void model.reload();
|
||||||
})();
|
})();
|
||||||
}, [model, t]);
|
}, [model, snapshot.modified, t]);
|
||||||
const handleOverwrite = useCallback(() => void model.overwrite(), [model]);
|
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 handleVimModeChange = useCallback((nextMode: string | null) => setVimMode(nextMode), []);
|
||||||
const renderedPreview = useMemo<ExplorerFile>(
|
const renderedPreview = useMemo<ExplorerFile>(
|
||||||
() => ({
|
() => ({
|
||||||
@@ -750,9 +791,7 @@ function EditableFilePane({
|
|||||||
editorStatus={snapshot.status}
|
editorStatus={snapshot.status}
|
||||||
cursor={showSource ? cursor : undefined}
|
cursor={showSource ? cursor : undefined}
|
||||||
vimMode={showSource ? vimMode : null}
|
vimMode={showSource ? vimMode : null}
|
||||||
conflictUnavailable={snapshot.observedVersion.status !== "ready"}
|
conflict={conflict}
|
||||||
onOverwrite={handleOverwrite}
|
|
||||||
onReload={handleReload}
|
|
||||||
mode={mode}
|
mode={mode}
|
||||||
onModeChange={onModeChange}
|
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) => ({
|
const styles = StyleSheet.create((theme) => ({
|
||||||
container: {
|
container: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
@@ -791,6 +850,7 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
flex: 1,
|
flex: 1,
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
|
gap: theme.spacing[3],
|
||||||
padding: theme.spacing[4],
|
padding: theme.spacing[4],
|
||||||
},
|
},
|
||||||
loadingText: {
|
loadingText: {
|
||||||
|
|||||||
@@ -1491,7 +1491,9 @@ export const ar: TranslationResources = {
|
|||||||
cursor: "السطر {{line}}، العمود {{column}}",
|
cursor: "السطر {{line}}، العمود {{column}}",
|
||||||
preview: "معاينة",
|
preview: "معاينة",
|
||||||
source: "المصدر",
|
source: "المصدر",
|
||||||
unavailableTitle: "الملف غير متاح على القرص",
|
deletedTitle: "تم حذف الملف من القرص",
|
||||||
|
checkFailedTitle: "تعذّر التحقق من الملف على القرص",
|
||||||
|
preservedDescription: "تم الاحتفاظ بالنسخة المفتوحة.",
|
||||||
conflictDescription: "تم الاحتفاظ بالمحتوى المحلي. اختر الإصدار الذي تريد إبقاءه.",
|
conflictDescription: "تم الاحتفاظ بالمحتوى المحلي. اختر الإصدار الذي تريد إبقاءه.",
|
||||||
overwrite: "استبدال",
|
overwrite: "استبدال",
|
||||||
reload: "إعادة تحميل",
|
reload: "إعادة تحميل",
|
||||||
|
|||||||
@@ -1502,7 +1502,9 @@ export const en = {
|
|||||||
cursor: "Line {{line}}, column {{column}}",
|
cursor: "Line {{line}}, column {{column}}",
|
||||||
preview: "Preview",
|
preview: "Preview",
|
||||||
source: "Source",
|
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.",
|
conflictDescription: "The local buffer was preserved. Choose which version to keep.",
|
||||||
overwrite: "Overwrite",
|
overwrite: "Overwrite",
|
||||||
reload: "Reload",
|
reload: "Reload",
|
||||||
|
|||||||
@@ -1534,7 +1534,9 @@ export const es: TranslationResources = {
|
|||||||
cursor: "Línea {{line}}, columna {{column}}",
|
cursor: "Línea {{line}}, columna {{column}}",
|
||||||
preview: "Vista previa",
|
preview: "Vista previa",
|
||||||
source: "Código fuente",
|
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.",
|
conflictDescription: "Se conservó el búfer local. Elige qué versión mantener.",
|
||||||
overwrite: "Sobrescribir",
|
overwrite: "Sobrescribir",
|
||||||
reload: "Recargar",
|
reload: "Recargar",
|
||||||
|
|||||||
@@ -1537,7 +1537,9 @@ export const fr: TranslationResources = {
|
|||||||
cursor: "Ligne {{line}}, colonne {{column}}",
|
cursor: "Ligne {{line}}, colonne {{column}}",
|
||||||
preview: "Aperçu",
|
preview: "Aperçu",
|
||||||
source: "Source",
|
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.",
|
conflictDescription: "Le contenu local a été conservé. Choisissez la version à garder.",
|
||||||
overwrite: "Écraser",
|
overwrite: "Écraser",
|
||||||
reload: "Recharger",
|
reload: "Recharger",
|
||||||
|
|||||||
@@ -1507,7 +1507,9 @@ export const ja: TranslationResources = {
|
|||||||
cursor: "{{line}} 行、{{column}} 列",
|
cursor: "{{line}} 行、{{column}} 列",
|
||||||
preview: "プレビュー",
|
preview: "プレビュー",
|
||||||
source: "ソース",
|
source: "ソース",
|
||||||
unavailableTitle: "ディスク上のファイルを利用できません",
|
deletedTitle: "ファイルはディスク上から削除されました",
|
||||||
|
checkFailedTitle: "ディスク上のファイルを確認できませんでした",
|
||||||
|
preservedDescription: "開いているコピーは保持されています。",
|
||||||
conflictDescription: "ローカルの内容は保持されています。残すバージョンを選択してください。",
|
conflictDescription: "ローカルの内容は保持されています。残すバージョンを選択してください。",
|
||||||
overwrite: "上書き",
|
overwrite: "上書き",
|
||||||
reload: "再読み込み",
|
reload: "再読み込み",
|
||||||
|
|||||||
@@ -1520,7 +1520,9 @@ export const ptBR: TranslationResources = {
|
|||||||
cursor: "Linha {{line}}, coluna {{column}}",
|
cursor: "Linha {{line}}, coluna {{column}}",
|
||||||
preview: "Prévia",
|
preview: "Prévia",
|
||||||
source: "Fonte",
|
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.",
|
conflictDescription: "O conteúdo local foi preservado. Escolha qual versão manter.",
|
||||||
overwrite: "Sobrescrever",
|
overwrite: "Sobrescrever",
|
||||||
reload: "Recarregar",
|
reload: "Recarregar",
|
||||||
|
|||||||
@@ -1525,7 +1525,9 @@ export const ru: TranslationResources = {
|
|||||||
cursor: "Строка {{line}}, столбец {{column}}",
|
cursor: "Строка {{line}}, столбец {{column}}",
|
||||||
preview: "Просмотр",
|
preview: "Просмотр",
|
||||||
source: "Исходник",
|
source: "Исходник",
|
||||||
unavailableTitle: "Файл недоступен на диске",
|
deletedTitle: "Файл удалён с диска",
|
||||||
|
checkFailedTitle: "Не удалось проверить файл на диске",
|
||||||
|
preservedDescription: "Открытая копия сохранена.",
|
||||||
conflictDescription: "Локальный буфер сохранён. Выберите версию, которую нужно оставить.",
|
conflictDescription: "Локальный буфер сохранён. Выберите версию, которую нужно оставить.",
|
||||||
overwrite: "Перезаписать",
|
overwrite: "Перезаписать",
|
||||||
reload: "Перезагрузить",
|
reload: "Перезагрузить",
|
||||||
|
|||||||
@@ -1472,7 +1472,9 @@ export const zhCN: TranslationResources = {
|
|||||||
cursor: "第 {{line}} 行,第 {{column}} 列",
|
cursor: "第 {{line}} 行,第 {{column}} 列",
|
||||||
preview: "预览",
|
preview: "预览",
|
||||||
source: "源代码",
|
source: "源代码",
|
||||||
unavailableTitle: "磁盘上的文件不可用",
|
deletedTitle: "文件已从磁盘删除",
|
||||||
|
checkFailedTitle: "无法检查磁盘上的文件",
|
||||||
|
preservedDescription: "打开的副本已保留。",
|
||||||
conflictDescription: "本地内容已保留。请选择要保留的版本。",
|
conflictDescription: "本地内容已保留。请选择要保留的版本。",
|
||||||
overwrite: "覆盖",
|
overwrite: "覆盖",
|
||||||
reload: "重新加载",
|
reload: "重新加载",
|
||||||
|
|||||||
Reference in New Issue
Block a user