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:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user