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
@@ -168,6 +168,10 @@ import {
closeBulkWorkspaceTabs,
} from "@/screens/workspace/workspace-bulk-close";
import { resolveCloseAgentTabPolicy } from "@/subagents";
import {
getPanelInstanceAttributes,
useModifiedPanelTabIds,
} from "@/panels/panel-instance-attributes";
import { findAdjacentPane } from "@/utils/split-navigation";
import { useIsCompactFormFactor, supportsDesktopPaneSplits } from "@/constants/layout";
import { getIsElectron, isNative, isWeb } from "@/constants/platform";
@@ -2661,11 +2665,8 @@ function WorkspaceScreenContent({
[archiveAgent, closeTab, closeWorkspaceTabWithCleanup, normalizedServerId, persistenceKey, t],
);
const handleCloseDraftOrFileTab = useCallback(
function handleCloseDraftOrFileTab(input: {
tabId: string;
target?: WorkspaceTabTarget | null;
}) {
const handleClosePassiveTab = useCallback(
function handleClosePassiveTab(input: { tabId: string; target?: WorkspaceTabTarget | null }) {
setHoveredCloseTabKey((current) => (current === input.tabId ? null : current));
if (persistenceKey) {
closeWorkspaceTabWithCleanup({ tabId: input.tabId, target: input.target });
@@ -2674,12 +2675,37 @@ function WorkspaceScreenContent({
[closeWorkspaceTabWithCleanup, persistenceKey],
);
const confirmDiscardModifiedTab = useCallback(
async (tabId: string): Promise<boolean> => {
const attributes = getPanelInstanceAttributes({
serverId: normalizedServerId,
workspaceId: normalizedWorkspaceId,
tabId,
});
if (!attributes.modified) return true;
const resumePendingSave = attributes.suspendPendingSave?.();
const confirmed = await confirmDialog({
title: t("workspace.tabs.confirmations.unsavedTitle"),
message: t("workspace.tabs.confirmations.unsavedMessage"),
confirmLabel: t("workspace.tabs.confirmations.closeWithoutSaving"),
cancelLabel: t("workspace.tabs.confirmations.cancel"),
destructive: true,
});
if (!confirmed) resumePendingSave?.();
return confirmed;
},
[normalizedServerId, normalizedWorkspaceId, t],
);
const handleCloseTabById = useCallback(
async (tabId: string) => {
const tab = allTabDescriptorsById.get(tabId);
if (!tab) {
return;
}
if (!(await confirmDiscardModifiedTab(tabId))) {
return;
}
if (tab.target.kind === "terminal") {
await handleCloseTerminalTab({ tabId, terminalId: tab.target.terminalId });
return;
@@ -2688,9 +2714,15 @@ function WorkspaceScreenContent({
await handleCloseAgentTab({ tabId, agentId: tab.target.agentId });
return;
}
handleCloseDraftOrFileTab({ tabId, target: tab.target });
handleClosePassiveTab({ tabId, target: tab.target });
},
[allTabDescriptorsById, handleCloseAgentTab, handleCloseDraftOrFileTab, handleCloseTerminalTab],
[
allTabDescriptorsById,
confirmDiscardModifiedTab,
handleCloseAgentTab,
handleClosePassiveTab,
handleCloseTerminalTab,
],
);
const handleCopyAgentId = useCallback(
@@ -2834,9 +2866,21 @@ function WorkspaceScreenContent({
}
const groups = classifyBulkClosableTabs(tabsToClose);
const modifiedCount = tabsToClose.filter(
(tab) =>
getPanelInstanceAttributes({
serverId: normalizedServerId,
workspaceId: normalizedWorkspaceId,
tabId: tab.tabId,
}).modified,
).length;
const bulkMessage = buildBulkCloseConfirmationMessage(groups, bulkCloseConfirmationLabels);
const confirmed = await confirmDialog({
title,
message: buildBulkCloseConfirmationMessage(groups, bulkCloseConfirmationLabels),
message:
modifiedCount > 0
? `${bulkMessage}\n\n${t("workspace.tabs.confirmations.bulkUnsaved", { count: modifiedCount })}`
: bulkMessage,
confirmLabel: t("workspace.tabs.confirmations.close"),
cancelLabel: t("workspace.tabs.confirmations.cancel"),
destructive: true,
@@ -2869,6 +2913,8 @@ function WorkspaceScreenContent({
client,
closeTab,
closeWorkspaceTabWithCleanup,
normalizedServerId,
normalizedWorkspaceId,
persistenceKey,
t,
],
@@ -3055,12 +3101,15 @@ function WorkspaceScreenContent({
}
if (action.id === "workspace.pane.close") {
for (const tabId of focusedPane.tabIds) {
closeWorkspaceTabWithCleanup({
tabId,
target: allTabDescriptorsById.get(tabId)?.target ?? null,
});
}
const tabsToClose = focusedPane.tabIds.flatMap((tabId) => {
const tab = allTabDescriptorsById.get(tabId);
return tab ? [tab] : [];
});
void handleBulkCloseTabs({
tabsToClose,
title: t("workspace.tabs.confirmations.closePaneTitle"),
logLabel: "from pane close",
});
return true;
}
@@ -3068,14 +3117,15 @@ function WorkspaceScreenContent({
},
[
allTabDescriptorsById,
closeWorkspaceTabWithCleanup,
focusWorkspacePane,
handleBulkCloseTabs,
handleCreateDraftSplit,
moveWorkspaceTabToPane,
persistenceKey,
focusedPaneTabState.activeTabId,
focusedPaneTabState.pane,
toggleFocusMode,
t,
workspaceLayout,
],
);
@@ -3210,10 +3260,16 @@ function WorkspaceScreenContent({
[focusedPaneTabState.pane],
);
const focusedPaneTabIds = useMemo(() => tabs.map((tab) => tab.tabId), [tabs]);
const modifiedFocusedPaneTabIds = useModifiedPanelTabIds({
serverId: normalizedServerId,
workspaceId: normalizedWorkspaceId,
tabIds: focusedPaneTabIds,
});
const focusedPaneTabDescriptorMap = useStableTabDescriptorMap(tabs);
const { mountedTabIds: mountedFocusedPaneTabIdsSet } = useMountedTabSet({
activeTabId,
allTabIds: focusedPaneTabIds,
retainedTabIds: modifiedFocusedPaneTabIds,
cap: 3,
});
const mountedFocusedPaneTabIds = useMemo(