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-20 22:41:19 +02:00
committed by GitHub
parent 9292f58896
commit 4bda2dfea9
69 changed files with 3678 additions and 241 deletions

View File

@@ -0,0 +1,249 @@
import { mkdir, readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { expect, test, type Page } from "./fixtures";
import { openFileExplorer, openFileFromExplorer, expectFileTabOpen } from "./helpers/file-explorer";
import { installDaemonWebSocketGate } from "./helpers/daemon-websocket-gate";
const RED_PIXEL = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9ZQmcAAAAASUVORK5CYII=",
"base64",
);
const BLUE_PIXEL = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
"base64",
);
function editor(page: Page) {
return page.getByTestId("file-source-editor").filter({ visible: true }).locator(".cm-content");
}
async function replaceEditorText(page: Page, content: string): Promise<void> {
const contentElement = editor(page);
await contentElement.click();
await contentElement.press("Control+A");
await contentElement.type(content);
}
async function openWorkspaceFile(page: Page, filename: string): Promise<void> {
const tree = page.getByTestId("file-explorer-tree-scroll");
if (!(await tree.isVisible())) await openFileExplorer(page);
await openFileFromExplorer(page, filename);
await expectFileTabOpen(page, filename);
}
test.describe("CodeMirror workspace file editing", () => {
test("shows the full file path and keeps editor controls stable", async ({
page,
withWorkspace,
}) => {
await page.emulateMedia({ colorScheme: "dark" });
const workspace = await withWorkspace({ prefix: "file-editing-visuals-" });
const relativePath = "src/deep/visuals.md";
const sourcePath = path.join(workspace.repoPath, relativePath);
await mkdir(path.dirname(sourcePath), { recursive: true });
await writeFile(
sourcePath,
[...Array.from({ length: 11 }, (_, index) => `line ${index + 1}`), "abcdefghijklmnop"].join(
"\n",
),
"utf8",
);
await workspace.navigateTo();
await openFileExplorer(page);
await page.getByTestId("file-explorer-tree-scroll").getByText("src", { exact: true }).click();
await page.getByTestId("file-explorer-tree-scroll").getByText("deep", { exact: true }).click();
await openFileFromExplorer(page, "visuals.md");
await expectFileTabOpen(page, relativePath);
const fileTab = page.getByTestId(`workspace-tab-file_${relativePath}`).first();
await fileTab.hover();
await expect(page.getByTestId(`workspace-tab-tooltip-file_${relativePath}`)).toHaveText(
relativePath,
);
await expect(page.getByTestId("file-panel-bar")).not.toContainText("visuals.md");
const modeControl = page.getByTestId("file-markdown-mode");
await expect(modeControl).toBeVisible();
await page.getByTestId("file-mode-source").click();
const editorHost = page.getByTestId("file-source-editor");
const content = editor(page);
await expect(editorHost).toHaveAttribute("data-pmono", "");
await expect(content).toHaveCSS("font-family", /SFMono-Regular/);
await content.click();
const cursor = editorHost.locator(".cm-cursor-primary");
await expect(cursor).toBeVisible();
await expect(cursor).toHaveCSS("border-left-color", "rgb(250, 250, 250)");
const initialModeBox = await modeControl.boundingBox();
expect(initialModeBox).not.toBeNull();
const initialModeX = initialModeBox!.x;
await content.press("Control+End");
await expect(page.getByLabel(/Line 12, column \d+/)).toBeVisible();
const movedModeBox = await modeControl.boundingBox();
expect(movedModeBox).not.toBeNull();
expect(movedModeBox!.x).toBe(initialModeX);
await content.press("Control+a");
const selection = editorHost.locator(".cm-selectionBackground").first();
await expect(selection).toBeVisible();
await expect(selection).toHaveCSS("background-color", "rgba(255, 255, 255, 0.2)");
});
test("autosaves, saves immediately, resolves conflicts, and restores live updates after reconnect", async ({
page,
withWorkspace,
}) => {
test.setTimeout(120_000);
const gate = await installDaemonWebSocketGate(page);
const workspace = await withWorkspace({ prefix: "file-editing-source-" });
const sourcePath = path.join(workspace.repoPath, "source.ts");
await writeFile(sourcePath, "const initial = 1;\n", "utf8");
await Promise.all(
["one.ts", "two.ts", "three.ts", "four.ts"].map((fileName) =>
writeFile(path.join(workspace.repoPath, fileName), `// ${fileName}\n`, "utf8"),
),
);
await workspace.navigateTo();
await openWorkspaceFile(page, "source.ts");
await expect(page.getByTestId("file-source-editor")).toBeVisible();
await expect(page.getByLabel(/File size/)).toBeVisible();
await expect(page.getByLabel(/lines/)).toBeVisible();
await replaceEditorText(page, "const autosaved = 2;\n");
await expect(page.getByTestId("workspace-tab-modified-file_source.ts")).toBeVisible();
await expect(page.getByLabel("Editor status dirty")).toBeVisible();
await expect(page.getByLabel("Editor status clean")).toBeVisible({ timeout: 5_000 });
await expect(page.getByTestId("workspace-tab-modified-file_source.ts")).not.toBeVisible();
await expect.poll(() => readFile(sourcePath, "utf8")).toBe("const autosaved = 2;\n");
await replaceEditorText(page, "const immediate = 3;\n");
await editor(page).press("Control+s");
await expect.poll(() => readFile(sourcePath, "utf8")).toBe("const immediate = 3;\n");
await writeFile(sourcePath, "const external = 4;\nconst line = 2;\n", "utf8");
await expect(editor(page)).toContainText("const external = 4;");
await expect(page.getByLabel("3 lines")).toBeVisible();
await replaceEditorText(page, "const localWins = 5;\n");
await writeFile(sourcePath, "const diskLoses = 6;\n", "utf8");
await expect(page.getByTestId("file-conflict-alert")).toBeVisible();
for (const fileName of ["one.ts", "two.ts", "three.ts", "four.ts"]) {
await openWorkspaceFile(page, fileName);
}
await page.getByTestId("workspace-tab-file_source.ts").filter({ visible: true }).click();
await expect(editor(page)).toContainText("const localWins = 5;");
await expect(page.getByTestId("file-conflict-alert")).toBeVisible();
await page.getByRole("button", { name: "Overwrite", exact: true }).click();
await expect.poll(() => readFile(sourcePath, "utf8")).toBe("const localWins = 5;\n");
await replaceEditorText(page, "const discarded = 7;\n");
await writeFile(sourcePath, "const diskWins = 8;\n", "utf8");
await expect(page.getByTestId("file-conflict-alert")).toBeVisible();
page.once("dialog", (dialog) => dialog.accept());
await page.getByRole("button", { name: "Reload", exact: true }).click();
await expect(editor(page)).toContainText("const diskWins = 8;");
const subscriptionCount = gate.getClientRequestCount("fs.file.subscribe.request");
await gate.drop();
gate.restore();
await expect
.poll(() => gate.getClientRequestCount("fs.file.subscribe.request"), { timeout: 30_000 })
.toBeGreaterThan(subscriptionCount);
await writeFile(sourcePath, "const afterReconnect = 9;\n", "utf8");
await expect(editor(page)).toContainText("const afterReconnect = 9;");
});
test("warns before closing a panel with an unsaved draft", async ({ page, withWorkspace }) => {
const workspace = await withWorkspace({ prefix: "file-editing-draft-" });
const sourcePath = path.join(workspace.repoPath, "draft.ts");
await writeFile(sourcePath, "const initial = 1;\n", "utf8");
await workspace.navigateTo();
await openWorkspaceFile(page, "draft.ts");
await replaceEditorText(page, "const local = 2;\n");
await writeFile(sourcePath, "const external = 3;\n", "utf8");
await expect(page.getByTestId("file-conflict-alert")).toBeVisible();
await expect(page.getByTestId("workspace-tab-modified-file_draft.ts")).toBeVisible();
let closePrompt = "";
page.once("dialog", async (dialog) => {
closePrompt = dialog.message();
await dialog.dismiss();
});
await page
.getByTestId("workspace-tab-file_draft.ts")
.filter({ visible: true })
.first()
.click({ button: "right" });
await page
.getByTestId("workspace-tab-context-file_draft.ts-close")
.filter({ visible: true })
.click();
expect(closePrompt).toContain("Closing it will discard the draft.");
await expect(page.getByTestId("file-source-editor")).toBeVisible();
await expect(page.getByTestId("workspace-tab-modified-file_draft.ts")).toBeVisible();
});
test("refreshes Markdown and images while preserving Preview and Source behavior", async ({
page,
withWorkspace,
}) => {
test.setTimeout(90_000);
const workspace = await withWorkspace({ prefix: "file-editing-preview-" });
const markdownPath = path.join(workspace.repoPath, "notes.md");
const imagePath = path.join(workspace.repoPath, "pixel.png");
await writeFile(markdownPath, "# First heading\n", "utf8");
await writeFile(imagePath, RED_PIXEL);
await workspace.navigateTo();
await openWorkspaceFile(page, "notes.md");
await expect(page.getByText("First heading", { exact: true })).toBeVisible();
await expect(page.getByTestId("file-markdown-mode")).toBeVisible();
await writeFile(markdownPath, "# Updated heading\n", "utf8");
await expect(page.getByText("Updated heading", { exact: true })).toBeVisible();
await page.getByTestId("file-mode-source").click();
await expect(page.getByTestId("file-source-editor")).toBeVisible();
await replaceEditorText(page, "# Saved from source\n");
await expect.poll(() => readFile(markdownPath, "utf8")).toBe("# Saved from source\n");
await page.getByTestId("file-mode-preview").click();
await expect(page.getByText("Saved from source", { exact: true })).toBeVisible();
await openWorkspaceFile(page, "pixel.png");
const image = page.getByTestId("workspace-file-pane").locator("img");
await expect(image).toBeVisible();
const initialSource = await image.getAttribute("src");
await writeFile(imagePath, BLUE_PIXEL);
await expect.poll(() => image.getAttribute("src")).not.toBe(initialSource);
});
test("persists Vim keybindings and reports Vim mode with cursor position", async ({
page,
withWorkspace,
}) => {
test.setTimeout(90_000);
const workspace = await withWorkspace({ prefix: "file-editing-vim-" });
await writeFile(path.join(workspace.repoPath, "vim.ts"), "const vim = true;\n", "utf8");
await page.goto("/settings/editor");
const toggle = page.getByRole("switch", { name: "Vim keybindings" });
await expect(toggle).toBeVisible();
await toggle.click();
await expect(toggle).toBeChecked();
await page.reload();
await expect(page.getByRole("switch", { name: "Vim keybindings" })).toBeChecked();
await workspace.navigateTo();
await openWorkspaceFile(page, "vim.ts");
await expect(page.getByLabel("Vim mode NORMAL")).toBeVisible();
await expect(page.getByLabel("Line 1, column 1")).toBeVisible();
await editor(page).click();
await editor(page).press("i");
await expect(page.getByLabel("Vim mode INSERT")).toBeVisible();
await editor(page).press("Escape");
await expect(page.getByLabel("Vim mode NORMAL")).toBeVisible();
});
});

View File

@@ -22,6 +22,7 @@ interface SavedSettingsHostInput {
const SECTION_LABELS = {
general: "General",
appearance: "Appearance",
editor: "Editor",
shortcuts: "Shortcuts",
integrations: "Integrations",
permissions: "Permissions",

View File

@@ -32,6 +32,11 @@
"build:terminal-webview": "node ./scripts/build-terminal-webview-html.mjs"
},
"dependencies": {
"@codemirror/commands": "6.10.4",
"@codemirror/language": "6.12.4",
"@codemirror/search": "6.7.1",
"@codemirror/state": "6.7.1",
"@codemirror/view": "6.43.6",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
@@ -46,6 +51,7 @@
"@react-native-masked-view/masked-view": "^0.3.2",
"@react-native/normalize-colors": "^0.81.5",
"@react-navigation/native": "^7.1.8",
"@replit/codemirror-vim": "6.3.0",
"@tanstack/react-query": "^5.90.11",
"@tanstack/react-virtual": "^3.13.21",
"@xterm/addon-clipboard": "^0.3.0-beta.213",

View File

@@ -15,7 +15,6 @@ import {
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
import * as Clipboard from "expo-clipboard";
import { SvgXml } from "react-native-svg";
import {
ChevronDown,
Copy,
@@ -25,7 +24,7 @@ import {
MoreVertical,
RotateCw,
} from "lucide-react-native";
import { getFileIconSvg } from "@/components/material-file-icons";
import { MaterialFileIcon } from "@/components/material-file-icon";
import { TreeChevron, TreeIndentGuides, TREE_INDENT_PER_LEVEL } from "@/components/tree-primitives";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import type { AgentFileExplorerState, ExplorerEntry } from "@/stores/session-store";
@@ -154,7 +153,7 @@ function TreeRowItem({
<View style={styles.entryIcon}>
{(() => {
if (!isDirectory) {
return <SvgXml xml={getFileIconSvg(entry.name)} width={16} height={16} />;
return <MaterialFileIcon fileName={entry.name} size={16} />;
}
if (loading) return <ActivityIndicator size="small" />;
return <TreeChevron expanded={isExpanded} />;

View File

@@ -0,0 +1,15 @@
import type { ComponentType } from "react";
import { SvgXml } from "react-native-svg";
import { getFileIconSvg } from "@/components/material-file-icons";
import type { PanelIconProps } from "@/panels/panel-registry";
export function MaterialFileIcon({ fileName, size }: { fileName: string; size: number }) {
return <SvgXml xml={getFileIconSvg(fileName)} width={size} height={size} />;
}
export function createMaterialFileIcon(fileName: string): ComponentType<PanelIconProps> {
function BoundMaterialFileIcon({ size }: PanelIconProps) {
return <MaterialFileIcon fileName={fileName} size={size} />;
}
return BoundMaterialFileIcon;
}

View File

@@ -55,6 +55,7 @@ import {
getWorkspacePaneDescriptors,
} from "@/screens/workspace/workspace-pane-state";
import { useMountedTabSet } from "@/screens/workspace/use-mounted-tab-set";
import { useModifiedPanelTabIds } from "@/panels/panel-instance-attributes";
import {
WorkspacePaneContent,
type WorkspacePaneContentModel,
@@ -934,11 +935,17 @@ function SplitPaneView({
);
const paneTabs = useMemo(() => paneState.tabs.map((tab) => tab.descriptor), [paneState.tabs]);
const paneTabIds = useMemo(() => paneTabs.map((tab) => tab.tabId), [paneTabs]);
const modifiedPaneTabIds = useModifiedPanelTabIds({
serverId: normalizedServerId,
workspaceId: normalizedWorkspaceId,
tabIds: paneTabIds,
});
const tabDescriptorMap = useStableTabDescriptorMap(paneTabs);
const activeTabDescriptor = paneState.activeTab?.descriptor ?? null;
const { mountedTabIds } = useMountedTabSet({
activeTabId: activeTabDescriptor?.tabId ?? null,
allTabIds: paneTabIds,
retainedTabIds: modifiedPaneTabIds,
cap: 3,
});
const mountedPaneTabIds = useMemo(

View File

@@ -10,6 +10,7 @@ const theme = {
md: 6,
lg: 8,
xl: 12,
full: 9999,
},
borderWidth: {
1: 1,
@@ -102,12 +103,41 @@ describe("control geometry", () => {
expect(geometry.formTextInputMd.paddingVertical).toBe(11);
});
it("subtracts segmented control inset from the nested segment radius", () => {
it("keeps segmented controls ghost with fully rounded segments in a button-sized track", () => {
const geometry = createControlGeometry(theme);
expect(geometry.segmentedContainerSm.borderRadius).toBe(6);
expect(geometry.segmentedSegmentSm.borderRadius).toBe(4);
expect(geometry.segmentedContainerMd.borderRadius).toBe(8);
expect(geometry.segmentedSegmentMd.borderRadius).toBe(5);
expect(geometry.segmentedContainerXs.padding).toBe(0);
expect(geometry.segmentedContainerSm.padding).toBe(0);
expect(geometry.segmentedContainerMd.padding).toBe(0);
expect(geometry.segmentedSegmentXs.borderRadius).toBe(9999);
expect(geometry.segmentedSegmentSm.borderRadius).toBe(9999);
expect(geometry.segmentedSegmentMd.borderRadius).toBe(9999);
expect(geometry.segmentedContainerXs.minHeight).toBe(geometry.buttonXs.minHeight);
expect(geometry.segmentedContainerSm.minHeight).toBe(geometry.buttonSm.minHeight);
expect(geometry.segmentedContainerMd.minHeight).toBe(geometry.buttonMd.minHeight);
expect(geometry.segmentedSegmentXs.minHeight).toBe(24);
expect(geometry.segmentedSegmentSm.minHeight).toBe(28);
expect(geometry.segmentedSegmentMd.minHeight).toBe(38);
});
it("keeps one size contract across buttons and segmented controls", () => {
const geometry = createControlGeometry(theme);
// xs is a genuinely smaller tier, not sm with a different font.
expect(geometry.buttonXs.minHeight).toBe(28);
expect(geometry.buttonSm.minHeight).toBe(32);
expect(geometry.buttonMd.minHeight).toBe(44);
// Same size name means the same label size on every control kind.
expect(geometry.segmentedLabelXs.fontSize).toBe(12);
expect(geometry.segmentedLabelXs.fontSize).toBe(geometry.buttonTextXs.fontSize);
expect(geometry.segmentedLabelSm.fontSize).toBe(14);
expect(geometry.segmentedLabelSm.fontSize).toBe(geometry.buttonText.fontSize);
expect(geometry.segmentedLabelMd.fontSize).toBe(geometry.buttonText.fontSize);
// Same size name means the same horizontal padding on every control kind.
expect(geometry.segmentedSegmentXs.paddingHorizontal).toBe(geometry.buttonXs.paddingHorizontal);
expect(geometry.segmentedSegmentSm.paddingHorizontal).toBe(geometry.buttonSm.paddingHorizontal);
expect(geometry.segmentedSegmentMd.paddingHorizontal).toBe(geometry.buttonMd.paddingHorizontal);
});
});

View File

@@ -3,7 +3,7 @@ import { ICON_SIZE, type Theme } from "@/styles/theme";
export type ButtonControlSize = "xs" | "sm" | "md" | "lg";
export type FieldControlSize = "sm" | "md";
export type SegmentedControlSize = "sm" | "md";
export type SegmentedControlSize = "xs" | "sm" | "md";
export type ControlInteractionPhase = "rest" | "hover" | "active";
export interface ControlInteractionState {
@@ -22,8 +22,10 @@ export interface ControlInteractionStyleMap {
controlDisabled?: StyleProp<ViewStyle>;
}
const TIGHT_CONTROL_HEIGHT = 28;
const COMPACT_CONTROL_HEIGHT = 32;
const FIELD_CONTROL_HEIGHT = 44;
const SEGMENTED_TIGHT_INSET = 2;
const SEGMENTED_COMPACT_INSET = 2;
const SEGMENTED_FIELD_INSET = 3;
const SWITCH_TRACK_WIDTH = 34;
@@ -35,6 +37,7 @@ const CONTROL_CENTER_JUSTIFY_CONTENT = "center";
const FIELD_TEXT_LINE_HEIGHT_RATIO = 1.4;
const controlHeights = {
tight: TIGHT_CONTROL_HEIGHT,
compact: COMPACT_CONTROL_HEIGHT,
field: FIELD_CONTROL_HEIGHT,
};
@@ -47,6 +50,7 @@ export const buttonIconSize: Record<ButtonControlSize, number> = {
};
export const segmentedIconSize: Record<SegmentedControlSize, number> = {
xs: ICON_SIZE.xs,
sm: ICON_SIZE.sm,
md: ICON_SIZE.md,
};
@@ -58,10 +62,6 @@ export const switchGeometry = {
thumbTravel: SWITCH_TRACK_WIDTH - SWITCH_THUMB_SIZE - (SWITCH_TRACK_HEIGHT - SWITCH_THUMB_SIZE),
};
function nestedRadius(containerRadius: number, inset: number): number {
return Math.max(0, containerRadius - inset);
}
function fieldLineHeight(fontSize: number): number {
return Math.round(fontSize * FIELD_TEXT_LINE_HEIGHT_RATIO);
}
@@ -121,8 +121,6 @@ export function createControlGeometry(theme: Theme) {
fontSize: theme.fontSize.base,
lineHeight: fieldTextMdLineHeight,
};
const segmentedContainerSmRadius = theme.borderRadius.md;
const segmentedContainerMdRadius = theme.borderRadius.lg;
const switchControl = {
minHeight: controlHeights.compact,
justifyContent: CONTROL_CENTER_JUSTIFY_CONTENT,
@@ -130,7 +128,7 @@ export function createControlGeometry(theme: Theme) {
return {
buttonXs: {
minHeight: controlHeights.compact,
minHeight: controlHeights.tight,
paddingHorizontal: theme.spacing[3],
borderRadius: theme.borderRadius.md,
},
@@ -194,31 +192,41 @@ export function createControlGeometry(theme: Theme) {
opacity: theme.opacity[50],
},
switchControl,
segmentedContainerXs: {
minHeight: controlHeights.tight,
padding: 0,
},
segmentedContainerSm: {
minHeight: controlHeights.compact,
padding: SEGMENTED_COMPACT_INSET,
borderRadius: segmentedContainerSmRadius,
padding: 0,
},
segmentedContainerMd: {
minHeight: controlHeights.field,
padding: SEGMENTED_FIELD_INSET,
borderRadius: segmentedContainerMdRadius,
padding: 0,
},
segmentedSegmentXs: {
minHeight: controlHeights.tight - SEGMENTED_TIGHT_INSET * 2,
paddingHorizontal: theme.spacing[3],
borderRadius: theme.borderRadius.full,
},
segmentedSegmentSm: {
minHeight: controlHeights.compact - SEGMENTED_COMPACT_INSET * 2,
paddingHorizontal: theme.spacing[4],
borderRadius: nestedRadius(segmentedContainerSmRadius, SEGMENTED_COMPACT_INSET),
paddingHorizontal: theme.spacing[3],
borderRadius: theme.borderRadius.full,
},
segmentedSegmentMd: {
minHeight: controlHeights.field - SEGMENTED_FIELD_INSET * 2,
paddingHorizontal: theme.spacing[6],
borderRadius: nestedRadius(segmentedContainerMdRadius, SEGMENTED_FIELD_INSET),
paddingHorizontal: theme.spacing[4],
borderRadius: theme.borderRadius.full,
},
segmentedLabelXs: {
fontSize: theme.fontSize.xs,
},
segmentedLabelSm: {
fontSize: theme.fontSize.sm,
},
segmentedLabelMd: {
fontSize: theme.fontSize.base,
fontSize: theme.fontSize.sm,
},
};
}

View File

@@ -41,7 +41,7 @@ function SegmentIcon({ icon, iconSize, iconColor }: SegmentIconProps) {
const ThemedSegmentIcon = withUnistyles(SegmentIcon);
const selectedIconMapping = (theme: Theme) => ({ iconColor: theme.colors.foreground });
const selectedIconMapping = (theme: Theme) => ({ iconColor: theme.colors.surface0 });
const mutedIconMapping = (theme: Theme) => ({ iconColor: theme.colors.foregroundMuted });
export function SegmentedControl<T extends string>({
@@ -53,9 +53,14 @@ export function SegmentedControl<T extends string>({
style,
testID,
}: SegmentedControlProps<T>) {
const containerSizeStyle = size === "sm" ? styles.containerSm : styles.containerMd;
const segmentSizeStyle = size === "sm" ? styles.segmentSm : styles.segmentMd;
const labelSizeStyle = size === "sm" ? styles.labelSm : styles.labelMd;
const sizeStyles = {
xs: { container: styles.containerXs, segment: styles.segmentXs, label: styles.labelXs },
sm: { container: styles.containerSm, segment: styles.segmentSm, label: styles.labelSm },
md: { container: styles.containerMd, segment: styles.segmentMd, label: styles.labelMd },
}[size];
const containerSizeStyle = sizeStyles.container;
const segmentSizeStyle = sizeStyles.segment;
const labelSizeStyle = sizeStyles.label;
const iconSize = segmentedIconSize[size];
const containerStyle = useMemo(
@@ -161,9 +166,12 @@ const styles = StyleSheet.create((theme) => {
return {
container: {
flexDirection: "row",
alignItems: "stretch",
backgroundColor: theme.colors.surface2,
gap: 2,
alignItems: "center",
backgroundColor: "transparent",
gap: theme.spacing[1],
},
containerXs: {
...geometry.segmentedContainerXs,
},
containerSm: {
...geometry.segmentedContainerSm,
@@ -178,6 +186,9 @@ const styles = StyleSheet.create((theme) => {
flexShrink: 0,
gap: theme.spacing[1],
},
segmentXs: {
...geometry.segmentedSegmentXs,
},
segmentSm: {
...geometry.segmentedSegmentSm,
},
@@ -185,18 +196,13 @@ const styles = StyleSheet.create((theme) => {
...geometry.segmentedSegmentMd,
},
segmentSelected: {
backgroundColor: theme.colors.surface0,
shadowColor: "#000",
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.08,
shadowRadius: 2,
elevation: 1,
backgroundColor: theme.colors.foreground,
},
segmentHover: {
backgroundColor: theme.colors.surface1,
backgroundColor: theme.colors.surface2,
},
segmentPressed: {
backgroundColor: theme.colors.surface1,
backgroundColor: theme.colors.surface3,
},
segmentDisabled: {
opacity: theme.opacity[50],
@@ -209,6 +215,9 @@ const styles = StyleSheet.create((theme) => {
color: theme.colors.foregroundMuted,
fontWeight: theme.fontWeight.normal,
},
labelXs: {
...geometry.segmentedLabelXs,
},
labelSm: {
...geometry.segmentedLabelSm,
},
@@ -216,7 +225,7 @@ const styles = StyleSheet.create((theme) => {
...geometry.segmentedLabelMd,
},
labelSelected: {
color: theme.colors.foreground,
color: theme.colors.surface0,
},
};
});

View File

@@ -0,0 +1,178 @@
import { Text, View } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { SegmentedControl } from "@/components/ui/segmented-control";
import type { Theme } from "@/styles/theme";
import { FileConflictAlert } from "./conflict-alert";
import type { FileEditorStatus } from "./editor/model";
const ThemedSpinner = withUnistyles(LoadingSpinner);
const spinnerMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
export function FilePanelBar({
size,
lineCount,
mode,
onModeChange,
editorStatus,
cursor,
vimMode,
conflictUnavailable,
onOverwrite,
onReload,
}: {
size: number;
lineCount?: number;
mode?: "preview" | "source";
onModeChange?(mode: "preview" | "source"): void;
editorStatus?: FileEditorStatus;
cursor?: { line: number; column: number };
vimMode?: string | null;
conflictUnavailable?: boolean;
onOverwrite?(): void;
onReload?(): void;
}) {
const { t } = useTranslation();
const markdownModes = [
{
value: "preview" as const,
label: t("panels.file.editor.preview"),
testID: "file-mode-preview",
},
{ value: "source" as const, label: t("panels.file.editor.source"), testID: "file-mode-source" },
];
return (
<View style={styles.chrome} testID="file-panel-bar">
<View style={styles.row}>
<View style={styles.metadata}>
<Text
style={styles.whisper}
accessibilityLabel={t("panels.file.editor.fileSize", { size: formatFileSize(size) })}
>
{formatFileSize(size)}
</Text>
{lineCount !== undefined ? (
<Text
style={styles.whisper}
accessibilityLabel={t("panels.file.editor.lines", { count: lineCount })}
>
{t("panels.file.editor.lines", { count: lineCount })}
</Text>
) : null}
</View>
<View
style={styles.status}
accessibilityLabel={
editorStatus
? t("panels.file.editor.editorStatus", { status: editorStatus })
: undefined
}
>
{editorStatus === "dirty" ? (
<View
style={styles.dirtyDot}
accessibilityLabel={t("panels.file.editor.unsavedChanges")}
/>
) : null}
{editorStatus === "saving" ? (
<>
<ThemedSpinner size={14} uniProps={spinnerMapping} />
<Text style={styles.secondary}>{t("panels.file.editor.saving")}</Text>
</>
) : null}
{editorStatus === "error" ? (
<Text style={styles.error}>{t("panels.file.editor.saveFailed")}</Text>
) : null}
{editorStatus === "conflict" ? (
<Text style={styles.error}>{t("panels.file.editor.changedOnDisk")}</Text>
) : null}
{vimMode ? (
<Text
style={styles.vim}
accessibilityLabel={t("panels.file.editor.vimMode", { mode: vimMode })}
>
{vimMode}
</Text>
) : null}
{cursor ? (
<Text
style={styles.whisper}
accessibilityLabel={t("panels.file.editor.cursor", cursor)}
>
Ln {cursor.line}, Col {cursor.column}
</Text>
) : null}
</View>
{mode && onModeChange ? (
<SegmentedControl
size="xs"
value={mode}
onValueChange={onModeChange}
testID="file-markdown-mode"
options={markdownModes}
/>
) : null}
</View>
{editorStatus === "conflict" && onOverwrite && onReload ? (
<View style={styles.notice}>
<FileConflictAlert
unavailable={conflictUnavailable ?? false}
onOverwrite={onOverwrite}
onReload={onReload}
/>
</View>
) : null}
</View>
);
}
function formatFileSize(size: number): string {
if (size < 1024) return `${size} B`;
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`;
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
}
const styles = StyleSheet.create((theme) => ({
chrome: {
flexShrink: 0,
backgroundColor: theme.colors.surface1,
borderBottomWidth: 1,
borderBottomColor: theme.colors.border,
},
row: {
minHeight: 32,
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[3],
paddingHorizontal: theme.spacing[3],
},
metadata: {
flex: 1,
minWidth: 0,
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
secondary: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.xs },
whisper: { color: theme.colors.foregroundExtraMuted, fontSize: theme.fontSize.xs },
error: { color: theme.colors.palette.red[300], fontSize: theme.fontSize.xs },
dirtyDot: {
width: 6,
height: 6,
borderRadius: theme.borderRadius.full,
backgroundColor: theme.colors.foregroundExtraMuted,
},
status: {
flexShrink: 0,
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
vim: {
color: theme.colors.foregroundMuted,
fontFamily: theme.fontFamily.mono,
fontSize: theme.fontSize.xs,
},
notice: { paddingHorizontal: theme.spacing[3], paddingBottom: theme.spacing[3] },
}));

View File

@@ -0,0 +1,34 @@
import { Alert } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { useTranslation } from "react-i18next";
export function FileConflictAlert({
unavailable,
onOverwrite,
onReload,
}: {
unavailable: boolean;
onOverwrite(): void;
onReload(): void;
}) {
const { t } = useTranslation();
return (
<Alert
variant="warning"
title={
unavailable
? t("panels.file.editor.unavailableTitle")
: t("panels.file.editor.changedOnDisk")
}
description={t("panels.file.editor.conflictDescription")}
testID="file-conflict-alert"
>
<Button variant="outline" size="sm" onPress={onOverwrite} disabled={unavailable}>
{t("panels.file.editor.overwrite")}
</Button>
<Button variant="outline" size="sm" onPress={onReload} disabled={unavailable}>
{t("panels.file.editor.reload")}
</Button>
</Alert>
);
}

View File

@@ -0,0 +1,87 @@
import { defaultKeymap, history, historyKeymap, indentWithTab } from "@codemirror/commands";
import {
bracketMatching,
defaultHighlightStyle,
indentOnInput,
syntaxHighlighting,
} from "@codemirror/language";
import { searchKeymap } from "@codemirror/search";
import {
EditorView,
drawSelection,
highlightActiveLine,
keymap,
lineNumbers,
} from "@codemirror/view";
import { createCodeMirrorHighlightStyle, type HighlightStyle } from "@getpaseo/highlight";
export interface EditorVisualTheme {
colorScheme: "light" | "dark";
background: string;
foreground: string;
cursor: string;
foregroundMuted: string;
border: string;
selection: string;
monoFont: string;
codeFontSize: number;
syntax: Record<HighlightStyle, string>;
}
export function editorBaseExtensions(onSave: () => void) {
return [
lineNumbers(),
history(),
drawSelection(),
indentOnInput(),
bracketMatching(),
highlightActiveLine(),
syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
keymap.of([
{ key: "Mod-s", preventDefault: true, run: () => (onSave(), true) },
indentWithTab,
...defaultKeymap,
...historyKeymap,
...searchKeymap,
]),
];
}
export function editorTheme(theme: EditorVisualTheme) {
return [
EditorView.theme(
{
"&": {
height: "100%",
backgroundColor: theme.background,
color: theme.foreground,
fontFamily: theme.monoFont,
fontSize: `${theme.codeFontSize}px`,
},
".cm-scroller": {
overflow: "auto",
fontFamily: theme.monoFont,
lineHeight: "1.45",
},
".cm-content": { caretColor: theme.foreground, padding: "16px 0" },
".cm-cursor, .cm-dropCursor": { borderLeftColor: theme.cursor },
".cm-gutters": {
backgroundColor: theme.background,
color: theme.foregroundMuted,
borderRight: `1px solid ${theme.border}`,
},
".cm-activeLine": { backgroundColor: "transparent" },
".cm-activeLineGutter": { backgroundColor: "transparent", color: theme.foreground },
"&.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground": {
backgroundColor: theme.selection,
},
".cm-selectionBackground, ::selection": {
backgroundColor: theme.selection,
},
"&.cm-focused": { outline: "none" },
},
{ dark: theme.colorScheme === "dark" },
),
syntaxHighlighting(createCodeMirrorHighlightStyle(theme.syntax)),
];
}

View File

@@ -0,0 +1,252 @@
import { describe, expect, test } from "vitest";
import type { FileVersion, FileWriteResult } from "@getpaseo/protocol/messages";
import {
FileEditorModel,
type FileEditorClock,
type FileEditorFile,
type FileEditorSession,
} from "./model";
class TestClock implements FileEditorClock {
private callback: (() => void) | null = null;
setTimeout(callback: () => void): ReturnType<typeof setTimeout> {
this.callback = callback;
return 1 as unknown as ReturnType<typeof setTimeout>;
}
clearTimeout(): void {
this.callback = null;
}
fire(): void {
const callback = this.callback;
this.callback = null;
callback?.();
}
}
class FileSession implements FileEditorSession {
file: FileEditorFile;
writes: Array<{ content: string; expectedModifiedAt: string; expectedRevision?: string }> = [];
nextWrite: FileWriteResult | Error | null = null;
private pendingWrite: Promise<FileWriteResult> | null = null;
private resolvePendingWrite: ((result: FileWriteResult) => void) | null = null;
constructor(file: FileEditorFile) {
this.file = file;
}
async read(): Promise<FileEditorFile> {
return this.file;
}
async write(input: {
content: string;
expectedModifiedAt: string;
expectedRevision?: string;
}): Promise<FileWriteResult> {
this.writes.push(input);
if (this.pendingWrite) return this.pendingWrite;
if (this.nextWrite instanceof Error) throw this.nextWrite;
if (this.nextWrite) return this.nextWrite;
return {
status: "written",
modifiedAt: "2026-07-18T00:00:01.000Z",
size: input.content.length,
};
}
holdNextWrite(): void {
this.pendingWrite = new Promise((resolve) => {
this.resolvePendingWrite = resolve;
});
}
finishHeldWrite(result: FileWriteResult): void {
this.resolvePendingWrite?.(result);
this.pendingWrite = null;
this.resolvePendingWrite = null;
}
}
function ready(
modifiedAt = "2026-07-18T00:00:00.000Z",
size = 3,
): Extract<FileVersion, { status: "ready" }> {
return { status: "ready", cwd: "/workspace", path: "file.ts", size, modifiedAt };
}
function makeModel() {
const file = { content: "one", version: ready() as Extract<FileVersion, { status: "ready" }> };
const session = new FileSession(file);
const clock = new TestClock();
return { model: new FileEditorModel({ file, session, clock }), session, clock };
}
describe("FileEditorModel", () => {
test("tracks whether the current buffer differs from persisted content", async () => {
const { model } = makeModel();
expect(model.getSnapshot().modified).toBe(false);
model.edit("two");
expect(model.getSnapshot().modified).toBe(true);
model.edit("one");
expect(model.getSnapshot()).toMatchObject({ status: "clean", modified: false });
model.edit("saved");
await model.save();
expect(model.getSnapshot()).toMatchObject({ status: "clean", modified: false });
});
test("adopts a precise revision for otherwise unchanged initial metadata", () => {
const { model } = makeModel();
model.receiveFileVersion({ ...ready(), revision: "precise-revision" });
expect(model.getSnapshot().observedVersion).toMatchObject({ revision: "precise-revision" });
});
test("keeps a newer edit modified when an older save finishes", async () => {
const { model, session } = makeModel();
session.holdNextWrite();
model.edit("saving");
const save = model.save();
model.edit("newer edit");
session.finishHeldWrite({
status: "written",
modifiedAt: "2026-07-18T00:00:01.000Z",
size: 6,
});
await save;
expect(model.getSnapshot()).toMatchObject({
status: "dirty",
content: "newer edit",
modified: true,
});
});
test("autosaves the latest edit after inactivity", async () => {
const { model, session, clock } = makeModel();
model.edit("two");
model.edit("three");
clock.fire();
await Promise.resolve();
expect(session.writes).toEqual([
{ content: "three", expectedModifiedAt: "2026-07-18T00:00:00.000Z" },
]);
expect(model.getSnapshot().status).toBe("clean");
});
test("reloads a clean editor when the disk version changes", async () => {
const { model, session } = makeModel();
session.file = {
content: "external",
version: ready("2026-07-18T00:00:02.000Z", 8) as Extract<FileVersion, { status: "ready" }>,
};
model.receiveFileVersion(session.file.version);
await Promise.resolve();
expect(model.getSnapshot()).toMatchObject({ status: "clean", content: "external" });
});
test("coalesces consecutive clean disk updates onto the latest reload", async () => {
const { model, session } = makeModel();
const reads: Array<(file: FileEditorFile) => void> = [];
session.read = () => new Promise((resolve) => reads.push(resolve));
const firstVersion = ready("2026-07-18T00:00:02.000Z", 5);
const latestVersion = ready("2026-07-18T00:00:03.000Z", 6);
model.receiveFileVersion(firstVersion);
model.receiveFileVersion(latestVersion);
reads[0]?.({ content: "first", version: firstVersion });
await Promise.resolve();
reads[1]?.({ content: "latest", version: latestVersion });
await Promise.resolve();
expect(model.getSnapshot()).toMatchObject({ status: "clean", content: "latest" });
});
test("preserves a dirty buffer and overwrites against the newest disk revision", async () => {
const { model, session } = makeModel();
model.edit("local");
model.receiveFileVersion(ready("2026-07-18T00:00:02.000Z", 4));
expect(model.getSnapshot()).toMatchObject({ status: "conflict", content: "local" });
await model.overwrite();
expect(session.writes).toEqual([
{ content: "local", expectedModifiedAt: "2026-07-18T00:00:02.000Z" },
]);
expect(model.getSnapshot().status).toBe("clean");
});
test("reload discards a conflicted local buffer for the disk contents", async () => {
const { model, session } = makeModel();
model.edit("local");
const diskVersion = ready("2026-07-18T00:00:02.000Z", 4) as Extract<
FileVersion,
{ status: "ready" }
>;
session.file = { content: "disk", version: diskVersion };
model.receiveFileVersion(diskVersion);
await model.reload();
expect(model.getSnapshot()).toMatchObject({ status: "clean", content: "disk" });
});
test("reports failed saves without losing the local buffer", async () => {
const { model, session } = makeModel();
session.nextWrite = new Error("disk full");
model.edit("important local work");
await model.save();
expect(model.getSnapshot()).toMatchObject({
status: "error",
content: "important local work",
error: "disk full",
});
});
test("a deletion conflicts with local changes and stops autosave", () => {
const { model, session, clock } = makeModel();
model.edit("local");
model.receiveFileVersion({ status: "missing", cwd: "/workspace", path: "file.ts" });
clock.fire();
expect(model.getSnapshot().status).toBe("conflict");
expect(session.writes).toEqual([]);
});
test("dispose cancels pending autosave", () => {
const { model, session, clock } = makeModel();
model.edit("local");
model.dispose();
clock.fire();
expect(session.writes).toEqual([]);
});
test("suspends a pending autosave while close confirmation is active", async () => {
const { model, session, clock } = makeModel();
model.edit("local");
const resume = model.suspendAutosave();
clock.fire();
expect(session.writes).toEqual([]);
resume();
clock.fire();
await Promise.resolve();
expect(session.writes).toHaveLength(1);
});
});

View File

@@ -0,0 +1,302 @@
import type { FileVersion, FileWriteResult } from "@getpaseo/protocol/messages";
export type FileEditorStatus = "loading" | "clean" | "dirty" | "saving" | "conflict" | "error";
export interface FileEditorSnapshot {
status: FileEditorStatus;
content: string;
modified: boolean;
version: FileVersion;
observedVersion: FileVersion;
error: string | null;
}
export interface FileEditorFile {
content: string;
version: Extract<FileVersion, { status: "ready" }>;
}
export interface FileEditorSession {
read(): Promise<FileEditorFile>;
write(input: {
content: string;
expectedModifiedAt: string;
expectedRevision?: string;
}): Promise<FileWriteResult>;
}
export interface FileEditorClock {
setTimeout(callback: () => void, delayMs: number): ReturnType<typeof setTimeout>;
clearTimeout(handle: ReturnType<typeof setTimeout>): void;
}
const systemClock: FileEditorClock = {
setTimeout(callback, delay) {
return globalThis.setTimeout(callback, delay);
},
clearTimeout(handle) {
globalThis.clearTimeout(handle);
},
};
export class FileEditorModel {
private readonly session: FileEditorSession;
private readonly clock: FileEditorClock;
private readonly listeners = new Set<() => void>();
private snapshot: FileEditorSnapshot;
private autosave: ReturnType<typeof setTimeout> | null = null;
private saveSequence = 0;
private disposed = false;
private observedWhileSaving: FileVersion | null = null;
private persistedContent: string;
constructor(input: {
file: FileEditorFile;
session: FileEditorSession;
clock?: FileEditorClock;
}) {
this.session = input.session;
this.clock = input.clock ?? systemClock;
this.persistedContent = input.file.content;
this.snapshot = {
status: "clean",
content: input.file.content,
modified: false,
version: input.file.version,
observedVersion: input.file.version,
error: null,
};
}
subscribe = (listener: () => void): (() => void) => {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
};
getSnapshot = (): FileEditorSnapshot => this.snapshot;
edit(content: string): void {
if (this.disposed || content === this.snapshot.content) return;
const modified = content !== this.persistedContent;
let status: FileEditorStatus = modified ? "dirty" : "clean";
if (this.snapshot.status === "conflict" || this.snapshot.status === "loading") {
status = "conflict";
}
this.setSnapshot({ ...this.snapshot, status, content, modified, error: null });
if (status === "dirty") this.scheduleAutosave();
else this.clearAutosave();
}
async save(): Promise<void> {
if (this.disposed || (this.snapshot.status !== "dirty" && this.snapshot.status !== "error")) {
return;
}
if (this.snapshot.observedVersion.status !== "ready") {
this.enterConflict(this.snapshot.observedVersion);
return;
}
await this.performWrite(this.snapshot.observedVersion);
}
receiveFileVersion(version: FileVersion): void {
if (this.disposed) return;
if (sameVersion(version, this.snapshot.observedVersion)) {
if (
version.status === "ready" &&
this.snapshot.observedVersion.status === "ready" &&
version.revision &&
!this.snapshot.observedVersion.revision
) {
this.setSnapshot({
...this.snapshot,
version:
this.snapshot.version.status === "ready"
? { ...this.snapshot.version, revision: version.revision }
: this.snapshot.version,
observedVersion: version,
});
}
return;
}
this.setSnapshot({ ...this.snapshot, observedVersion: version });
if (this.snapshot.status === "saving") {
this.observedWhileSaving = version;
return;
}
if (this.snapshot.status === "clean" || this.snapshot.status === "loading") {
void this.reloadFromDisk(version);
return;
}
this.enterConflict(version);
}
async overwrite(): Promise<void> {
if (this.disposed || this.snapshot.status !== "conflict") return;
if (this.snapshot.observedVersion.status !== "ready") return;
await this.performWrite(this.snapshot.observedVersion);
}
async reload(): Promise<void> {
if (this.disposed) return;
await this.reloadFromDisk(this.snapshot.observedVersion);
}
dispose(): void {
this.disposed = true;
this.saveSequence += 1;
this.clearAutosave();
this.listeners.clear();
}
suspendAutosave(): () => void {
const wasScheduled = this.autosave !== null;
this.clearAutosave();
let resumed = false;
return () => {
if (resumed || this.disposed) return;
resumed = true;
if (wasScheduled && this.snapshot.status === "dirty") this.scheduleAutosave();
};
}
private async performWrite(
expectedVersion: Extract<FileVersion, { status: "ready" }>,
): Promise<void> {
this.clearAutosave();
const sequence = ++this.saveSequence;
const content = this.snapshot.content;
this.observedWhileSaving = null;
this.setSnapshot({ ...this.snapshot, status: "saving", error: null });
let result: FileWriteResult;
try {
result = await this.session.write({
content,
expectedModifiedAt: expectedVersion.modifiedAt,
expectedRevision: expectedVersion.revision,
});
} catch (error) {
if (this.disposed || sequence !== this.saveSequence) return;
this.setSnapshot({
...this.snapshot,
status: "error",
error: error instanceof Error ? error.message : String(error),
});
return;
}
if (this.disposed || sequence !== this.saveSequence) return;
if (result.status === "error") {
this.setSnapshot({ ...this.snapshot, status: "error", error: result.error });
return;
}
if (result.status === "conflict") {
this.enterConflict(result.version);
return;
}
const writtenVersion: FileVersion = {
status: "ready",
cwd: this.snapshot.version.cwd,
path: this.snapshot.version.path,
size: result.size,
modifiedAt: result.modifiedAt,
revision: result.revision,
};
const pending = this.observedWhileSaving;
this.observedWhileSaving = null;
this.persistedContent = content;
if (pending && !sameVersion(pending, writtenVersion)) {
this.setSnapshot({
...this.snapshot,
status: "conflict",
modified: this.snapshot.content !== this.persistedContent,
version: writtenVersion,
observedVersion: pending,
error: null,
});
return;
}
const modified = this.snapshot.content !== this.persistedContent;
this.setSnapshot({
...this.snapshot,
status: modified ? "dirty" : "clean",
modified,
version: writtenVersion,
observedVersion: writtenVersion,
error: null,
});
if (modified) this.scheduleAutosave();
}
private async reloadFromDisk(version: FileVersion): Promise<void> {
this.clearAutosave();
if (version.status !== "ready") {
this.enterConflict(version);
return;
}
const sequence = ++this.saveSequence;
this.setSnapshot({ ...this.snapshot, status: "loading", error: null });
try {
const file = await this.session.read();
if (this.disposed || sequence !== this.saveSequence || this.snapshot.status !== "loading") {
return;
}
this.persistedContent = file.content;
this.setSnapshot({
status: "clean",
content: file.content,
modified: false,
version: file.version,
observedVersion: file.version,
error: null,
});
} catch (error) {
if (this.disposed || sequence !== this.saveSequence) return;
this.setSnapshot({
...this.snapshot,
status: "error",
error: error instanceof Error ? error.message : String(error),
});
}
}
private enterConflict(version: FileVersion): void {
this.clearAutosave();
this.setSnapshot({
...this.snapshot,
status: "conflict",
modified: this.snapshot.content !== this.persistedContent,
observedVersion: version,
error: version.status === "error" ? version.error : null,
});
}
private scheduleAutosave(): void {
this.clearAutosave();
this.autosave = this.clock.setTimeout(() => {
this.autosave = null;
void this.save();
}, 800);
}
private clearAutosave(): void {
if (!this.autosave) return;
this.clock.clearTimeout(this.autosave);
this.autosave = null;
}
private setSnapshot(snapshot: FileEditorSnapshot): void {
this.snapshot = snapshot;
for (const listener of this.listeners) listener();
}
}
function sameVersion(left: FileVersion, right: FileVersion): boolean {
if (left.status !== right.status || left.cwd !== right.cwd || left.path !== right.path)
return false;
if (left.status === "ready" && right.status === "ready") {
if (left.revision && right.revision) return left.revision === right.revision;
return left.modifiedAt === right.modifiedAt && left.size === right.size;
}
if (left.status === "error" && right.status === "error") return left.error === right.error;
return true;
}

View File

@@ -0,0 +1,33 @@
import { Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import type { HighlightStyle } from "@getpaseo/highlight";
import type { FileEditorModel } from "./model";
export function FileEditorView(_props: {
model: FileEditorModel;
filename: string;
vimEnabled: boolean;
theme: {
background: string;
foreground: string;
foregroundMuted: string;
border: string;
selection: string;
monoFont: string;
codeFontSize: number;
syntax: Record<HighlightStyle, string>;
};
onCursorChange(position: { line: number; column: number }): void;
onVimModeChange(mode: string | null): void;
}) {
return (
<View style={styles.container}>
<Text style={styles.text}>Source editing is available on web and desktop.</Text>
</View>
);
}
const styles = StyleSheet.create((theme) => ({
container: { flex: 1, alignItems: "center", justifyContent: "center" },
text: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm },
}));

View File

@@ -0,0 +1,124 @@
import { useEffect, useRef, useSyncExternalStore } from "react";
import { Annotation, Compartment, EditorState, Transaction } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import { getLanguageForFile } from "@getpaseo/highlight";
import { getCM, vim } from "@replit/codemirror-vim";
import type { FileEditorModel } from "./model";
import { editorBaseExtensions, editorTheme, type EditorVisualTheme } from "./extensions.web";
interface FileEditorViewProps {
model: FileEditorModel;
filename: string;
vimEnabled: boolean;
theme: EditorVisualTheme;
onCursorChange(position: { line: number; column: number }): void;
onVimModeChange(mode: string | null): void;
}
const languageCompartment = new Compartment();
const themeCompartment = new Compartment();
const vimCompartment = new Compartment();
export function FileEditorView({
model,
filename,
vimEnabled,
theme,
onCursorChange,
onVimModeChange,
}: FileEditorViewProps) {
const hostRef = useRef<HTMLDivElement>(null);
const viewRef = useRef<EditorView | null>(null);
const snapshot = useSyncExternalStore(model.subscribe, model.getSnapshot, model.getSnapshot);
const initial = useRef({ filename, model, theme, vimEnabled, content: snapshot.content });
const onCursorChangeRef = useRef(onCursorChange);
onCursorChangeRef.current = onCursorChange;
useEffect(() => {
if (!hostRef.current) return;
const values = initial.current;
const view = new EditorView({
parent: hostRef.current,
state: EditorState.create({
doc: values.content,
extensions: [
vimCompartment.of(values.vimEnabled ? vim() : []),
...editorBaseExtensions(() => void values.model.save()),
languageCompartment.of(getLanguageForFile(values.filename)?.extension ?? []),
themeCompartment.of(editorTheme(values.theme)),
EditorView.updateListener.of((update) => {
if (
update.docChanged &&
!update.transactions.some((tr) => tr.annotation(remoteUpdate))
) {
values.model.edit(update.state.doc.toString());
}
if (update.selectionSet || update.docChanged) {
const head = update.state.selection.main.head;
const line = update.state.doc.lineAt(head);
onCursorChangeRef.current({ line: line.number, column: head - line.from + 1 });
}
}),
],
}),
});
viewRef.current = view;
onCursorChangeRef.current({ line: 1, column: 1 });
return () => {
view.destroy();
viewRef.current = null;
};
}, []);
useEffect(() => {
const view = viewRef.current;
if (!view || view.state.doc.toString() === snapshot.content) return;
const head = Math.min(view.state.selection.main.head, snapshot.content.length);
view.dispatch({
changes: { from: 0, to: view.state.doc.length, insert: snapshot.content },
selection: { anchor: head },
annotations: [remoteUpdate.of(true), Transaction.addToHistory.of(false)],
});
}, [snapshot.content]);
useEffect(() => {
viewRef.current?.dispatch({
effects: languageCompartment.reconfigure(getLanguageForFile(filename)?.extension ?? []),
});
}, [filename]);
useEffect(() => {
viewRef.current?.dispatch({ effects: themeCompartment.reconfigure(editorTheme(theme)) });
}, [theme]);
useEffect(() => {
const view = viewRef.current;
if (!view) return;
view.dispatch({ effects: vimCompartment.reconfigure(vimEnabled ? vim() : []) });
if (!vimEnabled) {
onVimModeChange(null);
return;
}
const cm = getCM(view);
if (!cm) return;
function handleModeChange(event: { mode?: string }) {
onVimModeChange((event.mode ?? "normal").toUpperCase());
}
cm.on("vim-mode-change", handleModeChange);
onVimModeChange("NORMAL");
return () => cm.off("vim-mode-change", handleModeChange);
}, [onVimModeChange, vimEnabled]);
return (
<div
ref={hostRef}
data-pmono=""
data-testid="file-source-editor"
aria-label={`Source editor for ${filename}`}
style={HOST_STYLE}
/>
);
}
const remoteUpdate = Annotation.define<boolean>();
const HOST_STYLE = { flex: 1, minHeight: 0, overflow: "hidden" } as const;

View File

@@ -0,0 +1,93 @@
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 };
}

View File

@@ -1,6 +1,13 @@
import React, { useEffect, useMemo, useRef } from "react";
import { useQuery } from "@tanstack/react-query";
import type { FileReadResult } from "@getpaseo/client/internal/daemon-client";
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
useSyncExternalStore,
} from "react";
import type { DaemonClient, FileReadResult } from "@getpaseo/client/internal/daemon-client";
import type { FileVersion } from "@getpaseo/protocol/messages";
import {
ActivityIndicator,
Image as RNImage,
@@ -8,7 +15,7 @@ import {
Text,
View,
} from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { StyleSheet, UnistylesRuntime } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import { MarkdownRenderer } from "@/components/markdown/renderer";
import { useIsCompactFormFactor } from "@/constants/layout";
@@ -29,6 +36,14 @@ import type { WorkspaceFileLocation } from "@/workspace/file-open";
import { useRetainedPanelActive } from "@/components/retained-panel";
import { useAppActivelyVisible } from "@/hooks/use-app-visible";
import { isFileQueryEnabled } from "@/components/file-pane-enabled";
import { isWeb } from "@/constants/platform";
import { useAppSettings } from "@/hooks/use-settings";
import { useLiveFile } from "./live-file";
import { FilePanelBar } from "./bar";
import { FileEditorModel, type FileEditorFile } from "./editor/model";
import { FileEditorView } from "./editor/view";
import { confirmDialog } from "@/utils/confirm-dialog";
import { usePublishPanelInstanceAttributes } from "@/panels/panel-instance-attributes";
interface CodeLineProps {
tokens: HighlightToken[];
@@ -45,6 +60,8 @@ interface FilePreviewBodyProps {
imagePreviewUri: string | null;
}
type TextExplorerFile = ExplorerFile & { kind: "text" };
function trimNonEmpty(value: string | null | undefined): string | null {
if (typeof value !== "string") {
return null;
@@ -192,7 +209,7 @@ function FilePreviewBody({
location,
imagePreviewUri,
}: FilePreviewBodyProps) {
const { theme } = useUnistyles();
const theme = UnistylesRuntime.getTheme();
const { t } = useTranslation();
const filePath = location.path;
const isMarkdownFile =
@@ -370,8 +387,18 @@ export function FilePane({
}) {
const { t } = useTranslation();
const isMobile = useIsCompactFormFactor();
const [markdownMode, setMarkdownMode] = useState<"preview" | "source">("preview");
const [resolvedPreview, setResolvedPreview] = useState<{
key: string | null;
file: ExplorerFile | null;
imageAttachment: AttachmentMetadata | null;
}>({ key: null, file: null, imageAttachment: null });
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
// COMPAT(workspaceFileEditing): added in v0.2.0, remove after 2027-01-18 once daemon floor >= v0.2.0.
const supportsEditing = useSessionStore(
(state) => state.sessions[serverId]?.serverInfo?.features?.workspaceFileEditing === true,
);
const normalizedWorkspaceRoot = useMemo(() => workspaceRoot.trim(), [workspaceRoot]);
const normalizedFilePath = useMemo(() => trimNonEmpty(location.path), [location.path]);
const readTarget = useMemo(
@@ -390,53 +417,174 @@ export function FilePane({
// from another window after an external edit. The gate lives in isFileQueryEnabled.
const isActive = useRetainedPanelActive();
const isAppVisible = useAppActivelyVisible();
const query = useQuery({
queryKey: ["workspaceFile", serverId, readTarget?.cwd ?? null, readTarget?.path ?? null],
enabled: isFileQueryEnabled({
hasReadTarget: Boolean(client && readTarget),
isTabActive: isActive,
isAppVisible,
}),
queryFn: async () => {
if (!client || !readTarget) {
return {
file: null as ExplorerFile | null,
error: t("workspace.terminal.hostDisconnected"),
};
}
try {
const file = await client.readFile(readTarget.cwd, readTarget.path);
const preview = await createFilePanePreview(file);
return {
file: preview.file,
imageAttachment: preview.imageAttachment,
error: null,
};
} catch (error) {
return {
file: null,
imageAttachment: null,
error: error instanceof Error ? error.message : t("panels.file.failedToLoad"),
};
}
},
staleTime: 5_000,
refetchOnMount: true,
const enabled = isFileQueryEnabled({
hasReadTarget: Boolean(client && readTarget),
isTabActive: isActive,
isAppVisible,
});
const imagePreviewUri = useAttachmentPreviewUrl(query.data?.imageAttachment ?? null);
const { query, version } = useLiveFile({
client,
serverId,
cwd: readTarget?.cwd ?? null,
path: readTarget?.path ?? null,
enabled,
liveUpdates: supportsEditing,
});
useEffect(() => {
let active = true;
const key = readTarget ? `${readTarget.cwd}:${readTarget.path}` : null;
void (async () => {
const nextPreview = await createFilePanePreview(query.data ?? null);
if (active) setResolvedPreview({ key, ...nextPreview });
})();
return () => {
active = false;
};
}, [query.data, readTarget]);
useEffect(() => setMarkdownMode("preview"), [readTarget?.path]);
const previewKey = readTarget ? `${readTarget.cwd}:${readTarget.path}` : null;
const preview = resolvedPreview.key === previewKey ? resolvedPreview.file : null;
const imagePreviewUri = useAttachmentPreviewUrl(
resolvedPreview.key === previewKey ? resolvedPreview.imageAttachment : null,
);
const isMarkdown = isMarkdownPreview(preview, location.path);
const editable = isEditableTextFile({
preview,
supportsEditing,
});
const lineCount =
preview?.kind === "text" ? (preview.content ?? "").split("\n").length : undefined;
const errorMessage = getFileErrorMessage(query.error, t("panels.file.failedToLoad"));
return (
<FilePanePresentation
serverId={serverId}
client={client}
readTarget={readTarget}
preview={preview}
version={version}
filename={getFileNameFromPath(location.path) ?? location.path}
markdownMode={isMarkdown ? markdownMode : undefined}
onMarkdownModeChange={isMarkdown ? setMarkdownMode : undefined}
lineCount={lineCount}
editable={editable}
disconnectedMessage={t("workspace.terminal.hostDisconnected")}
errorMessage={errorMessage}
isLoading={query.isFetching}
isMobile={isMobile}
location={location}
imagePreviewUri={imagePreviewUri}
/>
);
}
function isMarkdownPreview(preview: ExplorerFile | null, path: string): boolean {
return preview?.kind === "text" && isRenderedMarkdownFile(path);
}
function getFileErrorMessage(error: unknown, fallback: string): string | null {
if (!error) return null;
return error instanceof Error ? error.message : fallback;
}
function isEditableTextFile(input: {
preview: ExplorerFile | null;
supportsEditing: boolean;
}): boolean {
return Boolean(
isWeb &&
input.supportsEditing &&
input.preview?.kind === "text" &&
input.preview.size <= 1024 * 1024,
);
}
function FilePanePresentation({
serverId,
client,
readTarget,
preview,
version,
filename,
markdownMode,
onMarkdownModeChange,
lineCount,
editable,
disconnectedMessage,
errorMessage,
isLoading,
isMobile,
location,
imagePreviewUri,
}: {
serverId: string;
client: DaemonClient | null;
readTarget: { cwd: string; path: string } | null;
preview: ExplorerFile | null;
version: FileVersion | null;
filename: string;
markdownMode?: "preview" | "source";
onMarkdownModeChange?: (mode: "preview" | "source") => void;
lineCount?: number;
editable: boolean;
disconnectedMessage: string;
errorMessage: string | null;
isLoading: boolean;
isMobile: boolean;
location: WorkspaceFileLocation;
imagePreviewUri: string | null;
}) {
if (!client && readTarget) {
return (
<View style={styles.container} testID="workspace-file-pane">
<View style={styles.centerState}>
<Text style={styles.errorText}>{disconnectedMessage}</Text>
</View>
</View>
);
}
if (editable && client && readTarget && preview?.kind === "text") {
return (
<EditableFilePane
key={`${serverId}:${readTarget.cwd}:${readTarget.path}`}
client={client}
cwd={readTarget.cwd}
path={readTarget.path}
preview={preview as TextExplorerFile}
version={version}
filename={filename}
mode={markdownMode}
onModeChange={onMarkdownModeChange}
isLoading={isLoading}
isMobile={isMobile}
location={location}
/>
);
}
return (
<View style={styles.container} testID="workspace-file-pane">
{query.data?.error ? (
{preview ? (
<FilePanelBar
size={preview.size}
lineCount={lineCount}
mode={markdownMode}
onModeChange={onMarkdownModeChange}
/>
) : null}
{errorMessage ? (
<View style={styles.centerState}>
<Text style={styles.errorText}>{query.data.error}</Text>
<Text style={styles.errorText}>{errorMessage}</Text>
</View>
) : null}
<FilePreviewBody
preview={query.data?.file ?? null}
isLoading={query.isFetching}
preview={preview}
isLoading={isLoading}
isMobile={isMobile}
location={location}
imagePreviewUri={imagePreviewUri}
@@ -445,6 +593,173 @@ export function FilePane({
);
}
function EditableFilePane({
client,
cwd,
path,
preview,
version,
filename,
mode,
onModeChange,
isLoading,
isMobile,
location,
}: {
client: DaemonClient;
cwd: string;
path: string;
preview: TextExplorerFile;
version: FileVersion | null;
filename: string;
mode?: "preview" | "source";
onModeChange?: (mode: "preview" | "source") => void;
isLoading: boolean;
isMobile: boolean;
location: WorkspaceFileLocation;
}) {
const { settings } = useAppSettings();
const { t } = useTranslation();
const [cursor, setCursor] = useState({ line: 1, column: 1 });
const [vimMode, setVimMode] = useState<string | null>(settings.vimKeybindings ? "NORMAL" : null);
const session = useMemo(
() => ({
async read(): Promise<FileEditorFile> {
const file = await client.readFile(cwd, path);
if (file.kind !== "text") throw new Error("File is no longer text.");
return {
content: new TextDecoder().decode(file.bytes),
version: {
status: "ready",
cwd,
path,
size: file.size,
modifiedAt: file.modifiedAt,
revision: file.revision,
},
};
},
write(input: { content: string; expectedModifiedAt: string; expectedRevision?: string }) {
return client.writeFile({ cwd, path, ...input });
},
}),
[client, cwd, path],
);
const [model] = useState(
() =>
new FileEditorModel({
file: {
content: preview.content ?? "",
version: {
status: "ready",
cwd,
path,
size: preview.size,
modifiedAt: preview.modifiedAt,
},
},
session,
}),
);
const snapshot = useSyncExternalStore(model.subscribe, model.getSnapshot, model.getSnapshot);
const suspendPendingSave = useCallback(() => model.suspendAutosave(), [model]);
usePublishPanelInstanceAttributes({ modified: snapshot.modified, suspendPendingSave });
const theme = UnistylesRuntime.getTheme();
const visualTheme = useMemo(
() => ({
colorScheme: theme.colorScheme,
background: theme.colors.surface0,
foreground: theme.colors.foreground,
cursor: theme.colors.terminal.cursor,
foregroundMuted: theme.colors.foregroundMuted,
border: theme.colors.border,
selection: theme.colors.terminal.selectionBackground,
monoFont: theme.fontFamily.mono,
codeFontSize: theme.fontSize.code,
syntax: theme.colors.syntax,
}),
[
theme.colors.border,
theme.colors.foreground,
theme.colors.foregroundMuted,
theme.colors.surface0,
theme.colors.syntax,
theme.colors.terminal.cursor,
theme.colors.terminal.selectionBackground,
theme.colorScheme,
theme.fontFamily.mono,
theme.fontSize.code,
],
);
useEffect(() => () => model.dispose(), [model]);
useEffect(() => {
if (version) model.receiveFileVersion(version);
}, [model, version]);
const handleReload = useCallback(() => {
void (async () => {
const confirmed = await confirmDialog({
title: t("panels.file.editor.reloadTitle"),
message: t("panels.file.editor.reloadMessage"),
confirmLabel: t("panels.file.editor.reload"),
destructive: true,
});
if (confirmed) void model.reload();
})();
}, [model, t]);
const handleOverwrite = useCallback(() => void model.overwrite(), [model]);
const handleVimModeChange = useCallback((nextMode: string | null) => setVimMode(nextMode), []);
const renderedPreview = useMemo<ExplorerFile>(
() => ({
...preview,
content: snapshot.content,
size: snapshot.version.status === "ready" ? snapshot.version.size : preview.size,
modifiedAt:
snapshot.version.status === "ready" ? snapshot.version.modifiedAt : preview.modifiedAt,
}),
[preview, snapshot.content, snapshot.version],
);
const showSource = mode !== "preview";
return (
<View style={styles.container} testID="workspace-file-pane">
<FilePanelBar
size={
snapshot.observedVersion.status === "ready" ? snapshot.observedVersion.size : preview.size
}
lineCount={snapshot.content.split("\n").length}
editorStatus={snapshot.status}
cursor={showSource ? cursor : undefined}
vimMode={showSource ? vimMode : null}
conflictUnavailable={snapshot.observedVersion.status !== "ready"}
onOverwrite={handleOverwrite}
onReload={handleReload}
mode={mode}
onModeChange={onModeChange}
/>
{showSource ? (
<FileEditorView
model={model}
filename={filename}
vimEnabled={settings.vimKeybindings}
theme={visualTheme}
onCursorChange={setCursor}
onVimModeChange={handleVimModeChange}
/>
) : (
<FilePreviewBody
preview={renderedPreview}
isLoading={isLoading}
isMobile={isMobile}
location={location}
imagePreviewUri={null}
/>
)}
</View>
);
}
const styles = StyleSheet.create((theme) => ({
container: {
flex: 1,

View File

@@ -191,6 +191,9 @@ export function useSettings<TSelected>(
if (updates.toolCallDetailLevel !== undefined) {
appUpdates.toolCallDetailLevel = updates.toolCallDetailLevel;
}
if (updates.vimKeybindings !== undefined) {
appUpdates.vimKeybindings = updates.vimKeybindings;
}
const promises: Promise<void>[] = [];
if (Object.keys(appUpdates).length > 0) {
promises.push(appSettings.updateSettings(appUpdates));

View File

@@ -43,6 +43,7 @@ export interface AppSettings {
workspaceTitleSource: WorkspaceTitleSource;
autoExpandReasoning: boolean;
toolCallDetailLevel: ToolCallDetailLevel;
vimKeybindings: boolean;
}
export interface Settings extends AppSettings {
@@ -66,6 +67,7 @@ export const DEFAULT_CLIENT_SETTINGS: AppSettings = {
workspaceTitleSource: "title",
autoExpandReasoning: false,
toolCallDetailLevel: "detailed",
vimKeybindings: false,
};
export const DEFAULT_APP_SETTINGS: Settings = {
@@ -233,6 +235,9 @@ function pickAppSettings(stored: StoredAppSettings): Partial<AppSettings> {
if (typeof stored.syntaxTheme === "string" && isSyntaxThemeId(stored.syntaxTheme)) {
result.syntaxTheme = stored.syntaxTheme;
}
if (typeof stored.vimKeybindings === "boolean") {
result.vimKeybindings = stored.vimKeybindings;
}
if (
typeof stored.workspaceTitleSource === "string" &&
VALID_WORKSPACE_TITLE_SOURCES.has(stored.workspaceTitleSource)

View File

@@ -466,6 +466,7 @@ export const ar: TranslationResources = {
},
tabs: {
loading: "تحميل...",
modified: "تغييرات غير محفوظة",
loadingAgentTitle: "جارٍ تحميل عنوان الوكيل",
emptyPane: "لا توجد علامات تبويب في هذا الجزء.",
fallback: {
@@ -532,6 +533,13 @@ export const ar: TranslationResources = {
failedToReloadAgent: "فشل في إعادة تحميل الوكيل",
},
confirmations: {
unsavedTitle: "تغييرات غير محفوظة",
unsavedMessage:
"تحتوي علامة التبويب هذه على تغييرات غير محفوظة. سيؤدي إغلاقها إلى تجاهل المسودة.",
closeWithoutSaving: "إغلاق بدون حفظ",
closePaneTitle: "إغلاق اللوحة؟",
bulkUnsaved:
"تحتوي {{count}} علامة تبويب على تغييرات غير محفوظة. سيؤدي الإغلاق إلى تجاهل المسودات.",
close: "يغلق",
cancel: "يلغي",
archive: "أرشيف",
@@ -1445,6 +1453,25 @@ export const ar: TranslationResources = {
binaryPreviewUnavailable: "المعاينة الثنائية غير متاحة",
failedToLoad: "فشل تحميل الملف",
failedToLoadPreview: "فشل تحميل معاينة الملف",
editor: {
fileSize: "حجم الملف {{size}}",
lines: "{{count}} سطر",
editorStatus: "حالة المحرر {{status}}",
unsavedChanges: "تغييرات غير محفوظة",
saving: "جارٍ الحفظ...",
saveFailed: "فشل الحفظ",
changedOnDisk: "تغيّر على القرص",
vimMode: "وضع Vim {{mode}}",
cursor: "السطر {{line}}، العمود {{column}}",
preview: "معاينة",
source: "المصدر",
unavailableTitle: "الملف غير متاح على القرص",
conflictDescription: "تم الاحتفاظ بالمحتوى المحلي. اختر الإصدار الذي تريد إبقاءه.",
overwrite: "استبدال",
reload: "إعادة تحميل",
reloadTitle: "إعادة التحميل من القرص؟",
reloadMessage: "ستفقد تغييراتك المحلية.",
},
},
diff: {
changesLabel: "التغييرات",
@@ -1540,12 +1567,18 @@ export const ar: TranslationResources = {
sections: {
general: "عام",
appearance: "مظهر",
editor: "المحرر",
shortcuts: "الاختصارات",
integrations: "التكامل",
permissions: "الأذونات",
diagnostics: "التشخيص",
about: "عن",
},
editor: {
title: "المحرر",
vimKeybindings: "اختصارات Vim",
vimHint: "تنطبق على ملفات المصدر في الويب وسطح المكتب.",
},
hostSections: {
connections: "اتصالات",
agents: "Agents",

View File

@@ -465,6 +465,7 @@ export const en = {
},
tabs: {
loading: "Loading...",
modified: "Unsaved changes",
loadingAgentTitle: "Loading agent title",
emptyPane: "No tabs in this pane.",
fallback: {
@@ -534,6 +535,12 @@ export const en = {
close: "Close",
cancel: "Cancel",
archive: "Archive",
unsavedTitle: "Unsaved changes",
unsavedMessage:
"This tab has changes that have not been saved. Closing it will discard the draft.",
closeWithoutSaving: "Close without saving",
closePaneTitle: "Close pane?",
bulkUnsaved: "{{count}} tab(s) have unsaved changes. Closing will discard those drafts.",
closeTerminalTitle: "Close terminal?",
closeTerminalMessage: "Any running process in this terminal will be stopped immediately.",
archiveRunningAgentTitle: "Archive running agent?",
@@ -1457,6 +1464,25 @@ export const en = {
binaryPreviewUnavailable: "Binary preview unavailable",
failedToLoad: "Failed to load file",
failedToLoadPreview: "Failed to load file preview",
editor: {
fileSize: "File size {{size}}",
lines: "{{count}} lines",
editorStatus: "Editor status {{status}}",
unsavedChanges: "Unsaved changes",
saving: "Saving...",
saveFailed: "Save failed",
changedOnDisk: "Changed on disk",
vimMode: "Vim mode {{mode}}",
cursor: "Line {{line}}, column {{column}}",
preview: "Preview",
source: "Source",
unavailableTitle: "File unavailable on disk",
conflictDescription: "The local buffer was preserved. Choose which version to keep.",
overwrite: "Overwrite",
reload: "Reload",
reloadTitle: "Reload from disk?",
reloadMessage: "Your local changes will be lost.",
},
},
diff: {
changesLabel: "Changes",
@@ -1552,12 +1578,18 @@ export const en = {
sections: {
general: "General",
appearance: "Appearance",
editor: "Editor",
shortcuts: "Shortcuts",
integrations: "Integrations",
permissions: "Permissions",
diagnostics: "Diagnostics",
about: "About",
},
editor: {
title: "Editor",
vimKeybindings: "Vim keybindings",
vimHint: "Applies to source files on web and desktop.",
},
hostSections: {
connections: "Connections",
agents: "Agents",

View File

@@ -470,6 +470,7 @@ export const es: TranslationResources = {
},
tabs: {
loading: "Cargando...",
modified: "Cambios sin guardar",
loadingAgentTitle: "Título del agente de carga",
emptyPane: "No hay pestañas en este panel.",
fallback: {
@@ -537,6 +538,13 @@ export const es: TranslationResources = {
failedToReloadAgent: "No se pudo recargar el agente",
},
confirmations: {
unsavedTitle: "Cambios sin guardar",
unsavedMessage:
"Esta pestaña tiene cambios sin guardar. Al cerrarla se descartará el borrador.",
closeWithoutSaving: "Cerrar sin guardar",
closePaneTitle: "¿Cerrar panel?",
bulkUnsaved:
"{{count}} pestaña(s) tienen cambios sin guardar. Al cerrar se descartarán esos borradores.",
close: "Cerca",
cancel: "Cancelar",
archive: "Archivo",
@@ -1488,6 +1496,25 @@ export const es: TranslationResources = {
binaryPreviewUnavailable: "Vista previa binaria no disponible",
failedToLoad: "No se pudo cargar el archivo",
failedToLoadPreview: "No se pudo cargar la vista previa del archivo",
editor: {
fileSize: "Tamaño {{size}}",
lines: "{{count}} líneas",
editorStatus: "Estado del editor: {{status}}",
unsavedChanges: "Cambios sin guardar",
saving: "Guardando...",
saveFailed: "Error al guardar",
changedOnDisk: "Cambiado en disco",
vimMode: "Modo Vim {{mode}}",
cursor: "Línea {{line}}, columna {{column}}",
preview: "Vista previa",
source: "Código fuente",
unavailableTitle: "Archivo no disponible en disco",
conflictDescription: "Se conservó el búfer local. Elige qué versión mantener.",
overwrite: "Sobrescribir",
reload: "Recargar",
reloadTitle: "¿Recargar desde el disco?",
reloadMessage: "Se perderán tus cambios locales.",
},
},
diff: {
changesLabel: "Cambios",
@@ -1583,12 +1610,18 @@ export const es: TranslationResources = {
sections: {
general: "General",
appearance: "Apariencia",
editor: "Editor",
shortcuts: "Atajos",
integrations: "Integraciones",
permissions: "Permisos",
diagnostics: "Diagnóstico",
about: "Acerca de",
},
editor: {
title: "Editor",
vimKeybindings: "Atajos de Vim",
vimHint: "Se aplica a archivos fuente en web y escritorio.",
},
hostSections: {
connections: "Conexiones",
agents: "Agents",

View File

@@ -470,6 +470,7 @@ export const fr: TranslationResources = {
},
tabs: {
loading: "Chargement...",
modified: "Modifications non enregistrées",
loadingAgentTitle: "Titre d'agent de chargement",
emptyPane: "Aucun onglet dans ce volet.",
fallback: {
@@ -537,6 +538,13 @@ export const fr: TranslationResources = {
failedToReloadAgent: "Échec du rechargement de l'agent",
},
confirmations: {
unsavedTitle: "Modifications non enregistrées",
unsavedMessage:
"Cet onglet contient des modifications non enregistrées. Le fermer supprimera le brouillon.",
closeWithoutSaving: "Fermer sans enregistrer",
closePaneTitle: "Fermer le volet?",
bulkUnsaved:
"{{count}} onglet(s) contiennent des modifications non enregistrées. Les fermer supprimera ces brouillons.",
close: "Fermer",
cancel: "Annuler",
archive: "Archive",
@@ -1491,6 +1499,25 @@ export const fr: TranslationResources = {
binaryPreviewUnavailable: "Aperçu binaire indisponible",
failedToLoad: "Échec du chargement du fichier",
failedToLoadPreview: "Échec du chargement de l'aperçu du fichier",
editor: {
fileSize: "Taille {{size}}",
lines: "{{count}} lignes",
editorStatus: "État de léditeur : {{status}}",
unsavedChanges: "Modifications non enregistrées",
saving: "Enregistrement...",
saveFailed: "Échec de lenregistrement",
changedOnDisk: "Modifié sur le disque",
vimMode: "Mode Vim {{mode}}",
cursor: "Ligne {{line}}, colonne {{column}}",
preview: "Aperçu",
source: "Source",
unavailableTitle: "Fichier indisponible sur le disque",
conflictDescription: "Le contenu local a été conservé. Choisissez la version à garder.",
overwrite: "Écraser",
reload: "Recharger",
reloadTitle: "Recharger depuis le disque ?",
reloadMessage: "Vos modifications locales seront perdues.",
},
},
diff: {
changesLabel: "Modifications",
@@ -1586,12 +1613,18 @@ export const fr: TranslationResources = {
sections: {
general: "Général",
appearance: "Apparence",
editor: "Éditeur",
shortcuts: "Raccourcis",
integrations: "Intégrations",
permissions: "Autorisations",
diagnostics: "Diagnostic",
about: "À propos",
},
editor: {
title: "Éditeur",
vimKeybindings: "Raccourcis Vim",
vimHint: "Sapplique aux fichiers source sur le web et le bureau.",
},
hostSections: {
connections: "Relations",
agents: "Agents",

View File

@@ -470,6 +470,7 @@ export const ja: TranslationResources = {
},
tabs: {
loading: "読み込み中...",
modified: "未保存の変更",
loadingAgentTitle: "エージェントタイトルを読み込み中",
emptyPane: "このペインにタブがありません。",
fallback: {
@@ -537,6 +538,11 @@ export const ja: TranslationResources = {
failedToReloadAgent: "エージェントの再読み込みに失敗しました",
},
confirmations: {
unsavedTitle: "未保存の変更",
unsavedMessage: "このタブには未保存の変更があります。閉じると下書きが破棄されます。",
closeWithoutSaving: "保存せずに閉じる",
closePaneTitle: "ペインを閉じますか?",
bulkUnsaved: "{{count}} 個のタブに未保存の変更があります。閉じると下書きが破棄されます。",
close: "閉じる",
cancel: "キャンセル",
archive: "アーカイブ",
@@ -1463,6 +1469,25 @@ export const ja: TranslationResources = {
binaryPreviewUnavailable: "バイナリプレビューが利用できません",
failedToLoad: "ファイルの読み込みに失敗しました",
failedToLoadPreview: "ファイルプレビューの読み込みに失敗しました",
editor: {
fileSize: "ファイルサイズ {{size}}",
lines: "{{count}} 行",
editorStatus: "エディターの状態 {{status}}",
unsavedChanges: "未保存の変更",
saving: "保存中...",
saveFailed: "保存に失敗しました",
changedOnDisk: "ディスク上で変更されました",
vimMode: "Vim モード {{mode}}",
cursor: "{{line}} 行、{{column}} 列",
preview: "プレビュー",
source: "ソース",
unavailableTitle: "ディスク上のファイルを利用できません",
conflictDescription: "ローカルの内容は保持されています。残すバージョンを選択してください。",
overwrite: "上書き",
reload: "再読み込み",
reloadTitle: "ディスクから再読み込みしますか?",
reloadMessage: "ローカルの変更は失われます。",
},
},
diff: {
changesLabel: "変更",
@@ -1558,12 +1583,18 @@ export const ja: TranslationResources = {
sections: {
general: "一般",
appearance: "外観",
editor: "エディター",
shortcuts: "ショートカット",
integrations: "連携",
permissions: "権限",
diagnostics: "診断",
about: "アプリ情報",
},
editor: {
title: "エディター",
vimKeybindings: "Vim キーバインド",
vimHint: "Web とデスクトップのソースファイルに適用されます。",
},
hostSections: {
connections: "接続",
agents: "エージェント",

View File

@@ -470,6 +470,7 @@ export const ptBR: TranslationResources = {
},
tabs: {
loading: "Carregando...",
modified: "Alterações não salvas",
loadingAgentTitle: "Carregando título do agente",
emptyPane: "Nenhuma aba neste painel.",
fallback: {
@@ -536,6 +537,12 @@ export const ptBR: TranslationResources = {
failedToReloadAgent: "Falha ao recarregar agente",
},
confirmations: {
unsavedTitle: "Alterações não salvas",
unsavedMessage: "Esta aba tem alterações não salvas. Fechá-la descartará o rascunho.",
closeWithoutSaving: "Fechar sem salvar",
closePaneTitle: "Fechar painel?",
bulkUnsaved:
"{{count}} aba(s) têm alterações não salvas. Fechar descartará esses rascunhos.",
close: "Fechar",
cancel: "Cancelar",
archive: "Arquivar",
@@ -1475,6 +1482,25 @@ export const ptBR: TranslationResources = {
binaryPreviewUnavailable: "Prévia binária indisponível",
failedToLoad: "Falha ao carregar arquivo",
failedToLoadPreview: "Falha ao carregar prévia do arquivo",
editor: {
fileSize: "Tamanho {{size}}",
lines: "{{count}} linhas",
editorStatus: "Status do editor: {{status}}",
unsavedChanges: "Alterações não salvas",
saving: "Salvando...",
saveFailed: "Falha ao salvar",
changedOnDisk: "Alterado no disco",
vimMode: "Modo Vim {{mode}}",
cursor: "Linha {{line}}, coluna {{column}}",
preview: "Prévia",
source: "Fonte",
unavailableTitle: "Arquivo indisponível no disco",
conflictDescription: "O conteúdo local foi preservado. Escolha qual versão manter.",
overwrite: "Sobrescrever",
reload: "Recarregar",
reloadTitle: "Recarregar do disco?",
reloadMessage: "Suas alterações locais serão perdidas.",
},
},
diff: {
changesLabel: "Alterações",
@@ -1570,12 +1596,18 @@ export const ptBR: TranslationResources = {
sections: {
general: "Geral",
appearance: "Aparência",
editor: "Editor",
shortcuts: "Atalhos",
integrations: "Integrações",
permissions: "Permissões",
diagnostics: "Diagnósticos",
about: "Sobre",
},
editor: {
title: "Editor",
vimKeybindings: "Atalhos do Vim",
vimHint: "Aplica-se a arquivos-fonte na web e no desktop.",
},
hostSections: {
connections: "Conexões",
agents: "Agentes",

View File

@@ -470,6 +470,7 @@ export const ru: TranslationResources = {
},
tabs: {
loading: "Загрузка...",
modified: "Несохранённые изменения",
loadingAgentTitle: "Название агента загрузки",
emptyPane: "На этой панели нет вкладок.",
fallback: {
@@ -536,6 +537,13 @@ export const ru: TranslationResources = {
failedToReloadAgent: "Не удалось перезагрузить агент",
},
confirmations: {
unsavedTitle: "Несохранённые изменения",
unsavedMessage:
"В этой вкладке есть несохранённые изменения. При закрытии черновик будет удалён.",
closeWithoutSaving: "Закрыть без сохранения",
closePaneTitle: "Закрыть панель?",
bulkUnsaved:
"В {{count}} вкладках есть несохранённые изменения. При закрытии черновики будут удалены.",
close: "Закрывать",
cancel: "Отмена",
archive: "Архив",
@@ -1480,6 +1488,25 @@ export const ru: TranslationResources = {
binaryPreviewUnavailable: "Предварительный просмотр двоичного файла недоступен.",
failedToLoad: "Не удалось загрузить файл",
failedToLoadPreview: "Не удалось загрузить предварительный просмотр файла.",
editor: {
fileSize: "Размер файла {{size}}",
lines: "Строк: {{count}}",
editorStatus: "Состояние редактора: {{status}}",
unsavedChanges: "Несохранённые изменения",
saving: "Сохранение...",
saveFailed: "Не удалось сохранить",
changedOnDisk: "Изменён на диске",
vimMode: "Режим Vim {{mode}}",
cursor: "Строка {{line}}, столбец {{column}}",
preview: "Просмотр",
source: "Исходник",
unavailableTitle: "Файл недоступен на диске",
conflictDescription: "Локальный буфер сохранён. Выберите версию, которую нужно оставить.",
overwrite: "Перезаписать",
reload: "Перезагрузить",
reloadTitle: "Перезагрузить с диска?",
reloadMessage: "Локальные изменения будут потеряны.",
},
},
diff: {
changesLabel: "Изменения",
@@ -1575,12 +1602,18 @@ export const ru: TranslationResources = {
sections: {
general: "Общий",
appearance: "Появление",
editor: "Редактор",
shortcuts: "Ярлыки",
integrations: "Интеграции",
permissions: "Разрешения",
diagnostics: "Диагностика",
about: "О",
},
editor: {
title: "Редактор",
vimKeybindings: "Клавиши Vim",
vimHint: "Применяется к исходным файлам в веб- и настольной версии.",
},
hostSections: {
connections: "Соединения",
agents: "Agents",

View File

@@ -466,6 +466,7 @@ export const zhCN: TranslationResources = {
},
tabs: {
loading: "正在加载...",
modified: "未保存的更改",
loadingAgentTitle: "正在加载 Agent 标题",
emptyPane: "此窗格中没有标签。",
fallback: {
@@ -532,6 +533,11 @@ export const zhCN: TranslationResources = {
failedToReloadAgent: "重新加载 Agent 失败",
},
confirmations: {
unsavedTitle: "未保存的更改",
unsavedMessage: "此标签页有尚未保存的更改。关闭将丢弃草稿。",
closeWithoutSaving: "不保存并关闭",
closePaneTitle: "关闭面板?",
bulkUnsaved: "{{count}} 个标签页有未保存的更改。关闭将丢弃这些草稿。",
close: "关闭",
cancel: "取消",
archive: "归档",
@@ -1428,6 +1434,25 @@ export const zhCN: TranslationResources = {
binaryPreviewUnavailable: "二进制预览不可用",
failedToLoad: "加载文件失败",
failedToLoadPreview: "加载文件预览失败",
editor: {
fileSize: "文件大小 {{size}}",
lines: "{{count}} 行",
editorStatus: "编辑器状态 {{status}}",
unsavedChanges: "未保存的更改",
saving: "正在保存...",
saveFailed: "保存失败",
changedOnDisk: "磁盘上的文件已更改",
vimMode: "Vim 模式 {{mode}}",
cursor: "第 {{line}} 行,第 {{column}} 列",
preview: "预览",
source: "源代码",
unavailableTitle: "磁盘上的文件不可用",
conflictDescription: "本地内容已保留。请选择要保留的版本。",
overwrite: "覆盖",
reload: "重新加载",
reloadTitle: "从磁盘重新加载?",
reloadMessage: "本地更改将丢失。",
},
},
diff: {
changesLabel: "更改",
@@ -1523,12 +1548,18 @@ export const zhCN: TranslationResources = {
sections: {
general: "通用",
appearance: "外观",
editor: "编辑器",
shortcuts: "快捷键",
integrations: "集成",
permissions: "权限",
diagnostics: "诊断",
about: "关于",
},
editor: {
title: "编辑器",
vimKeybindings: "Vim 键位",
vimHint: "适用于网页和桌面端的源文件。",
},
hostSections: {
connections: "连接",
agents: "Agents",

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

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,

View File

@@ -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,

View File

@@ -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,

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,
};
}

View File

@@ -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();
});
});

View File

@@ -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]);
}

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>();

View File

@@ -49,6 +49,7 @@ function useProviderSubagentDescriptor(
return {
label,
subtitle: `${formatProviderLabel(provider)} subagent`,
tooltip: label,
titleState: descriptor ? "ready" : "loading",
icon: getProviderIcon(provider),
statusBucket: descriptor

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,

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),

View File

@@ -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":

View File

@@ -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>
);
}

View File

@@ -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"]);
});
});

View File

@@ -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]);

View File

@@ -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,

View File

@@ -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(

View File

@@ -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",

View File

@@ -41,8 +41,11 @@ export interface SubagentsTrackProps {
const SUBAGENTS_LIST_MAX_HEIGHT = 200;
function buildRowPresentation(row: SubagentRow): WorkspaceTabPresentation {
const data = buildSubagentRowPresentationData(row);
return {
...buildSubagentRowPresentationData(row),
...data,
tooltip: data.label,
modified: false,
icon: getProviderIcon(row.provider),
};
}

View File

@@ -488,6 +488,7 @@ export function resolveKnownHostRoute(input: {
export const SETTINGS_SECTION_SLUGS = [
"general",
"appearance",
"editor",
"shortcuts",
"integrations",
"permissions",