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
@@ -33,6 +33,7 @@ import {
Plus,
FolderGit2,
SquareTerminal,
Code2,
} from "lucide-react-native";
import { DropdownTrigger } from "@/components/ui/dropdown-trigger";
import { ComboboxTrigger } from "@/components/ui/combobox-trigger";
@@ -65,6 +66,7 @@ import { AddHostMethodModal } from "@/components/add-host-method-modal";
import { AddHostModal } from "@/components/add-host-modal";
import { PairLinkModal } from "@/components/pair-link-modal";
import { KeyboardShortcutsSection } from "@/screens/settings/keyboard-shortcuts-section";
import { EditorSection } from "@/screens/settings/editor-section";
import { Button } from "@/components/ui/button";
import { CommunityLinks } from "@/components/community-links";
import { SegmentedControl } from "@/components/ui/segmented-control";
@@ -135,6 +137,7 @@ interface SidebarSectionItem {
const SIDEBAR_SECTION_ITEMS: SidebarSectionItem[] = [
{ id: "general", labelKey: "settings.sections.general", icon: Settings },
{ id: "appearance", labelKey: "settings.sections.appearance", icon: Palette },
{ id: "editor", labelKey: "settings.sections.editor", icon: Code2 },
{ id: "shortcuts", labelKey: "settings.sections.shortcuts", icon: Keyboard, desktopOnly: true },
{
id: "integrations",
@@ -1403,6 +1406,8 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti
);
case "appearance":
return <AppearanceSection />;
case "editor":
return <EditorSection />;
case "shortcuts":
return isDesktopApp ? <KeyboardShortcutsSection /> : null;
case "integrations":
@@ -0,0 +1,33 @@
import { Switch, Text, View } from "react-native";
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { useAppSettings } from "@/hooks/use-settings";
import { SettingsSection } from "./settings-section";
import { settingsStyles } from "@/styles/settings";
export function EditorSection() {
const { t } = useTranslation();
const { settings, updateSettings } = useAppSettings();
const handleChange = useCallback(
(vimKeybindings: boolean) => void updateSettings({ vimKeybindings }),
[updateSettings],
);
return (
<SettingsSection title={t("settings.editor.title")}>
<View style={settingsStyles.card}>
<View style={settingsStyles.row}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>{t("settings.editor.vimKeybindings")}</Text>
<Text style={settingsStyles.rowHint}>{t("settings.editor.vimHint")}</Text>
</View>
<Switch
value={settings.vimKeybindings}
onValueChange={handleChange}
accessibilityLabel={t("settings.editor.vimKeybindings")}
testID="vim-keybindings-toggle"
/>
</View>
</View>
</SettingsSection>
);
}
@@ -49,4 +49,23 @@ describe("useMountedTabSet", () => {
rerender({ activeTabId: "third" });
expect(mountedIds(result)).toEqual(["third", "second"]);
});
it("keeps retained panels mounted beyond the normal cap", () => {
const { result, rerender } = renderHook(
({ activeTabId }) =>
useMountedTabSet({
activeTabId,
allTabIds: ["modified", "second", "third", "fourth"],
retainedTabIds: new Set(["modified"]),
cap: 2,
}),
{ initialProps: { activeTabId: "modified" } },
);
rerender({ activeTabId: "second" });
rerender({ activeTabId: "third" });
rerender({ activeTabId: "fourth" });
expect(mountedIds(result)).toEqual(["fourth", "modified"]);
});
});
@@ -4,6 +4,7 @@ interface UseMountedTabSetInput {
activeTabId: string | null;
allTabIds: string[];
cap: number;
retainedTabIds?: Set<string>;
}
interface UseMountedTabSetResult {
@@ -15,6 +16,7 @@ interface DeriveMountedTabLruInput {
availableTabIds: Set<string>;
cap: number;
previousLru: string[];
retainedTabIds: Set<string>;
}
function createInitialMountedTabLru(input: UseMountedTabSetInput): string[] {
@@ -25,7 +27,7 @@ function createInitialMountedTabLru(input: UseMountedTabSetInput): string[] {
}
function deriveMountedTabLru(input: DeriveMountedTabLruInput): string[] {
const { activeTabId, availableTabIds, cap, previousLru } = input;
const { activeTabId, availableTabIds, cap, previousLru, retainedTabIds } = input;
const maxSize = Math.max(1, cap);
const next: string[] = [];
@@ -33,6 +35,10 @@ function deriveMountedTabLru(input: DeriveMountedTabLruInput): string[] {
next.push(activeTabId);
}
for (const tabId of retainedTabIds) {
if (tabId !== activeTabId && availableTabIds.has(tabId)) next.push(tabId);
}
for (const tabId of previousLru) {
if (next.length >= maxSize) break;
if (tabId !== activeTabId && availableTabIds.has(tabId)) {
@@ -57,8 +63,9 @@ export function useMountedTabSet(input: UseMountedTabSetInput): UseMountedTabSet
availableTabIds,
cap,
previousLru: committedLruRef.current,
retainedTabIds: input.retainedTabIds ?? new Set(),
}),
[activeTabId, availableTabIds, cap],
[activeTabId, availableTabIds, cap, input.retainedTabIds],
);
const mountedTabIds = useMemo(() => new Set<string>(mountedTabLru), [mountedTabLru]);
@@ -553,12 +553,14 @@ function TabChip({
onCloseTab: (tabId: string) => Promise<void> | void;
dragHandleProps: DraggableListDragHandleProps | undefined;
}) {
const { t } = useTranslation();
const { closeButtonTestId, contextMenuTestId, menuEntries } = resolvedTab;
const middleClickRef = useMiddleClickClose(
useCallback(() => void onCloseTab(tab.tabId), [onCloseTab, tab.tabId]),
);
const [hovered, setHovered] = useState(false);
const isHighlighted = isActive || hovered || isCloseHovered;
const showTrailingAffordance = showCloseButton || presentation.modified;
const closeButtonDragBlockers = isWeb
? ({
onPointerDown: (event: { stopPropagation?: () => void }) => {
@@ -630,16 +632,19 @@ function TabChip({
[isFocused],
);
const tabLabelSkeletonStyle = useMemo(
() => [styles.tabLabelSkeleton, showCloseButton && styles.tabLabelSkeletonWithCloseButton],
[showCloseButton],
() => [
styles.tabLabelSkeleton,
showTrailingAffordance && styles.tabLabelSkeletonWithCloseButton,
],
[showTrailingAffordance],
);
const tabLabelStyle = useMemo(
() => [
styles.tabLabel,
isHighlighted && styles.tabLabelActive,
showCloseButton && styles.tabLabelWithCloseButton,
showTrailingAffordance && styles.tabLabelWithCloseButton,
],
[isHighlighted, showCloseButton],
[isHighlighted, showTrailingAffordance],
);
return (
@@ -672,7 +677,7 @@ function TabChip({
tabLabelStyle={tabLabelStyle}
/>
{showCloseButton ? (
{showTrailingAffordance ? (
<Pressable
{...(closeButtonDragBlockers as object | undefined)}
testID={closeButtonTestId}
@@ -683,28 +688,43 @@ function TabChip({
onPress={handleCloseButtonPress}
style={closeButtonStyle}
>
{({ hovered: closeHovered, pressed }) =>
isClosingTab ? (
<ThemedActivityIndicator
size={12}
uniProps={
closeHovered || pressed ? foregroundColorMapping : mutedColorMapping
}
{({ hovered: closeHovered, pressed }) => {
const highlighted = closeHovered || pressed;
if (isClosingTab) {
return (
<ThemedActivityIndicator
size={12}
uniProps={highlighted ? foregroundColorMapping : mutedColorMapping}
/>
);
}
if (highlighted || !presentation.modified) {
return (
<ThemedX
size={12}
uniProps={highlighted ? foregroundColorMapping : mutedColorMapping}
/>
);
}
return (
<View
style={styles.tabModifiedDot}
accessibilityLabel={t("workspace.tabs.modified")}
testID={`workspace-tab-modified-${buildDeterministicWorkspaceTabId(tab.target)}`}
/>
) : (
<ThemedX
size={12}
uniProps={
closeHovered || pressed ? foregroundColorMapping : mutedColorMapping
}
/>
)
}
);
}}
</Pressable>
) : null}
</ContextMenuTrigger>
</TooltipTrigger>
<TooltipContent side="bottom" align="center" offset={8}>
<TooltipContent
side="bottom"
align="center"
offset={8}
maxWidth={720}
testID={`workspace-tab-tooltip-${buildDeterministicWorkspaceTabId(tab.target)}`}
>
{tab.target.kind === "agent" ? (
<View style={styles.tooltipAgentRow}>
<Text style={styles.newTabTooltipText}>{tooltipLabel}</Text>
@@ -1161,7 +1181,7 @@ function ResolvedDesktopTabChip({
const tooltipLabel =
presentation.titleState === "loading"
? t("workspace.tabs.loadingAgentTitle")
: presentation.label;
: presentation.tooltip;
return (
<View style={styles.tabSlot}>
@@ -1331,6 +1351,12 @@ const styles = StyleSheet.create((theme) => ({
tabCloseButtonActive: {
backgroundColor: theme.colors.surface3,
},
tabModifiedDot: {
width: 8,
height: 8,
borderRadius: theme.borderRadius.full,
backgroundColor: theme.colors.foregroundMuted,
},
newTabActionButton: {
width: 22,
height: 22,
@@ -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(
@@ -12,12 +12,15 @@ import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
import { isEmphasizedStatusDotBucket } from "@/utils/status-dot-color";
import { shouldRenderSyncedStatusLoader } from "@/utils/status-loader";
import type { Theme } from "@/styles/theme";
import { usePanelInstanceAttributes } from "@/panels/panel-instance-attributes";
export interface WorkspaceTabPresentation {
key: string;
kind: WorkspaceTabDescriptor["kind"];
label: string;
subtitle: string;
tooltip: string;
modified: boolean;
titleState: "ready" | "loading";
icon: React.ComponentType<{ size: number; color: string }>;
statusBucket: SidebarStateBucket | null;
@@ -72,7 +75,9 @@ function WorkspaceTabPresentationResolverInner({
const descriptor = registration.useDescriptor(tab.target as never, {
serverId,
workspaceId,
tabId: tab.tabId,
});
const attributes = usePanelInstanceAttributes({ serverId, workspaceId, tabId: tab.tabId });
const presentation = useMemo(
() => ({
@@ -80,6 +85,8 @@ function WorkspaceTabPresentationResolverInner({
kind: tab.kind,
label: descriptor.label,
subtitle: descriptor.subtitle,
tooltip: descriptor.tooltip,
modified: attributes.modified,
titleState: descriptor.titleState,
icon: descriptor.icon,
statusBucket: descriptor.statusBucket,
@@ -87,11 +94,13 @@ function WorkspaceTabPresentationResolverInner({
[
descriptor.icon,
descriptor.label,
descriptor.tooltip,
descriptor.statusBucket,
descriptor.subtitle,
descriptor.titleState,
tab.key,
tab.kind,
attributes.modified,
],
);
@@ -208,6 +217,9 @@ export function WorkspaceTabOptionRow({
</Text>
</View>
</Pressable>
{presentation.modified ? (
<View style={styles.optionModifiedDot} accessibilityLabel={t("workspace.tabs.modified")} />
) : null}
{selected ? (
<View style={styles.optionTrailingSlot}>
<ThemedCheckIcon size={16} uniProps={mutedColorMapping} />
@@ -303,6 +315,12 @@ const styles = StyleSheet.create((theme) => ({
alignItems: "center",
justifyContent: "center",
},
optionModifiedDot: {
width: 8,
height: 8,
borderRadius: theme.borderRadius.full,
backgroundColor: theme.colors.foregroundMuted,
},
optionTrailingAccessorySlot: {
alignItems: "center",
justifyContent: "center",