Edit workspace files directly on web (#2270)

* feat(files): edit workspace files on web

Keep source buffers synchronized with host file changes and require an explicit overwrite or reload when revisions diverge.

* feat(panels): surface and protect modified tabs

Expose tooltip and modification state through the generic panel boundary so tabs can show stable metadata and guard every close route consistently.

* fix(tests): use portable fake timeout handle

* fix(files): harden editor conflict handling

Preserve modified panel state across tab eviction, use precise revisions for optimistic writes, coalesce concurrent file watchers, and localize the editor interface.

* fix(files): close editor concurrency gaps

Coalesce clean reloads, preserve subscriber identities and file permissions, suspend pending saves during close confirmation, and carry precise revisions through file reads.

* test(files): expect read revision metadata
This commit is contained in:
Mohamed Boudra
2026-07-21 04:41:19 +08:00
committed by GitHub
parent 9292f58896
commit 4bda2dfea9
69 changed files with 3678 additions and 241 deletions
+1
View File
@@ -326,6 +326,7 @@ function useAgentPanelDescriptor(
return {
label: label ?? "",
subtitle: `${formatProviderLabel(provider)} agent`,
tooltip: label ?? `${formatProviderLabel(provider)} agent`,
titleState: label ? "ready" : "loading",
icon,
statusBucket: descriptorState.status
+3 -1
View File
@@ -42,10 +42,12 @@ function useBrowserPanelDescriptor(target: {
const browser = useBrowserStore((state) => state.browsersById[target.browserId] ?? null);
const url = browser?.url ?? "https://example.com";
const icon = createBrowserTabIcon(browser?.faviconUrl ?? null);
const label = getBrowserLabel({ title: browser?.title ?? "", url });
return {
label: getBrowserLabel({ title: browser?.title ?? "", url }),
label,
subtitle: url,
tooltip: url || label,
titleState: "ready",
icon,
statusBucket: browser?.isLoading ? "running" : null,
@@ -132,6 +132,7 @@ function useCommitDiffPanelDescriptor(
return {
label: target.sha.slice(0, 7),
subtitle: t("panels.diff.commitSubtitle"),
tooltip: target.sha,
titleState: "ready",
icon: ThemedGitCommitHorizontal,
statusBucket: null,
@@ -14,6 +14,7 @@ export function buildDraftPanelDescriptor(input: {
return {
label: creatingLabel,
subtitle: i18n.t("panels.draft.creatingAgent"),
tooltip: creatingLabel,
titleState: "ready",
icon,
statusBucket: "running",
@@ -23,6 +24,7 @@ export function buildDraftPanelDescriptor(input: {
return {
label: newAgentLabel,
subtitle: newAgentLabel,
tooltip: newAgentLabel,
titleState: "ready",
icon,
statusBucket: null,
+6 -3
View File
@@ -1,11 +1,12 @@
import { Text, View } from "react-native";
import { FileText } from "lucide-react-native";
import { useMemo } from "react";
import invariant from "tiny-invariant";
import { useTranslation } from "react-i18next";
import { FilePane } from "@/components/file-pane";
import { FilePane } from "@/file-pane/pane";
import { usePaneContext } from "@/panels/pane-context";
import type { PanelRegistration } from "@/panels/panel-registry";
import { useWorkspaceDirectory } from "@/stores/session-store-hooks";
import { createMaterialFileIcon } from "@/components/material-file-icon";
const CENTERED_PADDED_STYLE = {
flex: 1,
@@ -16,11 +17,13 @@ const CENTERED_PADDED_STYLE = {
function useFilePanelDescriptor(target: { kind: "file"; path: string }) {
const fileName = target.path.split("/").findLast(Boolean) ?? target.path;
const icon = useMemo(() => createMaterialFileIcon(fileName), [fileName]);
return {
label: fileName,
subtitle: target.path,
tooltip: target.path,
titleState: "ready" as const,
icon: FileText,
icon,
statusBucket: null,
};
}
@@ -0,0 +1,35 @@
import { describe, expect, test } from "vitest";
import {
getPanelInstanceAttributes,
setPanelInstanceAttributes,
subscribePanelInstanceAttributes,
} from "./panel-instance-attributes";
describe("panel instance attributes", () => {
test("keeps runtime attributes isolated by workspace and tab", () => {
const first = { serverId: "server", workspaceId: "one", tabId: "tab" };
const second = { serverId: "server", workspaceId: "two", tabId: "tab" };
setPanelInstanceAttributes(first, { modified: true });
expect(getPanelInstanceAttributes(first)).toEqual({ modified: true });
expect(getPanelInstanceAttributes(second)).toEqual({ modified: false });
setPanelInstanceAttributes(first, { modified: false });
});
test("notifies subscribers only when attributes change", () => {
const identity = { serverId: "server", workspaceId: "workspace", tabId: "observed" };
let notifications = 0;
const unsubscribe = subscribePanelInstanceAttributes(identity, () => {
notifications += 1;
});
setPanelInstanceAttributes(identity, { modified: true });
setPanelInstanceAttributes(identity, { modified: true });
setPanelInstanceAttributes(identity, { modified: false });
expect(notifications).toBe(2);
unsubscribe();
});
});
@@ -0,0 +1,118 @@
import { useCallback, useEffect, useMemo, useSyncExternalStore } from "react";
import { usePaneContext } from "@/panels/pane-context";
export interface PanelInstanceIdentity {
serverId: string;
workspaceId: string;
tabId: string;
}
export interface PanelInstanceAttributes {
modified: boolean;
suspendPendingSave?: () => () => void;
}
const DEFAULT_ATTRIBUTES: PanelInstanceAttributes = { modified: false };
const attributesByPanel = new Map<string, PanelInstanceAttributes>();
const listenersByPanel = new Map<string, Set<() => void>>();
const allListeners = new Set<() => void>();
let attributesRevision = 0;
export function buildPanelInstanceKey(identity: PanelInstanceIdentity): string {
return `${identity.serverId}:${identity.workspaceId}:${identity.tabId}`;
}
export function getPanelInstanceAttributes(
identity: PanelInstanceIdentity,
): PanelInstanceAttributes {
return attributesByPanel.get(buildPanelInstanceKey(identity)) ?? DEFAULT_ATTRIBUTES;
}
export function setPanelInstanceAttributes(
identity: PanelInstanceIdentity,
attributes: PanelInstanceAttributes,
): void {
const key = buildPanelInstanceKey(identity);
const previous = attributesByPanel.get(key) ?? DEFAULT_ATTRIBUTES;
if (
previous.modified === attributes.modified &&
previous.suspendPendingSave === attributes.suspendPendingSave
) {
return;
}
if (attributes.modified) attributesByPanel.set(key, attributes);
else attributesByPanel.delete(key);
attributesRevision += 1;
for (const listener of listenersByPanel.get(key) ?? []) listener();
for (const listener of allListeners) listener();
}
export function useModifiedPanelTabIds(input: {
serverId: string;
workspaceId: string;
tabIds: string[];
}): Set<string> {
const revision = useSyncExternalStore(
useCallback((listener: () => void) => {
allListeners.add(listener);
return () => allListeners.delete(listener);
}, []),
() => attributesRevision,
() => attributesRevision,
);
return useMemo(() => {
void revision;
return new Set(
input.tabIds.filter(
(tabId) =>
getPanelInstanceAttributes({
serverId: input.serverId,
workspaceId: input.workspaceId,
tabId,
}).modified,
),
);
}, [input.serverId, input.tabIds, input.workspaceId, revision]);
}
export function subscribePanelInstanceAttributes(
identity: PanelInstanceIdentity,
listener: () => void,
): () => void {
const key = buildPanelInstanceKey(identity);
const listeners = listenersByPanel.get(key) ?? new Set<() => void>();
listeners.add(listener);
listenersByPanel.set(key, listeners);
return () => {
listeners.delete(listener);
if (listeners.size === 0) listenersByPanel.delete(key);
};
}
export function usePanelInstanceAttributes({
serverId,
workspaceId,
tabId,
}: PanelInstanceIdentity): PanelInstanceAttributes {
const subscribe = useCallback(
(listener: () => void) =>
subscribePanelInstanceAttributes({ serverId, workspaceId, tabId }, listener),
[serverId, tabId, workspaceId],
);
const getSnapshot = useCallback(
() => getPanelInstanceAttributes({ serverId, workspaceId, tabId }),
[serverId, tabId, workspaceId],
);
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
}
export function usePublishPanelInstanceAttributes(attributes: PanelInstanceAttributes): void {
const { serverId, workspaceId, tabId } = usePaneContext();
const modified = attributes.modified;
const suspendPendingSave = attributes.suspendPendingSave;
useEffect(() => {
const identity = { serverId, workspaceId, tabId };
setPanelInstanceAttributes(identity, { modified, suspendPendingSave });
return () => setPanelInstanceAttributes(identity, DEFAULT_ATTRIBUTES);
}, [modified, serverId, suspendPendingSave, tabId, workspaceId]);
}
+2 -4
View File
@@ -10,6 +10,7 @@ export interface PanelIconProps {
export interface PanelDescriptor {
label: string;
subtitle: string;
tooltip: string;
titleState: "ready" | "loading";
icon: ComponentType<PanelIconProps>;
statusBucket: SidebarStateBucket | null;
@@ -18,6 +19,7 @@ export interface PanelDescriptor {
export interface PanelDescriptorContext {
serverId: string;
workspaceId: string;
tabId: string;
}
export interface PanelRegistration<
@@ -29,10 +31,6 @@ export interface PanelRegistration<
target: Extract<WorkspaceTabTarget, { kind: K }>,
context: PanelDescriptorContext,
): PanelDescriptor;
confirmClose?(
target: Extract<WorkspaceTabTarget, { kind: K }>,
context: PanelDescriptorContext,
): Promise<boolean>;
}
const panelRegistry = new Map<WorkspaceTabTarget["kind"], PanelRegistration>();
@@ -49,6 +49,7 @@ function useProviderSubagentDescriptor(
return {
label,
subtitle: `${formatProviderLabel(provider)} subagent`,
tooltip: label,
titleState: descriptor ? "ready" : "loading",
icon: getProviderIcon(provider),
statusBucket: descriptor
+3
View File
@@ -37,6 +37,7 @@ function useSetupPanelDescriptor(
return {
label: t("workspace.setup.descriptor.label"),
subtitle: t("workspace.setup.descriptor.completed"),
tooltip: t("workspace.setup.descriptor.completed"),
titleState: "ready",
icon: CheckCircle2,
statusBucket: null,
@@ -47,6 +48,7 @@ function useSetupPanelDescriptor(
return {
label: t("workspace.setup.descriptor.label"),
subtitle: t("workspace.setup.descriptor.failed"),
tooltip: t("workspace.setup.descriptor.failed"),
titleState: "ready",
icon: CircleAlert,
statusBucket: null,
@@ -56,6 +58,7 @@ function useSetupPanelDescriptor(
return {
label: t("workspace.setup.descriptor.label"),
subtitle: t("workspace.setup.descriptor.workspace"),
tooltip: t("workspace.setup.descriptor.workspace"),
titleState: "ready",
icon: SquareTerminal,
statusBucket: snapshot?.status === "running" ? "running" : null,
+5 -3
View File
@@ -62,12 +62,14 @@ function useTerminalPanelDescriptor(
);
const terminal =
terminalsQuery.data?.terminals.find((entry) => entry.id === target.terminalId) ?? null;
const label =
trimNonEmpty(terminal?.title ?? terminal?.name ?? null) ??
t("workspace.tabs.fallback.terminal");
return {
label:
trimNonEmpty(terminal?.title ?? terminal?.name ?? null) ??
t("workspace.tabs.fallback.terminal"),
label,
subtitle: t("workspace.tabs.fallback.terminal"),
tooltip: label,
titleState: "ready",
icon: Terminal,
statusBucket: deriveTerminalActivityStatusBucket(terminal?.activity),