-
SESSION LAUNCHER
-
- 选择一个 AI 会话,在当前 workspace 里继续查看文件、运行命令和推进代码修改。
-
-
- {(
- [
- {
- id: "claude",
- title: "Claude",
- meta: "analysis",
- icon:
,
- description: "更适合长上下文梳理、方案分析和代码审查。",
- className: "agent-provider-card-claude",
- },
- {
- id: "codex",
- title: "Codex",
- meta: "workspace",
- icon:
,
- description: "更适合终端操作、直接改文件和逐步修复问题。",
- className: "agent-provider-card-codex",
- },
- ] as const
- ).map((provider) => {
- const state = states[provider.id];
- const guide = getProviderGuide(provider.id);
- const isBusy =
- state.loading ||
- state.installJob?.status === "queued" ||
- state.installJob?.status === "running";
-
- return (
-
{provider.icon}}
- onClick={() => {
- void launch(provider.id);
- }}
- trailingIcon={ }
- variant="secondary"
- >
-
-
- {provider.title}
- {provider.meta}
-
- {provider.description}
- {getProviderCta(provider.id)}
- {isBusy ? (
-
- {t("provider.install.status.installing")}
-
- ) : null}
- {guide.message ? (
-
- {guide.message}
- {guide.docUrl ? (
- event.stopPropagation()}
- rel="noreferrer"
- target="_blank"
- >
- {t("provider.install.open_docs")}
-
- ) : null}
- event.stopPropagation()}
- >
- {t("diagnostics.actions.open_diagnostics")}
-
-
- ) : null}
+
+
+
+
+
+
+
+ {t("agent_panes.agent_panel")}
+
+
+
+ {(
+ [
+ {
+ id: "claude",
+ title: "Claude",
+ icon:
,
+ className: "agent-provider-card-claude",
+ },
+ {
+ id: "codex",
+ title: "Codex",
+ icon:
,
+ className: "agent-provider-card-codex",
+ },
+ ] as const
+ ).map((provider) => {
+ const state = states[provider.id];
+ const guide = getProviderGuide(provider.id);
+ const isBusy =
+ state.loading ||
+ state.installJob?.status === "queued" ||
+ state.installJob?.status === "running";
+
+ return (
+
{provider.icon}
+ }
+ onClick={() => {
+ void launch(provider.id);
+ }}
+ trailingIcon={
+
+ }
+ variant="secondary"
+ >
+
+
+ {provider.title}
+
+ {isBusy ? (
+
+ {t("provider.install.status.installing")}
+
+ ) : null}
+ {guide.message ? (
+
+ {guide.message}
+ {guide.docUrl ? (
+ event.stopPropagation()}
+ rel="noreferrer"
+ target="_blank"
+ >
+ {t("provider.install.open_docs")}
+
+ ) : null}
+ event.stopPropagation()}
+ >
+ {t("diagnostics.actions.open_diagnostics")}
+
+
+ ) : null}
+
+
+ );
+ })}
+
+
+
+
+
+
+
+
+
+
+
+
{t("agent_panes.file_editor")}
+
+
+
+
+
+ {t("agent_panes.drop_file_to_open")}
-
- );
- })}
+
+
+
+
+
+ {[
+ { id: "agent" as const, label: t("agent_panes.agent_panel") },
+ { id: "file" as const, label: t("agent_panes.file_editor") },
+ ].map((panel) => (
+ setActivePanel(panel.id)}
+ type="button"
+ />
+ ))}
+
+
+
{t("agent_panes.draft_footer")}
diff --git a/packages/web/src/features/agent-panes/views/shared/editor-pane-card.test.tsx b/packages/web/src/features/agent-panes/views/shared/editor-pane-card.test.tsx
new file mode 100644
index 00000000..30d3240d
--- /dev/null
+++ b/packages/web/src/features/agent-panes/views/shared/editor-pane-card.test.tsx
@@ -0,0 +1,179 @@
+import { fireEvent, render, screen } from "@testing-library/react";
+import { createStore, Provider } from "jotai";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { localeAtom } from "../../../../atoms/app-ui";
+import {
+ activeFilePathAtomFamily,
+ type OpenFile,
+ openFilesAtomFamily,
+} from "../../../workspace/atoms";
+import { EditorPaneCard } from "./editor-pane-card";
+
+const mocks = vi.hoisted(() => ({
+ editorState: {
+ marker: "editor-state",
+ currentFile: undefined as OpenFile | undefined,
+ },
+ mockUseCodeEditorActions: vi.fn(),
+ mockCodeEditorHost: vi.fn(() =>
Editor Host
),
+ mockCodeEditorDesktopHeaderActions: vi.fn(() => (
+
+ Editor Toolbar
+
+ )),
+}));
+const paneDragEnabledMock = vi.hoisted(() => ({
+ value: true,
+}));
+
+vi.mock("../../actions/use-pane-drag-enabled", () => ({
+ usePaneDragEnabled: () => paneDragEnabledMock.value,
+}));
+
+vi.mock("../../../code-editor/actions/use-code-editor-actions", () => ({
+ useCodeEditorActions: mocks.mockUseCodeEditorActions,
+}));
+
+vi.mock("../../../code-editor/views/shared/code-editor-host", () => ({
+ CodeEditorHost: mocks.mockCodeEditorHost,
+ CodeEditorDesktopHeaderActions: mocks.mockCodeEditorDesktopHeaderActions,
+}));
+
+describe("EditorPaneCard", () => {
+ afterEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("renders a drag handle in the header actions on desktop", () => {
+ const store = createStore();
+ const onClosePane = vi.fn();
+ const onSplitPane = vi.fn();
+ const onPaneDragStart = vi.fn();
+
+ mocks.mockUseCodeEditorActions.mockReturnValue(mocks.editorState);
+ store.set(localeAtom, "en");
+ store.set(activeFilePathAtomFamily("ws-123"), "src/app.tsx");
+
+ render(
+
+
+
+ );
+
+ const dragHandle = screen.getByRole("button", { name: "Drag pane" });
+
+ expect(dragHandle).toBeInTheDocument();
+
+ fireEvent.pointerDown(dragHandle);
+
+ expect(onPaneDragStart).toHaveBeenCalledWith(expect.objectContaining({ paneId: "pane-1" }));
+ });
+
+ it("renders editor pane actions and delegates split and close callbacks", () => {
+ const store = createStore();
+ const onClosePane = vi.fn();
+ const onSplitPane = vi.fn();
+
+ mocks.mockUseCodeEditorActions.mockReturnValue(mocks.editorState);
+ store.set(localeAtom, "en");
+ store.set(activeFilePathAtomFamily("ws-123"), "src/app.tsx");
+
+ render(
+
+
+
+ );
+
+ expect(screen.getByText("app.tsx")).toBeInTheDocument();
+ expect(screen.queryByText("src/app.tsx")).not.toBeInTheDocument();
+ expect(screen.getByTestId("editor-toolbar")).toBeInTheDocument();
+ expect(screen.getByTestId("editor-toolbar").closest(".panel-header")).toBeTruthy();
+ expect(
+ screen.queryByText("Editor Toolbar")?.closest(".editor-pane-card__toolbar-row")
+ ).toBeNull();
+ expect(screen.getByTestId("editor-host")).toBeInTheDocument();
+ expect(mocks.mockCodeEditorDesktopHeaderActions).toHaveBeenCalledWith(
+ expect.objectContaining({
+ state: mocks.editorState,
+ showCloseAction: false,
+ }),
+ undefined
+ );
+ expect(mocks.mockCodeEditorHost).toHaveBeenCalledWith(
+ expect.objectContaining({
+ chrome: "content-only",
+ editorState: mocks.editorState,
+ }),
+ undefined
+ );
+
+ fireEvent.click(screen.getByRole("button", { name: "Split horizontal" }));
+ fireEvent.click(screen.getByRole("button", { name: "Split vertical" }));
+ fireEvent.click(screen.getByRole("button", { name: "Close" }));
+
+ expect(onSplitPane).toHaveBeenNthCalledWith(1, "pane-1", "horizontal");
+ expect(onSplitPane).toHaveBeenNthCalledWith(2, "pane-1", "vertical");
+ expect(onClosePane).toHaveBeenCalledWith("pane-1");
+ });
+
+ it("marks dirty editor pane titles and confirms before closing dirty files", () => {
+ const store = createStore();
+ const onClosePane = vi.fn();
+ const onSplitPane = vi.fn();
+
+ mocks.mockUseCodeEditorActions.mockReturnValue(mocks.editorState);
+ store.set(localeAtom, "en");
+ store.set(activeFilePathAtomFamily("ws-123"), "src/app.tsx");
+ store.set(openFilesAtomFamily("ws-123"), {
+ "src/app.tsx": {
+ kind: "text",
+ path: "src/app.tsx",
+ content: "changed",
+ savedContent: "saved",
+ baseHash: "hash-1",
+ isDirty: true,
+ },
+ });
+
+ render(
+
+
+
+ );
+
+ const title = screen.getByText("app.tsx");
+ const titleElement = title.closest(".panel-header__title");
+ const dirtyMeta = titleElement?.nextElementSibling;
+
+ expect(dirtyMeta).toHaveClass("panel-header__meta");
+ expect(dirtyMeta?.querySelector(".editor-pane-card__dirty-indicator")).toBeTruthy();
+
+ fireEvent.click(screen.getByRole("button", { name: "Close" }));
+ expect(onClosePane).not.toHaveBeenCalled();
+ expect(screen.getByRole("dialog", { name: "Discard unsaved changes?" })).toBeInTheDocument();
+
+ fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
+ expect(onClosePane).not.toHaveBeenCalled();
+
+ fireEvent.click(screen.getByRole("button", { name: "Close" }));
+ fireEvent.click(screen.getByRole("button", { name: "Discard and Close" }));
+
+ expect(onClosePane).toHaveBeenCalledWith("pane-1");
+ });
+});
diff --git a/packages/web/src/features/agent-panes/views/shared/editor-pane-card.tsx b/packages/web/src/features/agent-panes/views/shared/editor-pane-card.tsx
new file mode 100644
index 00000000..4594fa93
--- /dev/null
+++ b/packages/web/src/features/agent-panes/views/shared/editor-pane-card.tsx
@@ -0,0 +1,174 @@
+import { useAtomValue } from "jotai";
+import { FlipHorizontal, FlipVertical, GripVertical, X } from "lucide-react";
+import type { FC } from "react";
+import { useState } from "react";
+import { ConfirmDialog, IconButton, Tooltip } from "../../../../components/ui";
+import { useTranslation } from "../../../../lib/i18n";
+import { useCodeEditorActions } from "../../../code-editor/actions/use-code-editor-actions";
+import {
+ CodeEditorDesktopHeaderActions,
+ CodeEditorHost,
+} from "../../../code-editor/views/shared/code-editor-host";
+import { PanelHeader } from "../../../shared/components/panel-header";
+import { activeFilePathAtomFamily, openFilesAtomFamily } from "../../../workspace/atoms";
+import type { PaneDropPlacement } from "../../actions/pane-drag-types";
+import type { PaneDragSourceSnapshot } from "../../actions/use-pane-drag-controller";
+import { usePaneDragEnabled } from "../../actions/use-pane-drag-enabled";
+
+function getEditorPaneTitle(path: string | null, fallbackTitle: string): string {
+ if (!path) {
+ return fallbackTitle;
+ }
+
+ const segments = path.split("/");
+ return segments[segments.length - 1] || path;
+}
+
+interface EditorPaneCardProps {
+ dragState?: {
+ isDragging: boolean;
+ isActiveDropTarget: boolean;
+ hoverPlacement: PaneDropPlacement | null;
+ };
+ paneId: string;
+ workspaceId: string;
+ onClosePane: (paneId: string) => void;
+ onPaneDragStart?: (source: PaneDragSourceSnapshot) => void;
+ onSplitPane: (paneId: string, direction: "horizontal" | "vertical") => void;
+}
+
+export const EditorPaneCard: FC
= ({
+ dragState,
+ paneId,
+ workspaceId,
+ onClosePane,
+ onPaneDragStart,
+ onSplitPane,
+}) => {
+ const t = useTranslation();
+ const [closeConfirmOpen, setCloseConfirmOpen] = useState(false);
+ const activeFilePath = useAtomValue(activeFilePathAtomFamily(workspaceId));
+ const openFiles = useAtomValue(openFilesAtomFamily(workspaceId));
+ const editorState = useCodeEditorActions();
+ const supportsPaneDrag = usePaneDragEnabled();
+ const canDragPane = supportsPaneDrag && Boolean(onPaneDragStart);
+ const title = getEditorPaneTitle(activeFilePath, t("agent_panes.file_editor"));
+ const activeOpenFile = activeFilePath ? openFiles[activeFilePath] : undefined;
+ const isDirtyTextFile = activeOpenFile?.kind === "text" && activeOpenFile.isDirty === true;
+ const dragOverlayPlacement = dragState?.isActiveDropTarget ? dragState.hoverPlacement : null;
+ const dirtyIndicator = isDirtyTextFile ? (
+
+ ) : null;
+ const requestClosePane = () => {
+ if (isDirtyTextFile) {
+ setCloseConfirmOpen(true);
+ return;
+ }
+
+ onClosePane(paneId);
+ };
+ const confirmClosePane = () => {
+ setCloseConfirmOpen(false);
+ onClosePane(paneId);
+ };
+
+ return (
+
+ {dragOverlayPlacement ? (
+
+ {dragOverlayPlacement === "center" ? (
+
{t("agent_panes.swap")}
+ ) : null}
+
+ ) : null}
+
+
+ {canDragPane ? (
+
+ }
+ onPointerDown={(event) => {
+ event.preventDefault();
+ event.stopPropagation();
+
+ if (event.pointerType === "touch") {
+ return;
+ }
+
+ onPaneDragStart?.({ paneId });
+ }}
+ size="sm"
+ />
+
+ ) : null}
+
+
+ }
+ onClick={() => onSplitPane(paneId, "horizontal")}
+ size="sm"
+ />
+
+
+ }
+ onClick={() => onSplitPane(paneId, "vertical")}
+ size="sm"
+ />
+
+
+ }
+ onClick={requestClosePane}
+ size="sm"
+ />
+
+ >
+ }
+ />
+
+
+
+
+ );
+};
+
+export default EditorPaneCard;
diff --git a/packages/web/src/features/agent-panes/views/shared/session-card.tsx b/packages/web/src/features/agent-panes/views/shared/session-card.tsx
index 728808af..c2524216 100644
--- a/packages/web/src/features/agent-panes/views/shared/session-card.tsx
+++ b/packages/web/src/features/agent-panes/views/shared/session-card.tsx
@@ -15,6 +15,7 @@ import { dispatchCommandAtom } from "../../../../atoms/connection";
import { sessionByIdAtomFamily, sessionsAtom } from "../../../../atoms/sessions";
import { workspaceByIdAtomFamily } from "../../../../atoms/workspaces";
import { IconButton, StatusDot, Tag, Tooltip } from "../../../../components/ui";
+import { useTranslation } from "../../../../lib/i18n";
import { useTerminalThemeBackground } from "../../../../theme";
import { PanelHeader } from "../../../shared/components/panel-header";
import { useSupervisor } from "../../../supervisor/actions/use-supervisor";
@@ -72,6 +73,7 @@ export const SessionCard: FC = ({
onSplitHorizontal,
onSplitVertical,
}) => {
+ const t = useTranslation();
const session = useAtomValue(sessionByIdAtomFamily(sessionId));
const dispatch = useAtomValue(dispatchCommandAtom);
const setSessions = useSetAtom(sessionsAtom);
@@ -113,7 +115,7 @@ export const SessionCard: FC = ({
const sessionTitle = session.title?.trim() || formatSessionLabel(session.id);
const providerLabel = formatProviderLabel(session.providerId);
- const sessionStateLabel = formatSessionStateLabel(session.state);
+ const sessionStateLabel = formatSessionStateLabel(session.state, t);
const terminalReadOnly = terminalReadOnlyOverride ?? !isSessionInteractive(session.state);
const isActiveSession = workspace?.uiState.activeSessionId === session.id;
const isRunning = session.state === "running";
@@ -187,7 +189,7 @@ export const SessionCard: FC = ({
{dragOverlayPlacement ? (
{dragOverlayPlacement === "center" ? (
-
Swap
+
{t("agent_panes.swap")}
) : null}
) : null}
@@ -227,10 +229,11 @@ export const SessionCard: FC = ({
{showHeaderActions ? (
{supportsPaneDrag ? (
-
+
}
onPointerDown={(event) => {
event.preventDefault();
@@ -255,28 +258,31 @@ export const SessionCard: FC = ({
/>
) : null}
-
+
}
onClick={() => onSplitHorizontal?.()}
size="sm"
/>
-
+
}
onClick={() => onSplitVertical?.()}
size="sm"
/>
-
+
}
onClick={() => void onClose?.()}
size="sm"
@@ -377,8 +383,15 @@ function formatSessionLabel(sessionId: string) {
return sessionId.replace(/[_-]/g, " ").toUpperCase();
}
-function formatSessionStateLabel(state: SessionState) {
- return state.replace(/_/g, " ").replace(/\b\w/g, (char) => char.toUpperCase());
+function formatSessionStateLabel(
+ state: SessionState,
+ t: (key: string, params?: Record) => string
+) {
+ const key = `session.state.${state}`;
+ const translated = t(key);
+ return translated === key
+ ? state.replace(/_/g, " ").replace(/\b\w/g, (char) => char.toUpperCase())
+ : translated;
}
function formatProviderLabel(providerId: string) {
diff --git a/packages/web/src/features/agent-providers/actions/use-agent-providers.ts b/packages/web/src/features/agent-providers/actions/use-agent-providers.ts
index a4a24af6..1c831a92 100644
--- a/packages/web/src/features/agent-providers/actions/use-agent-providers.ts
+++ b/packages/web/src/features/agent-providers/actions/use-agent-providers.ts
@@ -1,7 +1,8 @@
import type { ProviderListItem } from "@coder-studio/core";
import { useAtomValue } from "jotai";
-import { useEffect, useState } from "react";
+import { useCallback, useEffect, useState } from "react";
import { dispatchCommandAtom } from "../../../atoms/connection";
+import { useTranslation } from "../../../lib/i18n";
interface UseAgentProvidersResult {
providers: ProviderListItem[];
@@ -11,19 +12,20 @@ interface UseAgentProvidersResult {
}
export function useAgentProviders(): UseAgentProvidersResult {
+ const t = useTranslation();
const dispatch = useAtomValue(dispatchCommandAtom);
const [providers, setProviders] = useState([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
- const refresh = async () => {
+ const refresh = useCallback(async () => {
setIsLoading(true);
setError(null);
const result = await dispatch("provider.list", {});
if (!result.ok || !result.data) {
setProviders([]);
- setError(result.error?.message ?? "Failed to load providers");
+ setError(result.error?.message ?? t("provider.load_failed"));
setIsLoading(false);
return;
}
@@ -31,11 +33,11 @@ export function useAgentProviders(): UseAgentProvidersResult {
setProviders(result.data);
setError(null);
setIsLoading(false);
- };
+ }, [dispatch, t]);
useEffect(() => {
void refresh();
- }, [dispatch]);
+ }, [refresh]);
return {
providers,
diff --git a/packages/web/src/features/auth/index.tsx b/packages/web/src/features/auth/index.tsx
index 900404c6..b579baa8 100644
--- a/packages/web/src/features/auth/index.tsx
+++ b/packages/web/src/features/auth/index.tsx
@@ -107,13 +107,13 @@ export function LoginPage({
});
if (!response.ok) {
- const data = await response.json().catch(() => ({ error: "Login failed" }));
+ const data = await response.json().catch(() => ({ error: t("auth.login_failed") }));
if (data?.blocked === true) {
setError(formatBlockedMessage(data.blockedUntil));
return;
}
- setError(data.error || "Login failed");
+ setError(data.error || t("auth.login_failed"));
return;
}
diff --git a/packages/web/src/features/code-editor/actions/use-code-editor-actions.ts b/packages/web/src/features/code-editor/actions/use-code-editor-actions.ts
index 5a623b4c..bd8da6ec 100644
--- a/packages/web/src/features/code-editor/actions/use-code-editor-actions.ts
+++ b/packages/web/src/features/code-editor/actions/use-code-editor-actions.ts
@@ -1,7 +1,9 @@
+import type { GitCommitFileEntry, GitFileDiffPayload } from "@coder-studio/core";
import { useAtom, useAtomValue, useSetAtom } from "jotai";
import { useCallback, useEffect, useRef, useState } from "react";
import { dispatchCommandAtom } from "../../../atoms/connection";
import { activeWorkspaceAtom } from "../../../atoms/workspaces";
+import { useTranslation } from "../../../lib/i18n";
import { useOpenEditorsActions } from "../../workspace/actions/use-open-editors-actions";
import {
activeFilePathAtomFamily,
@@ -45,17 +47,8 @@ type FileReadImagePayload = {
type FileReadPayload = FileReadTextPayload | FileReadImagePayload;
-type GitDiffPayload = {
- diff: string;
- renderAs: "text" | "image";
- status: "modified" | "added" | "deleted";
- originalContent?: string;
- modifiedContent?: string;
- originalRevision?: "HEAD" | "INDEX";
- modifiedRevision?: "INDEX" | "WORKTREE";
-};
-
export function useCodeEditorActions() {
+ const t = useTranslation();
const workspace = useAtomValue(activeWorkspaceAtom);
const workspaceRootPath = workspace?.path;
const dispatch = useAtomValue(dispatchCommandAtom);
@@ -65,6 +58,7 @@ export function useCodeEditorActions() {
);
const [savingPaths, setSavingPaths] = useState>(() => new Set());
+ const savingPathsRef = useRef>(new Set());
const [saveError, setSaveError] = useState<{ path: string; message: string } | null>(null);
const [fileLoadError, setFileLoadError] = useState<{ path: string; message: string } | null>(
null
@@ -75,7 +69,7 @@ export function useCodeEditorActions() {
} | null>(null);
const workspaceId = workspace?.id;
- const [activeFilePath, setActiveFilePath] = useAtom(activeFilePathAtomFamily(workspaceId ?? ""));
+ const [activeFilePath] = useAtom(activeFilePathAtomFamily(workspaceId ?? ""));
const [openFiles, setOpenFiles] = useAtom(openFilesAtomFamily(workspaceId ?? ""));
const [mode, setMode] = useAtom(editorModeAtomFamily(workspaceId ?? ""));
const editorRefreshToken = useAtomValue(editorRefreshTokenAtomFamily(workspaceId ?? ""));
@@ -85,6 +79,7 @@ export function useCodeEditorActions() {
const pendingActivePathRef = useRef(null);
const nextSaveRequestIdRef = useRef(0);
const activeSaveRequestIdByPathRef = useRef>(new Map());
+ const nextCommitDiffRequestIdRef = useRef(0);
const previousOpenFilePathsRef = useRef(null);
const { closePath } = useOpenEditorsActions(workspaceId ?? "", {
workspaceRootPath,
@@ -106,7 +101,10 @@ export function useCodeEditorActions() {
lastSeededModePathRef.current = activeFilePath;
const shouldPreserveDiffMode =
- mode === "diff" && diffPreview?.source === "file" && diffPreview.path === activeFilePath;
+ mode === "diff" &&
+ (diffPreview?.kind === "worktree-file-diff" ||
+ diffPreview?.kind === "search-replace-file-diff") &&
+ diffPreview.path === activeFilePath;
const nextMode = shouldPreserveDiffMode ? "diff" : deriveEditorModeForOpenFile(currentFile);
if (nextMode !== mode) {
setMode(nextMode);
@@ -138,6 +136,7 @@ export function useCodeEditorActions() {
}
}
+ savingPathsRef.current = changed ? next : current;
return changed ? next : current;
});
setSaveError((current) => (current && removedPaths.has(current.path) ? null : current));
@@ -191,7 +190,7 @@ export function useCodeEditorActions() {
if (!result.ok || !result.data) {
finishPendingEditorLoad(workspaceId, path, requestId);
- const message = result.error?.message ?? "Failed to open file";
+ const message = result.error?.message ?? t("code_editor.open_failed_title");
console.error("Failed to open file:", message);
setFileLoadError({ path, message });
return;
@@ -208,7 +207,7 @@ export function useCodeEditorActions() {
if (!response.ok) {
finishPendingEditorLoad(workspaceId, path, requestId);
- const message = `Failed to fetch text-backed image bytes: ${response.status}`;
+ const message = `${t("code_editor.text_backed_image_load_failed")}: ${response.status}`;
console.error(message);
setFileLoadError({ path, message });
return;
@@ -242,7 +241,7 @@ export function useCodeEditorActions() {
} catch (error) {
finishPendingEditorLoad(workspaceId, path, requestId);
const message =
- error instanceof Error ? error.message : "Failed to fetch text-backed image bytes";
+ error instanceof Error ? error.message : t("code_editor.text_backed_image_load_failed");
console.error("Failed to fetch text-backed image bytes:", error);
setFileLoadError({ path, message });
}
@@ -284,17 +283,20 @@ export function useCodeEditorActions() {
setExternalStatus((current) => (current?.path === path ? null : current));
setFileLoadError((current) => (current?.path === path ? null : current));
},
- [dispatch, setOpenFiles, workspaceId, workspaceRootPath]
+ [dispatch, setOpenFiles, t, workspaceId, workspaceRootPath]
);
- const loadTextBackedImageContent = useCallback(async (url: string) => {
- const response = await fetch(url, { credentials: "include" });
- if (!response.ok) {
- throw new Error(`Failed to fetch text-backed image bytes: ${response.status}`);
- }
+ const loadTextBackedImageContent = useCallback(
+ async (url: string) => {
+ const response = await fetch(url, { credentials: "include" });
+ if (!response.ok) {
+ throw new Error(`${t("code_editor.text_backed_image_load_failed")}: ${response.status}`);
+ }
- return response.text();
- }, []);
+ return response.text();
+ },
+ [t]
+ );
const handleSave = useCallback(async () => {
if (!workspaceId || !currentFile || currentFile.kind !== "text") {
@@ -302,13 +304,16 @@ export function useCodeEditorActions() {
}
const { path, content, baseHash } = currentFile;
- if (savingPaths.has(path)) {
+ if (savingPathsRef.current.has(path)) {
return;
}
const requestId = ++nextSaveRequestIdRef.current;
activeSaveRequestIdByPathRef.current.set(path, requestId);
- setSavingPaths((current) => new Set(current).add(path));
+ const nextSavingPaths = new Set(savingPathsRef.current);
+ nextSavingPaths.add(path);
+ savingPathsRef.current = nextSavingPaths;
+ setSavingPaths(nextSavingPaths);
setSaveError((current) => (current?.path === path ? null : current));
const result = await dispatch<{ newHash: string }>("file.write", {
@@ -342,16 +347,15 @@ export function useCodeEditorActions() {
});
setExternalStatus((current) => (current?.path === path ? null : current));
} else {
- setSaveError({ path, message: result.error?.message ?? "Failed to save file" });
+ setSaveError({ path, message: result.error?.message ?? t("code_editor.save_failed_title") });
}
activeSaveRequestIdByPathRef.current.delete(path);
- setSavingPaths((current) => {
- const next = new Set(current);
- next.delete(path);
- return next;
- });
- }, [currentFile, dispatch, savingPaths, setOpenFiles, workspaceId]);
+ const nextSavingPathsAfterSave = new Set(savingPathsRef.current);
+ nextSavingPathsAfterSave.delete(path);
+ savingPathsRef.current = nextSavingPathsAfterSave;
+ setSavingPaths(nextSavingPathsAfterSave);
+ }, [currentFile, dispatch, setOpenFiles, t, workspaceId]);
const handleContentChange = useCallback(
(newContent: string) => {
@@ -630,8 +634,13 @@ export function useCodeEditorActions() {
workspaceRootPath,
]);
- const handleClose = useCallback(() => {
- if (diffPreview?.source === "commit") {
+ const handleClose = useCallback(async () => {
+ if (diffPreview?.kind === "commit-file-diff") {
+ setDiffPreview(diffPreview.parentList);
+ return;
+ }
+
+ if (diffPreview?.kind === "commit-file-list") {
setDiffPreview(null);
if (currentFile) {
const nextMode = deriveEditorModeForOpenFile(currentFile);
@@ -643,7 +652,7 @@ export function useCodeEditorActions() {
}
if (currentFile?.path || activeFilePath) {
- closePath(currentFile?.path ?? activeFilePath);
+ closePath(currentFile?.path ?? activeFilePath ?? undefined);
}
setSaveError(null);
@@ -675,7 +684,7 @@ export function useCodeEditorActions() {
return false;
}
- const result = await dispatch("git.diff", {
+ const result = await dispatch("git.diff", {
workspaceId,
path: currentFile.path,
staged: false,
@@ -685,13 +694,16 @@ export function useCodeEditorActions() {
return false;
}
- const nextPreview: GitDiffPreview = {
+ const nextPreview = {
+ kind: "worktree-file-diff",
path: currentFile.path,
diff: result.data.diff,
staged: false,
- source: "file",
...(result.data.renderAs ? { renderAs: result.data.renderAs } : {}),
...(result.data.status ? { status: result.data.status } : {}),
+ ...(result.data.mime ? { mime: result.data.mime } : {}),
+ ...(result.data.originalPath ? { originalPath: result.data.originalPath } : {}),
+ ...(result.data.modifiedPath ? { modifiedPath: result.data.modifiedPath } : {}),
...(result.data.originalContent !== undefined
? { originalContent: result.data.originalContent }
: {}),
@@ -700,13 +712,80 @@ export function useCodeEditorActions() {
: {}),
...(result.data.originalRevision ? { originalRevision: result.data.originalRevision } : {}),
...(result.data.modifiedRevision ? { modifiedRevision: result.data.modifiedRevision } : {}),
- };
+ } as GitDiffPreview;
setDiffPreviewDismissed(false);
setDiffPreview(nextPreview);
setMode("diff");
return true;
}, [currentFile, dispatch, setDiffPreview, setDiffPreviewDismissed, setMode, workspaceId]);
+ const openCommitFileDiff = useCallback(
+ async (file: GitCommitFileEntry) => {
+ if (!workspaceId || diffPreview?.kind !== "commit-file-list") {
+ return false;
+ }
+
+ const parentList = diffPreview;
+ const requestId = nextCommitDiffRequestIdRef.current + 1;
+ nextCommitDiffRequestIdRef.current = requestId;
+
+ const result = await dispatch("git.commitFileDiff", {
+ workspaceId,
+ sha: parentList.commit.sha,
+ path: file.path,
+ ...(file.oldPath ? { oldPath: file.oldPath } : {}),
+ });
+
+ if (!result.ok || !result.data) {
+ return false;
+ }
+
+ const payload = result.data;
+
+ let applied = false;
+ setDiffPreview((current) => {
+ if (requestId !== nextCommitDiffRequestIdRef.current) {
+ return current;
+ }
+
+ if (
+ current?.kind !== "commit-file-list" ||
+ current !== parentList ||
+ current.path !== parentList.path ||
+ current.commit.sha !== parentList.commit.sha
+ ) {
+ return current;
+ }
+
+ applied = true;
+ return {
+ kind: "commit-file-diff",
+ path: file.path,
+ title: file.path,
+ commit: parentList.commit,
+ file,
+ parentList,
+ diff: payload.diff,
+ renderAs: payload.renderAs,
+ status: payload.status,
+ ...(payload.mime ? { mime: payload.mime } : {}),
+ ...(payload.originalPath ? { originalPath: payload.originalPath } : {}),
+ ...(payload.modifiedPath ? { modifiedPath: payload.modifiedPath } : {}),
+ ...(payload.originalContent !== undefined
+ ? { originalContent: payload.originalContent }
+ : {}),
+ ...(payload.modifiedContent !== undefined
+ ? { modifiedContent: payload.modifiedContent }
+ : {}),
+ ...(payload.originalRevision ? { originalRevision: payload.originalRevision } : {}),
+ ...(payload.modifiedRevision ? { modifiedRevision: payload.modifiedRevision } : {}),
+ };
+ });
+ return applied;
+ },
+ [diffPreview, dispatch, setDiffPreview, workspaceId]
+ );
+
const isTextFile = currentFile?.kind === "text";
const isImageFile = currentFile?.kind === "image";
const isSvgTextBacked =
@@ -729,12 +808,39 @@ export function useCodeEditorActions() {
);
const activeDiffChange =
diffPreview &&
- ((diffPreview.source === "file" && diffPreview.path === activeFilePath) ||
- diffPreview.source === "commit")
+ (((diffPreview.kind === "worktree-file-diff" ||
+ diffPreview.kind === "search-replace-file-diff") &&
+ diffPreview.path === activeFilePath) ||
+ diffPreview.kind === "commit-file-list" ||
+ diffPreview.kind === "commit-file-diff")
? diffPreview
: null;
const isSaving = Boolean(isTextFile && savingPaths.has(currentFile.path));
const canSave = Boolean(isTextFile && currentFile.isDirty && !isSaving);
+ useEffect(() => {
+ const handleSaveShortcut = (event: KeyboardEvent) => {
+ const isSaveShortcut =
+ event.key.toLowerCase() === "s" && (event.ctrlKey || event.metaKey) && !event.altKey;
+ if (!isSaveShortcut) {
+ return;
+ }
+
+ if (!isTextFile) {
+ return;
+ }
+
+ event.preventDefault();
+ if (canSave) {
+ void handleSave();
+ }
+ };
+
+ window.addEventListener("keydown", handleSaveShortcut);
+ return () => {
+ window.removeEventListener("keydown", handleSaveShortcut);
+ };
+ }, [canSave, handleSave, isTextFile]);
+
const activeLoadError =
activeFilePath && fileLoadError?.path === activeFilePath ? fileLoadError.message : null;
const activeExternalStatus =
@@ -771,6 +877,7 @@ export function useCodeEditorActions() {
isSvgTextBacked,
isTextFile,
mode,
+ openCommitFileDiff,
openInDiffMode,
saveError: activeSaveError,
setMode: (nextMode: WorkspaceEditorMode) => {
diff --git a/packages/web/src/features/code-editor/actions/use-open-location.ts b/packages/web/src/features/code-editor/actions/use-open-location.ts
index 99c47374..854a3170 100644
--- a/packages/web/src/features/code-editor/actions/use-open-location.ts
+++ b/packages/web/src/features/code-editor/actions/use-open-location.ts
@@ -24,7 +24,11 @@ export function useOpenLocation(workspaceId: string): {
const openLocation = useCallback(
async (input: PendingEditorNavigation) => {
- if (diffPreview?.source === "commit") {
+ if (
+ diffPreview?.kind === "commit-file-list" ||
+ diffPreview?.kind === "commit-file-diff" ||
+ diffPreview?.kind === "search-replace-file-diff"
+ ) {
setDiffPreview(null);
const openFile = openFiles[input.path];
setEditorMode(
diff --git a/packages/web/src/features/code-editor/components/commit-file-list-preview.test.tsx b/packages/web/src/features/code-editor/components/commit-file-list-preview.test.tsx
new file mode 100644
index 00000000..bb90387c
--- /dev/null
+++ b/packages/web/src/features/code-editor/components/commit-file-list-preview.test.tsx
@@ -0,0 +1,55 @@
+import { fireEvent, render, screen, within } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+import { CommitFileListPreview } from "./commit-file-list-preview";
+
+describe("CommitFileListPreview", () => {
+ it("renders commit files with split path metadata and opens a selected commit diff", () => {
+ const onOpenFile = vi.fn();
+ const files = [
+ {
+ path: "src/app.tsx",
+ status: "modified" as const,
+ renderAs: "text" as const,
+ },
+ {
+ path: "src/renamed.ts",
+ oldPath: "src/old.ts",
+ status: "renamed" as const,
+ renderAs: "text" as const,
+ },
+ ];
+
+ render(
+
+ );
+
+ const modifiedRow = screen.getByRole("button", { name: "src/app.tsx modified" });
+ expect(within(modifiedRow).getByText("app.tsx")).toBeInTheDocument();
+ expect(within(modifiedRow).getByText("src/")).toBeInTheDocument();
+ expect(within(modifiedRow).getByText("modified")).toBeInTheDocument();
+
+ const renamedRow = screen.getByRole("button", { name: "src/old.ts -> src/renamed.ts renamed" });
+ expect(within(renamedRow).getByText("renamed.ts")).toBeInTheDocument();
+ expect(within(renamedRow).getByText("src/old.ts")).toBeInTheDocument();
+ expect(within(renamedRow).getByText("renamed")).toBeInTheDocument();
+
+ fireEvent.click(modifiedRow);
+
+ expect(onOpenFile).toHaveBeenCalledWith(files[0]);
+ });
+});
diff --git a/packages/web/src/features/code-editor/components/commit-file-list-preview.tsx b/packages/web/src/features/code-editor/components/commit-file-list-preview.tsx
new file mode 100644
index 00000000..0d13d50d
--- /dev/null
+++ b/packages/web/src/features/code-editor/components/commit-file-list-preview.tsx
@@ -0,0 +1,73 @@
+import type { GitCommitFileEntry } from "@coder-studio/core";
+import type { FC } from "react";
+import { ThemedIcon } from "../../../components/ui";
+import type { GitCommitFileListPreview } from "../../workspace/atoms";
+
+interface CommitFileListPreviewProps {
+ preview: GitCommitFileListPreview;
+ onOpenFile: (file: GitCommitFileEntry) => void;
+}
+
+function formatFileLabel(file: GitCommitFileEntry): string {
+ return file.oldPath ? `${file.oldPath} -> ${file.path}` : file.path;
+}
+
+function splitPath(filePath: string) {
+ const pathParts = filePath.split("/");
+ const name = pathParts[pathParts.length - 1] ?? filePath;
+ const dir = pathParts.length > 1 ? `${pathParts.slice(0, -1).join("/")}/` : "";
+ return { dir, name };
+}
+
+function getStatusSemantic(status: GitCommitFileEntry["status"]) {
+ switch (status) {
+ case "deleted":
+ return "git.status.deleted";
+ case "added":
+ case "renamed":
+ case "modified":
+ default:
+ return "git.status.modified";
+ }
+}
+
+export const CommitFileListPreview: FC = ({ preview, onOpenFile }) => {
+ return (
+
+
+ {preview.files.map((file) => {
+ const { dir, name } = splitPath(file.path);
+ return (
+
onOpenFile(file)}
+ >
+
+
+
+
+ {name}
+
+ {dir ? {dir} : null}
+ {file.oldPath ? {file.oldPath} : null}
+
+
+
+ {file.status[0]?.toUpperCase() ?? "?"}
+
+ {file.status}
+
+ );
+ })}
+
+
+ );
+};
+
+export default CommitFileListPreview;
diff --git a/packages/web/src/features/code-editor/components/image-diff-preview.test.tsx b/packages/web/src/features/code-editor/components/image-diff-preview.test.tsx
index 9809e9c1..98791604 100644
--- a/packages/web/src/features/code-editor/components/image-diff-preview.test.tsx
+++ b/packages/web/src/features/code-editor/components/image-diff-preview.test.tsx
@@ -1,10 +1,31 @@
-import { render, screen } from "@testing-library/react";
+import { fireEvent, render, screen, within } from "@testing-library/react";
+import { createStore, Provider } from "jotai";
+import type { PropsWithChildren, ReactElement } from "react";
import { describe, expect, it } from "vitest";
+import { localeAtom } from "../../../atoms/app-ui";
import { ImageDiffPreview } from "./image-diff-preview";
+function renderWithLocale(ui: ReactElement) {
+ const store = createStore();
+ store.set(localeAtom, "en");
+
+ function LocaleProvider({ children }: PropsWithChildren) {
+ return {children} ;
+ }
+
+ return render(ui, { wrapper: LocaleProvider });
+}
+
+function getPane(label: "Base" | "Current"): HTMLElement {
+ const header = screen.getByText(label);
+ const pane = header.closest("section");
+ expect(pane).toBeTruthy();
+ return pane as HTMLElement;
+}
+
describe("ImageDiffPreview", () => {
it("renders baseline image on top and workspace image on bottom for modified files", () => {
- render(
+ renderWithLocale(
{
);
const images = screen.getAllByRole("img");
- expect(images[0]).toHaveAttribute("alt", "assets/logo.png base");
- expect(images[1]).toHaveAttribute("alt", "assets/logo.png current");
+ expect(images[0]).toHaveAttribute("alt", "assets/logo.png Base");
+ expect(images[1]).toHaveAttribute("alt", "assets/logo.png Current");
});
it("renders an empty top state for added images", () => {
- render(
+ renderWithLocale(
{
/>
);
- expect(screen.getByText("No image")).toBeInTheDocument();
- expect(screen.getByRole("img", { name: "assets/new.png current" })).toBeInTheDocument();
+ expect(within(getPane("Base")).getByText("No base image")).toBeInTheDocument();
+ expect(
+ within(getPane("Current")).getByRole("img", { name: "assets/new.png Current" })
+ ).toBeInTheDocument();
});
it("renders an empty bottom state for deleted images", () => {
- render(
+ renderWithLocale(
{
/>
);
- expect(screen.getByRole("img", { name: "assets/deleted.png base" })).toBeInTheDocument();
- expect(screen.getByText("No image")).toBeInTheDocument();
+ expect(
+ within(getPane("Base")).getByRole("img", { name: "assets/deleted.png Base" })
+ ).toBeInTheDocument();
+ expect(within(getPane("Current")).getByText("No current image")).toBeInTheDocument();
+ });
+
+ it("renders a pane-local error state when one side fails to load", () => {
+ renderWithLocale(
+
+ );
+
+ fireEvent.error(screen.getByRole("img", { name: "assets/logo.png Base" }));
+
+ expect(within(getPane("Base")).getByText("Preview unavailable")).toBeInTheDocument();
+ expect(
+ within(getPane("Current")).getByRole("img", { name: "assets/logo.png Current" })
+ ).toBeInTheDocument();
+ });
+
+ it("lets the user retry after an image load failure without changing the url", () => {
+ renderWithLocale(
+
+ );
+
+ fireEvent.error(screen.getByRole("img", { name: "assets/logo.png Base" }));
+
+ const basePane = getPane("Base");
+ expect(within(basePane).getByText("Preview unavailable")).toBeInTheDocument();
+
+ fireEvent.click(within(basePane).getByRole("button", { name: "Retry" }));
+
+ expect(within(basePane).queryByText("Preview unavailable")).not.toBeInTheDocument();
+ expect(within(basePane).getByRole("img", { name: "assets/logo.png Base" })).toBeInTheDocument();
+ });
+
+ it("resets a pane error state when its image url changes", () => {
+ const { rerender } = renderWithLocale(
+
+ );
+
+ fireEvent.error(screen.getByRole("img", { name: "assets/logo.png Current" }));
+ expect(screen.getByText("Preview unavailable")).toBeInTheDocument();
+
+ rerender(
+
+ );
+
+ expect(screen.queryByText("Preview unavailable")).not.toBeInTheDocument();
+ expect(screen.getByRole("img", { name: "assets/logo.png Current" })).toHaveAttribute(
+ "src",
+ "/api/file?workspaceId=ws-1&path=assets%2Flogo.png&revision=HEAD"
+ );
});
});
diff --git a/packages/web/src/features/code-editor/components/image-diff-preview.tsx b/packages/web/src/features/code-editor/components/image-diff-preview.tsx
index 5704fa46..fc5a90d2 100644
--- a/packages/web/src/features/code-editor/components/image-diff-preview.tsx
+++ b/packages/web/src/features/code-editor/components/image-diff-preview.tsx
@@ -1,5 +1,7 @@
import type { FC } from "react";
-import { EmptyState } from "../../../components/ui";
+import { useEffect, useState } from "react";
+import { Button, EmptyState } from "../../../components/ui";
+import { useTranslation } from "../../../lib/i18n";
interface ImageDiffPreviewProps {
path: string;
@@ -15,19 +17,65 @@ function imageLabel(mime: string): string {
return head.replace(/^x-/, "").toUpperCase();
}
-function ImageDiffPane({ label, url, alt }: { label: string; url?: string; alt: string }) {
+function ImageDiffPane({
+ label,
+ emptyTitle,
+ url,
+ alt,
+}: {
+ label: string;
+ emptyTitle: string;
+ url?: string;
+ alt: string;
+}) {
+ const t = useTranslation();
+ const [errored, setErrored] = useState(false);
+ const [reloadKey, setReloadKey] = useState(0);
+
+ useEffect(() => {
+ setErrored(false);
+ setReloadKey(0);
+ }, [url]);
+
return (
- {url ? (
-
- ) : (
+ {!url ? (
No image}
+ title={{emptyTitle}
}
+ />
+ ) : errored ? (
+ {
+ setErrored(false);
+ setReloadKey((current) => current + 1);
+ }}
+ size="sm"
+ variant="ghost"
+ >
+ {t("code_editor.preview_retry")}
+
+ }
+ className="git-diff-empty"
+ description={
+ {t("code_editor.image_load_failed_body")}
+ }
+ title={{t("code_editor.preview_unavailable")}
}
+ />
+ ) : (
+ setErrored(true)}
/>
)}
@@ -42,6 +90,8 @@ export const ImageDiffPreview: FC = ({
beforeUrl,
afterUrl,
}) => {
+ const t = useTranslation();
+
return (
@@ -50,8 +100,18 @@ export const ImageDiffPreview: FC = ({
{status}
-
-
+
+
);
diff --git a/packages/web/src/features/code-editor/components/image-preview.test.tsx b/packages/web/src/features/code-editor/components/image-preview.test.tsx
index 0b01f605..29810269 100644
--- a/packages/web/src/features/code-editor/components/image-preview.test.tsx
+++ b/packages/web/src/features/code-editor/components/image-preview.test.tsx
@@ -1,10 +1,24 @@
import { fireEvent, render, screen } from "@testing-library/react";
+import { createStore, Provider } from "jotai";
+import type { PropsWithChildren, ReactElement } from "react";
import { describe, expect, it } from "vitest";
+import { localeAtom } from "../../../atoms/app-ui";
import { ImagePreview } from "./image-preview";
+function renderWithLocale(ui: ReactElement) {
+ const store = createStore();
+ store.set(localeAtom, "en");
+
+ function LocaleProvider({ children }: PropsWithChildren) {
+ return {children} ;
+ }
+
+ return render(ui, { wrapper: LocaleProvider });
+}
+
describe("ImagePreview", () => {
it("preserves the migrated empty-state fallback when image loading fails", () => {
- render(
+ renderWithLocale(
{
});
it("resets the preview state when only the version changes", () => {
- const { rerender } = render(
+ const { rerender } = renderWithLocale(
{
});
it("appends the cache-busting version with ampersand when the url already has query params", () => {
- render(
+ renderWithLocale(
= ({ url, version, mime, sizeBytes, alt }) => {
+ const t = useTranslation();
const [dimensions, setDimensions] = useState<{ w: number; h: number } | null>(null);
const [errored, setErrored] = useState(false);
const src = `${url}${url.includes("?") ? "&" : "?"}v=${version}`;
@@ -51,12 +53,9 @@ export const ImagePreview: FC = ({ url, version, mime, sizeBy
- The image could not be loaded. The file may have been moved or is larger than the
- browser allows.
-
+ {t("code_editor.image_load_failed_body")}
}
- title={Preview unavailable
}
+ title={{t("code_editor.preview_unavailable")}
}
/>
) : (
{children};
+ }
+
+ return render(ui, { wrapper: LocaleProvider });
+}
+
describe("LspStatusNotice", () => {
it("renders an install action when the server is missing and auto-install is supported", () => {
const onInstall = vi.fn();
- render(
+ renderWithLocale(
{
it("renders a retry action when installation failed", () => {
const onRetry = vi.fn();
- render(
+ renderWithLocale(
{
});
it("does not render an install action when prerequisites are missing", () => {
- render(
+ renderWithLocale(
{
});
it("renders a disabled notice without install or retry actions", () => {
- render(
+ renderWithLocale(
) => string
+): string | null {
if (!step) {
return null;
}
if (step.status === "running") {
- return `Installing: ${step.title}`;
+ return t("code_editor.lsp_installing_step", { title: step.title });
}
if (step.status === "failed") {
- return `Install failed at: ${step.title}`;
+ return t("code_editor.lsp_install_failed_step", { title: step.title });
}
return null;
}
+function getLspMessage(
+ state: LspNoticeState,
+ progressMessage: string | null,
+ t: (key: string, params?: Record) => string
+): string {
+ if (progressMessage) {
+ return progressMessage;
+ }
+
+ if (state.kind === "installing" && state.errorCode === "lsp_install_in_progress") {
+ return t("code_editor.lsp_install_in_progress");
+ }
+
+ if (state.kind === "failed" && state.errorCode === "lsp_install_failed") {
+ return state.message || t("code_editor.lsp_install_failed");
+ }
+
+ return state.message;
+}
+
export function LspStatusNotice({
state,
onInstall,
onRetry,
installing = false,
}: LspStatusNoticeProps) {
+ const t = useTranslation();
+
if (state.kind === "disabled") {
return (
);
}
@@ -54,7 +80,7 @@ export function LspStatusNotice({
const activeStep = state.installJob?.steps.find(
(step) => step.id === state.installJob?.currentStepId
);
- const progressMessage = describeStep(activeStep);
+ const progressMessage = describeStep(activeStep, t);
const canInstall =
state.kind === "tool_missing" &&
state.autoInstallSupported &&
@@ -64,19 +90,19 @@ export function LspStatusNotice({
const action = canInstall ? (
- Install
+ {t("code_editor.lsp_install")}
) : canRetry ? (
- Retry
+ {t("action.retry")}
) : null;
return (
);
diff --git a/packages/web/src/features/code-editor/components/monaco-diff-host.test.tsx b/packages/web/src/features/code-editor/components/monaco-diff-host.test.tsx
index c5802e34..fe9bb564 100644
--- a/packages/web/src/features/code-editor/components/monaco-diff-host.test.tsx
+++ b/packages/web/src/features/code-editor/components/monaco-diff-host.test.tsx
@@ -1,12 +1,16 @@
import { render, screen } from "@testing-library/react";
import { createStore, Provider } from "jotai";
-import { describe, expect, it, vi } from "vitest";
+import { beforeEach, describe, expect, it, vi } from "vitest";
import { getThemeById } from "../../../theme";
import { MonacoDiffHost } from "./monaco-diff-host";
const {
mockCreateDiffEditor,
+ mockRegisterLanguage,
+ mockSetLanguageConfiguration,
+ mockSetMonarchTokensProvider,
mockDefineTheme,
+ mockCreateModel,
mockSetModel,
mockOriginalModel,
mockModifiedModel,
@@ -23,6 +27,10 @@ const {
setValue: vi.fn(),
};
const mockSetModel = vi.fn();
+ const mockCreateModel = vi
+ .fn()
+ .mockImplementationOnce(() => mockOriginalModel)
+ .mockImplementationOnce(() => mockModifiedModel);
return {
mockCreateDiffEditor: vi.fn(() => ({
dispose: vi.fn(),
@@ -30,21 +38,33 @@ const {
setModel: mockSetModel,
updateOptions: vi.fn(),
})),
+ mockCreateModel,
mockDefineTheme: vi.fn(),
mockSetModel,
mockOriginalModel,
mockModifiedModel,
+ mockRegisterLanguage: vi.fn(),
+ mockSetLanguageConfiguration: vi.fn(),
+ mockSetMonarchTokensProvider: vi.fn(),
mockSetTheme: vi.fn(),
};
});
vi.mock("monaco-editor", () => ({
+ languages: {
+ register: mockRegisterLanguage,
+ setLanguageConfiguration: mockSetLanguageConfiguration,
+ setMonarchTokensProvider: mockSetMonarchTokensProvider,
+ IndentAction: {
+ None: 0,
+ Indent: 1,
+ IndentOutdent: 2,
+ Outdent: 3,
+ },
+ },
editor: {
createDiffEditor: mockCreateDiffEditor,
- createModel: vi
- .fn()
- .mockImplementationOnce(() => mockOriginalModel)
- .mockImplementationOnce(() => mockModifiedModel),
+ createModel: mockCreateModel,
defineTheme: mockDefineTheme,
setTheme: mockSetTheme,
},
@@ -67,6 +87,19 @@ vi.mock("monaco-editor/esm/vs/language/typescript/ts.worker?worker", () => ({
}));
describe("MonacoDiffHost", () => {
+ beforeEach(() => {
+ mockCreateDiffEditor.mockClear();
+ mockDefineTheme.mockClear();
+ mockSetModel.mockClear();
+ mockSetTheme.mockClear();
+ mockOriginalModel.dispose.mockClear();
+ mockModifiedModel.dispose.mockClear();
+ mockCreateModel
+ .mockReset()
+ .mockImplementationOnce(() => mockOriginalModel)
+ .mockImplementationOnce(() => mockModifiedModel);
+ });
+
it("creates a Monaco diff editor with original and modified models", () => {
render(
@@ -95,4 +128,19 @@ describe("MonacoDiffHost", () => {
});
expect(screen.getByTestId("monaco-diff-host")).toBeInTheDocument();
});
+
+ it("creates vue diff models with the vue language id", () => {
+ render(
+
+
+
+ );
+
+ expect(mockCreateModel).toHaveBeenNthCalledWith(1, expect.stringContaining("before"), "vue");
+ expect(mockCreateModel).toHaveBeenNthCalledWith(2, expect.stringContaining("after"), "vue");
+ });
});
diff --git a/packages/web/src/features/code-editor/components/monaco-diff-host.tsx b/packages/web/src/features/code-editor/components/monaco-diff-host.tsx
index 6b791d8e..2e402cdb 100644
--- a/packages/web/src/features/code-editor/components/monaco-diff-host.tsx
+++ b/packages/web/src/features/code-editor/components/monaco-diff-host.tsx
@@ -9,6 +9,7 @@ import type { FC } from "react";
import { useEffect, useRef } from "react";
import { themeAtom } from "../../../atoms/app-ui";
import { createWorkspaceMonacoTheme, getThemeById } from "../../../theme";
+import { ensureVueLanguageRegistered } from "../monaco/vue-language";
const monacoGlobal = globalThis as typeof globalThis & {
MonacoEnvironment?: monaco.Environment;
@@ -24,6 +25,8 @@ monacoGlobal.MonacoEnvironment ??= {
},
};
+ensureVueLanguageRegistered();
+
interface MonacoDiffHostProps {
originalContent: string;
modifiedContent: string;
@@ -58,7 +61,7 @@ export const MonacoDiffHost: FC = ({
return;
}
- editorRef.current = monaco.editor.createDiffEditor(containerRef.current, {
+ const options = {
automaticLayout: true,
fontFamily: "JetBrains Mono, monospace",
fontSize: 13,
@@ -67,8 +70,13 @@ export const MonacoDiffHost: FC = ({
readOnly,
renderSideBySide: false,
scrollBeyondLastLine: false,
+ "semanticHighlighting.enabled": true,
theme: editorTheme,
- });
+ } satisfies monaco.editor.IStandaloneDiffEditorConstructionOptions & {
+ "semanticHighlighting.enabled": boolean;
+ };
+
+ editorRef.current = monaco.editor.createDiffEditor(containerRef.current, options);
return () => {
editorRef.current?.dispose();
@@ -127,6 +135,7 @@ function detectEditorLanguage(filePath: string): string {
py: "python",
go: "go",
rs: "rust",
+ vue: "vue",
java: "java",
cpp: "cpp",
c: "c",
diff --git a/packages/web/src/features/code-editor/components/monaco-host.test.tsx b/packages/web/src/features/code-editor/components/monaco-host.test.tsx
index edb9dd49..1a3e2e5f 100644
--- a/packages/web/src/features/code-editor/components/monaco-host.test.tsx
+++ b/packages/web/src/features/code-editor/components/monaco-host.test.tsx
@@ -1,6 +1,6 @@
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { createStore, Provider } from "jotai";
-import { beforeEach, describe, expect, it, vi } from "vitest";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { themeAtom } from "../../../atoms/app-ui";
import { wsClientAtom } from "../../../atoms/connection";
import { getThemeById } from "../../../theme";
@@ -28,6 +28,9 @@ const {
mockSetJavaScriptCompilerOptions,
mockSetJavaScriptDiagnosticsOptions,
mockSetJavaScriptEagerModelSync,
+ mockRegisterLanguage,
+ mockSetLanguageConfiguration,
+ mockSetMonarchTokensProvider,
mockSetTypeScriptCompilerOptions,
mockSetTypeScriptDiagnosticsOptions,
mockSetTypeScriptEagerModelSync,
@@ -137,6 +140,9 @@ const {
return { dispose: vi.fn() };
}
);
+ const mockRegisterLanguage = vi.fn();
+ const mockSetLanguageConfiguration = vi.fn();
+ const mockSetMonarchTokensProvider = vi.fn();
const mockSetTypeScriptCompilerOptions = vi.fn();
const mockSetJavaScriptCompilerOptions = vi.fn();
const mockSetTypeScriptDiagnosticsOptions = vi.fn();
@@ -172,6 +178,9 @@ const {
mockRevealRangeInCenter,
mockRegistryGetOrCreate,
mockRegisterCodeEditorOpenHandler,
+ mockRegisterLanguage,
+ mockSetLanguageConfiguration,
+ mockSetMonarchTokensProvider,
mockSetJavaScriptCompilerOptions,
mockSetJavaScriptDiagnosticsOptions,
mockSetJavaScriptEagerModelSync,
@@ -212,6 +221,15 @@ vi.mock("monaco-editor", () => ({
CtrlCmd: 2048,
},
languages: {
+ register: mockRegisterLanguage,
+ setLanguageConfiguration: mockSetLanguageConfiguration,
+ setMonarchTokensProvider: mockSetMonarchTokensProvider,
+ IndentAction: {
+ None: 0,
+ Indent: 1,
+ IndentOutdent: 2,
+ Outdent: 3,
+ },
typescript: {
JsxEmit: {
ReactJSX: 4,
@@ -266,6 +284,7 @@ vi.mock("monaco-editor/esm/vs/editor/browser/services/codeEditorService.js", ()
describe("MonacoHost", () => {
beforeEach(() => {
+ window.localStorage.setItem("ui.locale", JSON.stringify("en"));
mockCreateEditor.mockClear();
mockCreateModel.mockClear();
mockDefineTheme.mockClear();
@@ -279,6 +298,9 @@ describe("MonacoHost", () => {
mockRevealRangeInCenter.mockClear();
mockRegistryGetOrCreate.mockClear();
mockRegisterCodeEditorOpenHandler.mockClear();
+ mockRegisterLanguage.mockClear();
+ mockSetLanguageConfiguration.mockClear();
+ mockSetMonarchTokensProvider.mockClear();
mockEditorInstance.dispose.mockClear();
mockEditorInstance.getValue.mockClear();
mockEditorInstance.layout.mockClear();
@@ -292,6 +314,10 @@ describe("MonacoHost", () => {
openHandlerState.current = null;
});
+ afterEach(() => {
+ window.localStorage.clear();
+ });
+
it("configures Monaco JS/TS defaults for JSX syntax and eager model sync", () => {
expect(mockSetTypeScriptCompilerOptions).toHaveBeenCalledWith(
expect.objectContaining({
@@ -353,6 +379,7 @@ describe("MonacoHost", () => {
expect.any(HTMLDivElement),
expect.objectContaining({
readOnly: false,
+ "semanticHighlighting.enabled": true,
theme: "coder-studio-workspace-mint-light",
})
);
@@ -625,6 +652,39 @@ describe("MonacoHost", () => {
});
});
+ it("attaches vue workspace-backed models with the vue language id", async () => {
+ render(
+
+ const count = 1;'}
+ />
+
+ );
+
+ await waitFor(() => {
+ expect(mockRegistryGetOrCreate).toHaveBeenCalledWith(
+ expect.objectContaining({
+ workspaceRootPath: "/repo",
+ path: "src/App.vue",
+ language: "vue",
+ })
+ );
+ expect(mockAttachLspBridgeModel).toHaveBeenCalledWith(
+ {
+ workspaceId: "ws-test",
+ workspaceRootPath: "/repo",
+ path: "src/App.vue",
+ monacoLanguage: "vue",
+ model: workspaceModelA,
+ },
+ expect.any(Function)
+ );
+ });
+ });
+
it("does not attach the lsp bridge when runtime mode is off", async () => {
const store = createStore();
store.set(lspRuntimeModeAtom, "off");
diff --git a/packages/web/src/features/code-editor/components/monaco-host.tsx b/packages/web/src/features/code-editor/components/monaco-host.tsx
index 3225bbec..26731f60 100644
--- a/packages/web/src/features/code-editor/components/monaco-host.tsx
+++ b/packages/web/src/features/code-editor/components/monaco-host.tsx
@@ -25,6 +25,7 @@ import { globalLspBridge, type LspBridgeState } from "../lsp/bridge";
import { lspRuntimeModeAtom } from "../lsp/runtime-mode";
import { monacoModelRegistry } from "../monaco/model-registry";
import { fromWorkspaceFileUri } from "../monaco/uri";
+import { ensureVueLanguageRegistered } from "../monaco/vue-language";
import { LspStatusNotice } from "./lsp-status-notice";
const monacoGlobal = globalThis as typeof globalThis & {
@@ -52,6 +53,7 @@ monacoGlobal.MonacoEnvironment ??= {
let javaScriptTypeScriptDefaultsConfigured = false;
configureJavaScriptTypeScriptDefaults();
+ensureVueLanguageRegistered();
interface MonacoTypeScriptLanguage {
JsxEmit: {
@@ -184,6 +186,7 @@ export const MonacoHost: FC = ({
minimap: { enabled: false },
readOnly,
scrollBeyondLastLine: false,
+ "semanticHighlighting.enabled": true,
padding: { top: 12, bottom: 12 },
automaticLayout: true,
});
@@ -434,6 +437,7 @@ function detectEditorLanguage(filePath: string): string {
py: "python",
go: "go",
rs: "rust",
+ vue: "vue",
java: "java",
cpp: "cpp",
c: "c",
diff --git a/packages/web/src/features/code-editor/index.test.tsx b/packages/web/src/features/code-editor/index.test.tsx
index 9b1181b7..090a596d 100644
--- a/packages/web/src/features/code-editor/index.test.tsx
+++ b/packages/web/src/features/code-editor/index.test.tsx
@@ -160,6 +160,14 @@ function createDeferred() {
return { promise, resolve, reject };
}
+function pressSaveShortcut() {
+ fireEvent.keyDown(window, {
+ key: "s",
+ code: "KeyS",
+ ctrlKey: true,
+ });
+}
+
describe("CodeEditorHost", () => {
afterEach(() => {
vi.restoreAllMocks();
@@ -288,10 +296,15 @@ describe("CodeEditorHost", () => {
baseHash: string;
encoding: "utf-8";
}>();
- const sendCommand = vi
- .fn()
- .mockImplementationOnce(() => firstRead.promise)
- .mockImplementationOnce(() => secondRead.promise);
+ let fileReadCount = 0;
+ const sendCommand = vi.fn().mockImplementation(async (op: string) => {
+ if (op === "file.read") {
+ fileReadCount += 1;
+ return fileReadCount === 1 ? firstRead.promise : secondRead.promise;
+ }
+
+ return null;
+ });
const { store } = setupStore({ activePath: "src/foo.ts", sendCommand });
render(
@@ -320,15 +333,16 @@ describe("CodeEditorHost", () => {
});
await waitFor(() => {
- expect(sendCommand).toHaveBeenNthCalledWith(
- 2,
+ const fileReadCalls = sendCommand.mock.calls.filter(([op]) => op === "file.read");
+ expect(fileReadCalls).toHaveLength(2);
+ expect(fileReadCalls[1]).toEqual([
"file.read",
{
workspaceId: "ws-1",
path: "src/foo.ts",
},
- undefined
- );
+ undefined,
+ ]);
});
await act(async () => {
@@ -452,7 +466,7 @@ describe("CodeEditorHost", () => {
);
});
- const heading = screen.getByRole("heading", { level: 2, name: "Open Editors (1)" });
+ const heading = screen.getByRole("heading", { level: 2, name: "Open Files (1)" });
const section = heading.closest("section") as HTMLElement;
const closeAll = within(section).getByRole("button", { name: "Close all" });
expect(closeAll).toBeEnabled();
@@ -781,10 +795,12 @@ describe("CodeEditorHost", () => {
});
store.set(editorModeAtomFamily("ws-1"), "diff");
store.set(gitDiffPreviewAtomFamily("ws-1"), {
+ kind: "worktree-file-diff",
path: "src/unrelated.ts",
diff: "diff --git a/src/unrelated.ts b/src/unrelated.ts",
+ renderAs: "text",
+ status: "modified",
staged: false,
- source: "file",
});
render(
@@ -826,10 +842,12 @@ describe("CodeEditorHost", () => {
});
store.set(editorModeAtomFamily("ws-1"), "diff");
store.set(gitDiffPreviewAtomFamily("ws-1"), {
+ kind: "worktree-file-diff",
path: "src/final.ts",
diff: "diff --git a/src/final.ts b/src/final.ts",
+ renderAs: "text",
+ status: "modified",
staged: false,
- source: "file",
});
render(
@@ -908,13 +926,7 @@ describe("CodeEditorHost", () => {
expect(screen.queryByTestId("monaco-host")).not.toBeInTheDocument();
// Save button must be disabled for images (nothing to write back).
- const saveBtn = screen.getByRole("button", { name: "Save File" });
- expect(saveBtn).toBeDisabled();
- expect(saveBtn).not.toHaveAttribute("title");
-
- fireEvent.mouseEnter(saveBtn);
- fireEvent.focus(saveBtn);
- expect(screen.queryByRole("tooltip")).toBeNull();
+ expect(screen.queryByRole("button", { name: "Save File" })).not.toBeInTheDocument();
});
it("defaults text files into edit mode and shows the text editor", async () => {
@@ -1063,10 +1075,12 @@ describe("CodeEditorHost", () => {
},
});
store.set(gitDiffPreviewAtomFamily("ws-1"), {
+ kind: "worktree-file-diff",
path: "src/dirty.ts",
diff: "diff --git a/src/dirty.ts b/src/dirty.ts",
+ renderAs: "text",
+ status: "modified",
staged: false,
- source: "file",
});
render(
@@ -1077,20 +1091,98 @@ describe("CodeEditorHost", () => {
expect(store.get(editorModeAtomFamily("ws-1"))).toBe("edit");
expect(store.get(gitDiffPreviewAtomFamily("ws-1"))).toEqual({
+ kind: "worktree-file-diff",
path: "src/dirty.ts",
diff: "diff --git a/src/dirty.ts b/src/dirty.ts",
+ renderAs: "text",
+ status: "modified",
staged: false,
- source: "file",
+ });
+ });
+
+ it("keeps search replace diff previews as active diff state for the matching file", async () => {
+ const { store } = setupStore({
+ activePath: "src/dirty.ts",
+ openFiles: {
+ "src/dirty.ts": {
+ kind: "text",
+ path: "src/dirty.ts",
+ content: "changed",
+ savedContent: "original",
+ baseHash: "dirty-hash",
+ isDirty: false,
+ },
+ },
+ });
+ store.set(editorModeAtomFamily("ws-1"), "diff");
+ store.set(gitDiffPreviewAtomFamily("ws-1"), {
+ kind: "search-replace-file-diff",
+ path: "src/dirty.ts",
+ title: "src/dirty.ts",
+ sessionId: "session-1",
+ baseHash: "dirty-hash",
+ originalContent: "original",
+ modifiedContent: "changed",
+ });
+
+ const { result } = renderHook(() => useCodeEditorActions(), {
+ wrapper: wrapperFor(store),
+ });
+
+ expect(result.current.mode).toBe("diff");
+ expect(result.current.activeDiffChange).toEqual({
+ kind: "search-replace-file-diff",
+ path: "src/dirty.ts",
+ title: "src/dirty.ts",
+ sessionId: "session-1",
+ baseHash: "dirty-hash",
+ originalContent: "original",
+ modifiedContent: "changed",
});
});
it("renders commit diff preview in the mobile content-only editor surface without an active file", () => {
const { store } = setupStore();
store.set(gitDiffPreviewAtomFamily("ws-1"), {
- path: "abc123",
- title: "abc123 · commit subject",
+ kind: "commit-file-diff",
+ path: "src/app.tsx",
+ title: "src/app.tsx",
diff: "diff --git a/src/app.tsx b/src/app.tsx",
- source: "commit",
+ renderAs: "text",
+ status: "modified",
+ originalContent: "const app = 0;",
+ modifiedContent: "const app = 1;",
+ commit: {
+ sha: "abc123",
+ shortSha: "abc123",
+ subject: "commit subject",
+ authorName: "Spencer",
+ authoredAt: 1,
+ },
+ file: {
+ path: "src/app.tsx",
+ status: "modified",
+ renderAs: "text",
+ },
+ parentList: {
+ kind: "commit-file-list",
+ path: "abc123",
+ title: "abc123 · commit subject",
+ commit: {
+ sha: "abc123",
+ shortSha: "abc123",
+ subject: "commit subject",
+ authorName: "Spencer",
+ authoredAt: 1,
+ },
+ files: [
+ {
+ path: "src/app.tsx",
+ status: "modified",
+ renderAs: "text",
+ },
+ ],
+ },
});
render(
@@ -1099,9 +1191,13 @@ describe("CodeEditorHost", () => {
);
+ expect(screen.getByTestId("monaco-diff-host")).toHaveAttribute(
+ "data-original",
+ "const app = 0;"
+ );
expect(screen.getByTestId("monaco-diff-host")).toHaveAttribute(
"data-modified",
- "diff --git a/src/app.tsx b/src/app.tsx"
+ "const app = 1;"
);
expect(screen.queryByRole("button", { name: "Close" })).not.toBeInTheDocument();
});
@@ -1129,10 +1225,45 @@ describe("CodeEditorHost", () => {
},
});
store.set(gitDiffPreviewAtomFamily("ws-1"), {
- path: "abc123",
- title: "abc123 · commit subject",
+ kind: "commit-file-diff",
+ path: "src/app.tsx",
+ title: "src/app.tsx",
diff: "diff --git a/src/app.tsx b/src/app.tsx",
- source: "commit",
+ renderAs: "text",
+ status: "modified",
+ originalContent: "const app = 0;",
+ modifiedContent: "const app = 1;",
+ commit: {
+ sha: "abc123",
+ shortSha: "abc123",
+ subject: "commit subject",
+ authorName: "Spencer",
+ authoredAt: 1,
+ },
+ file: {
+ path: "src/app.tsx",
+ status: "modified",
+ renderAs: "text",
+ },
+ parentList: {
+ kind: "commit-file-list",
+ path: "abc123",
+ title: "abc123 · commit subject",
+ commit: {
+ sha: "abc123",
+ shortSha: "abc123",
+ subject: "commit subject",
+ authorName: "Spencer",
+ authoredAt: 1,
+ },
+ files: [
+ {
+ path: "src/app.tsx",
+ status: "modified",
+ renderAs: "text",
+ },
+ ],
+ },
});
render(
@@ -1163,7 +1294,7 @@ describe("CodeEditorHost", () => {
});
});
- it("closing a commit-history preview restores the background file to its normal mode", async () => {
+ it("closing a commit file diff returns to its parent commit file list before restoring the background file", async () => {
const { store } = setupStore({
activePath: "src/background.ts",
openFiles: {
@@ -1192,10 +1323,45 @@ describe("CodeEditorHost", () => {
act(() => {
store.set(editorModeAtomFamily("ws-1"), "diff");
store.set(gitDiffPreviewAtomFamily("ws-1"), {
- path: "abc123",
- title: "abc123 · commit subject",
+ kind: "commit-file-diff",
+ path: "src/app.tsx",
+ title: "src/app.tsx",
diff: "diff --git a/src/app.tsx b/src/app.tsx",
- source: "commit",
+ renderAs: "text",
+ status: "modified",
+ originalContent: "const app = 0;",
+ modifiedContent: "const app = 1;",
+ commit: {
+ sha: "abc123",
+ shortSha: "abc123",
+ subject: "commit subject",
+ authorName: "Spencer",
+ authoredAt: 1,
+ },
+ file: {
+ path: "src/app.tsx",
+ status: "modified",
+ renderAs: "text",
+ },
+ parentList: {
+ kind: "commit-file-list",
+ path: "abc123",
+ title: "abc123 · commit subject",
+ commit: {
+ sha: "abc123",
+ shortSha: "abc123",
+ subject: "commit subject",
+ authorName: "Spencer",
+ authoredAt: 1,
+ },
+ files: [
+ {
+ path: "src/app.tsx",
+ status: "modified",
+ renderAs: "text",
+ },
+ ],
+ },
});
});
@@ -1205,6 +1371,44 @@ describe("CodeEditorHost", () => {
fireEvent.click(screen.getByRole("button", { name: "Close" }));
+ await waitFor(() => {
+ expect(store.get(gitDiffPreviewAtomFamily("ws-1"))).toEqual({
+ kind: "commit-file-list",
+ path: "abc123",
+ title: "abc123 · commit subject",
+ commit: {
+ sha: "abc123",
+ shortSha: "abc123",
+ subject: "commit subject",
+ authorName: "Spencer",
+ authoredAt: 1,
+ },
+ files: [
+ {
+ path: "src/app.tsx",
+ status: "modified",
+ renderAs: "text",
+ },
+ ],
+ });
+ });
+
+ const sendCommand = (
+ store.get(wsClientAtom) as unknown as {
+ sendCommand: ReturnType;
+ }
+ ).sendCommand;
+ expect(sendCommand).not.toHaveBeenCalledWith(
+ "git.commitDetail",
+ expect.objectContaining({
+ workspaceId: "ws-1",
+ sha: "abc123",
+ }),
+ undefined
+ );
+
+ fireEvent.click(screen.getByRole("button", { name: "Close" }));
+
await waitFor(() => {
expect(store.get(gitDiffPreviewAtomFamily("ws-1"))).toBeNull();
expect(store.get(editorModeAtomFamily("ws-1"))).toBe("edit");
@@ -1213,6 +1417,206 @@ describe("CodeEditorHost", () => {
});
});
+ it("ignores a stale commit file diff response after the user switches to another commit list", async () => {
+ const diffDeferred = createDeferred<{
+ diff: string;
+ renderAs: "text";
+ status: "modified";
+ originalContent: string;
+ modifiedContent: string;
+ }>();
+ const sendCommand = vi.fn().mockImplementation(async (op: string) => {
+ if (op === "git.commitFileDiff") {
+ return diffDeferred.promise;
+ }
+
+ if (op === "file.read") {
+ return {
+ kind: "text",
+ content: "hello world",
+ baseHash: "abc123",
+ encoding: "utf-8",
+ };
+ }
+
+ return null;
+ });
+ const { store } = setupStore({ sendCommand });
+
+ const parentListA = {
+ kind: "commit-file-list" as const,
+ path: "abc123",
+ title: "abc123 · commit subject",
+ commit: {
+ sha: "abc123",
+ shortSha: "abc123",
+ subject: "commit subject",
+ authorName: "Spencer",
+ authoredAt: 1,
+ },
+ files: [
+ {
+ path: "src/app.tsx",
+ status: "modified" as const,
+ renderAs: "text" as const,
+ },
+ ],
+ };
+ const parentListB = {
+ kind: "commit-file-list" as const,
+ path: "def456",
+ title: "def456 · other commit",
+ commit: {
+ sha: "def456",
+ shortSha: "def456",
+ subject: "other commit",
+ authorName: "Spencer",
+ authoredAt: 2,
+ },
+ files: [
+ {
+ path: "src/other.tsx",
+ status: "modified" as const,
+ renderAs: "text" as const,
+ },
+ ],
+ };
+
+ act(() => {
+ store.set(gitDiffPreviewAtomFamily("ws-1"), parentListA);
+ });
+
+ const { result } = renderHook(() => useCodeEditorActions(), {
+ wrapper: wrapperFor(store),
+ });
+
+ const openPromise = result.current.openCommitFileDiff(parentListA.files[0]!);
+
+ await waitFor(() => {
+ expect(sendCommand).toHaveBeenCalledWith(
+ "git.commitFileDiff",
+ {
+ workspaceId: "ws-1",
+ sha: "abc123",
+ path: "src/app.tsx",
+ },
+ undefined
+ );
+ });
+
+ act(() => {
+ store.set(gitDiffPreviewAtomFamily("ws-1"), parentListB);
+ });
+
+ let applied = true;
+ await act(async () => {
+ diffDeferred.resolve({
+ diff: "diff --git a/src/app.tsx b/src/app.tsx",
+ renderAs: "text",
+ status: "modified",
+ originalContent: "const app = 0;\n",
+ modifiedContent: "const app = 1;\n",
+ });
+ applied = await openPromise;
+ });
+
+ expect(applied).toBe(false);
+ expect(store.get(gitDiffPreviewAtomFamily("ws-1"))).toEqual(parentListB);
+ });
+
+ it("ignores a stale commit file diff response after the same commit list is reopened", async () => {
+ const diffDeferred = createDeferred<{
+ diff: string;
+ renderAs: "text";
+ status: "modified";
+ originalContent: string;
+ modifiedContent: string;
+ }>();
+ const sendCommand = vi.fn().mockImplementation(async (op: string) => {
+ if (op === "git.commitFileDiff") {
+ return diffDeferred.promise;
+ }
+
+ if (op === "file.read") {
+ return {
+ kind: "text",
+ content: "hello world",
+ baseHash: "abc123",
+ encoding: "utf-8",
+ };
+ }
+
+ return null;
+ });
+ const { store } = setupStore({ sendCommand });
+
+ const parentListA = {
+ kind: "commit-file-list" as const,
+ path: "abc123",
+ title: "abc123 · commit subject",
+ commit: {
+ sha: "abc123",
+ shortSha: "abc123",
+ subject: "commit subject",
+ authorName: "Spencer",
+ authoredAt: 1,
+ },
+ files: [
+ {
+ path: "src/app.tsx",
+ status: "modified" as const,
+ renderAs: "text" as const,
+ },
+ ],
+ };
+ const reopenedParentList = {
+ ...parentListA,
+ files: [...parentListA.files],
+ };
+
+ act(() => {
+ store.set(gitDiffPreviewAtomFamily("ws-1"), parentListA);
+ });
+
+ const { result } = renderHook(() => useCodeEditorActions(), {
+ wrapper: wrapperFor(store),
+ });
+
+ const openPromise = result.current.openCommitFileDiff(parentListA.files[0]!);
+
+ await waitFor(() => {
+ expect(sendCommand).toHaveBeenCalledWith(
+ "git.commitFileDiff",
+ {
+ workspaceId: "ws-1",
+ sha: "abc123",
+ path: "src/app.tsx",
+ },
+ undefined
+ );
+ });
+
+ act(() => {
+ store.set(gitDiffPreviewAtomFamily("ws-1"), null);
+ store.set(gitDiffPreviewAtomFamily("ws-1"), reopenedParentList);
+ });
+
+ let applied = true;
+ await act(async () => {
+ diffDeferred.resolve({
+ diff: "diff --git a/src/app.tsx b/src/app.tsx",
+ renderAs: "text",
+ status: "modified",
+ originalContent: "const app = 0;\n",
+ modifiedContent: "const app = 1;\n",
+ });
+ applied = await openPromise;
+ });
+
+ expect(applied).toBe(false);
+ expect(store.get(gitDiffPreviewAtomFamily("ws-1"))).toEqual(reopenedParentList);
+ });
+
it("closing a commit-history preview restores the background file save error", async () => {
const sendCommand = vi.fn().mockImplementation(async (op: string, args?: { path?: string }) => {
if (op === "file.write" && args?.path === "src/background.ts") {
@@ -1251,21 +1655,34 @@ describe("CodeEditorHost", () => {
);
- fireEvent.click(screen.getByRole("button", { name: "Save File" }));
+ pressSaveShortcut();
expect(await screen.findByRole("alert")).toHaveTextContent("Save failed on background");
act(() => {
store.set(gitDiffPreviewAtomFamily("ws-1"), {
+ kind: "commit-file-list",
path: "abc123",
title: "abc123 · commit subject",
- diff: "diff --git a/src/app.tsx b/src/app.tsx",
- source: "commit",
+ commit: {
+ sha: "abc123",
+ shortSha: "abc123",
+ subject: "commit subject",
+ authorName: "Spencer",
+ authoredAt: 1,
+ },
+ files: [
+ {
+ path: "src/app.tsx",
+ status: "modified",
+ renderAs: "text",
+ },
+ ],
});
});
await waitFor(() => {
- expect(screen.getByTestId("monaco-diff-host")).toBeInTheDocument();
+ expect(screen.getByTestId("commit-file-list-preview")).toBeInTheDocument();
expect(screen.queryByText("Save failed on background")).not.toBeInTheDocument();
});
@@ -1278,6 +1695,151 @@ describe("CodeEditorHost", () => {
});
});
+ it("openLocation normalizes editor mode when exiting a commit file list preview over a file-diff background", async () => {
+ const { store } = setupStore({
+ activePath: "src/background.ts",
+ openFiles: {
+ "src/background.ts": {
+ kind: "text",
+ path: "src/background.ts",
+ content: "background",
+ savedContent: "background",
+ baseHash: "hash-bg",
+ isDirty: false,
+ },
+ },
+ });
+
+ render(
+
+
+
+ );
+
+ await waitFor(() => {
+ expect(store.get(editorModeAtomFamily("ws-1"))).toBe("edit");
+ expect(screen.getByTestId("monaco-host")).toHaveTextContent("background");
+ });
+
+ act(() => {
+ store.set(editorModeAtomFamily("ws-1"), "diff");
+ store.set(gitDiffPreviewAtomFamily("ws-1"), {
+ kind: "commit-file-list",
+ path: "abc123",
+ title: "abc123 · commit subject",
+ commit: {
+ sha: "abc123",
+ shortSha: "abc123",
+ subject: "commit subject",
+ authorName: "Spencer",
+ authoredAt: 1,
+ },
+ files: [
+ {
+ path: "src/app.tsx",
+ status: "modified",
+ renderAs: "text",
+ },
+ ],
+ });
+ });
+
+ await waitFor(() => {
+ expect(screen.getByTestId("commit-file-list-preview")).toBeInTheDocument();
+ expect(store.get(editorModeAtomFamily("ws-1"))).toBe("diff");
+ });
+
+ const { result } = renderHook(() => useOpenLocation("ws-1"), {
+ wrapper: wrapperFor(store),
+ });
+
+ await act(async () => {
+ await result.current.openLocation({
+ workspaceId: "ws-1",
+ path: "src/background.ts",
+ source: "manual",
+ });
+ });
+
+ await waitFor(() => {
+ expect(store.get(gitDiffPreviewAtomFamily("ws-1"))).toBeNull();
+ expect(store.get(activeFilePathAtomFamily("ws-1"))).toBe("src/background.ts");
+ expect(store.get(editorModeAtomFamily("ws-1"))).toBe("edit");
+ expect(screen.getByTestId("monaco-host")).toHaveTextContent("background");
+ expect(screen.queryByTestId("commit-file-list-preview")).not.toBeInTheDocument();
+ });
+ });
+
+ it("shows the commit file list preview while a background save error remains hidden", async () => {
+ const sendCommand = vi.fn().mockImplementation(async (op: string, args?: { path?: string }) => {
+ if (op === "file.write" && args?.path === "src/background.ts") {
+ throw new Error("Save failed on background");
+ }
+
+ if (op === "file.read") {
+ return {
+ kind: "text",
+ content: "hello world",
+ baseHash: "abc123",
+ encoding: "utf-8",
+ };
+ }
+
+ return null;
+ });
+ const { store } = setupStore({
+ activePath: "src/background.ts",
+ sendCommand,
+ openFiles: {
+ "src/background.ts": {
+ kind: "text",
+ path: "src/background.ts",
+ content: "changed background",
+ savedContent: "saved background",
+ baseHash: "hash-bg",
+ isDirty: true,
+ },
+ },
+ });
+
+ render(
+
+
+
+ );
+
+ pressSaveShortcut();
+
+ expect(await screen.findByRole("alert")).toHaveTextContent("Save failed on background");
+
+ act(() => {
+ store.set(gitDiffPreviewAtomFamily("ws-1"), {
+ kind: "commit-file-list",
+ path: "abc123",
+ title: "abc123 · commit subject",
+ commit: {
+ sha: "abc123",
+ shortSha: "abc123",
+ subject: "commit subject",
+ authorName: "Spencer",
+ authoredAt: 1,
+ },
+ files: [
+ {
+ path: "src/app.tsx",
+ status: "modified",
+ renderAs: "text",
+ },
+ ],
+ });
+ });
+
+ await waitFor(() => {
+ expect(screen.getByTestId("commit-file-list-preview")).toBeInTheDocument();
+ expect(screen.queryByText("Save failed on background")).not.toBeInTheDocument();
+ });
+ });
+
it("openLocation normalizes editor mode when exiting a commit-history preview over a file-diff background", async () => {
const { store } = setupStore({
activePath: "src/background.ts",
@@ -1307,15 +1869,28 @@ describe("CodeEditorHost", () => {
act(() => {
store.set(editorModeAtomFamily("ws-1"), "diff");
store.set(gitDiffPreviewAtomFamily("ws-1"), {
+ kind: "commit-file-list",
path: "abc123",
title: "abc123 · commit subject",
- diff: "diff --git a/src/app.tsx b/src/app.tsx",
- source: "commit",
+ commit: {
+ sha: "abc123",
+ shortSha: "abc123",
+ subject: "commit subject",
+ authorName: "Spencer",
+ authoredAt: 1,
+ },
+ files: [
+ {
+ path: "src/app.tsx",
+ status: "modified",
+ renderAs: "text",
+ },
+ ],
});
});
await waitFor(() => {
- expect(screen.getByTestId("monaco-diff-host")).toBeInTheDocument();
+ expect(screen.getByTestId("commit-file-list-preview")).toBeInTheDocument();
expect(store.get(editorModeAtomFamily("ws-1"))).toBe("diff");
});
@@ -1336,7 +1911,7 @@ describe("CodeEditorHost", () => {
expect(store.get(activeFilePathAtomFamily("ws-1"))).toBe("src/background.ts");
expect(store.get(editorModeAtomFamily("ws-1"))).toBe("edit");
expect(screen.getByTestId("monaco-host")).toHaveTextContent("background");
- expect(screen.queryByTestId("monaco-diff-host")).not.toBeInTheDocument();
+ expect(screen.queryByTestId("commit-file-list-preview")).not.toBeInTheDocument();
});
});
@@ -1397,10 +1972,12 @@ describe("CodeEditorHost", () => {
untracked: [],
});
store.set(gitDiffPreviewAtomFamily("ws-1"), {
+ kind: "worktree-file-diff",
path: "src/app.ts",
diff: "diff --git a/src/app.ts b/src/app.ts",
+ renderAs: "text",
+ status: "modified",
staged: false,
- source: "file",
});
const { result } = renderHook(() => useCodeEditorActions(), {
@@ -1435,11 +2012,12 @@ describe("CodeEditorHost", () => {
untracked: [],
});
store.set(gitDiffPreviewAtomFamily("ws-1"), {
+ kind: "worktree-file-diff",
path: "src/app.ts",
diff: "diff --git a/src/app.ts b/src/app.ts",
staged: false,
- source: "file",
renderAs: "text",
+ status: "modified",
originalContent: "export const app = 1;",
modifiedContent: "export const app = 2;",
});
@@ -1455,6 +2033,8 @@ describe("CodeEditorHost", () => {
});
fireEvent.click(screen.getByRole("button", { name: "Close" }));
+ expect(screen.getByRole("dialog", { name: "Discard unsaved changes?" })).toBeInTheDocument();
+ fireEvent.click(screen.getByRole("button", { name: "Discard and Close" }));
await waitFor(() => {
expect(store.get(gitDiffPreviewAtomFamily("ws-1"))).toBeNull();
@@ -1463,7 +2043,7 @@ describe("CodeEditorHost", () => {
});
});
- it("shows the save tooltip on desktop for a text buffer", async () => {
+ it("omits the desktop save button for a text buffer", async () => {
const { store } = setupStore({
activePath: "src/save.ts",
openFiles: {
@@ -1484,11 +2064,7 @@ describe("CodeEditorHost", () => {
);
- const saveBtn = screen.getByRole("button", { name: "Save File" });
- expect(saveBtn).not.toHaveAttribute("title");
-
- fireEvent.mouseEnter(saveBtn);
- expect(screen.getByRole("tooltip")).toHaveTextContent("Save File");
+ expect(screen.queryByRole("button", { name: "Save File" })).not.toBeInTheDocument();
});
it("clears dirty state when text returns to the last saved content", async () => {
@@ -1528,7 +2104,7 @@ describe("CodeEditorHost", () => {
});
});
- expect(screen.getByRole("button", { name: "Save File" })).toBeDisabled();
+ expect(screen.queryByRole("button", { name: "Save File" })).not.toBeInTheDocument();
});
it("reloads a clean text buffer after an external refresh signal changes the file on disk", async () => {
@@ -1624,7 +2200,7 @@ describe("CodeEditorHost", () => {
);
- fireEvent.click(screen.getByRole("button", { name: "Save File" }));
+ pressSaveShortcut();
expect(await screen.findByRole("alert")).toHaveTextContent("Save failed on A");
@@ -1632,6 +2208,8 @@ describe("CodeEditorHost", () => {
.getByRole("button", { name: "src/a.ts" })
.closest(".workspace-open-editors__row") as HTMLElement;
fireEvent.click(within(activeRow).getByRole("button", { name: "Close src/a.ts" }));
+ expect(screen.getByRole("dialog", { name: "Discard unsaved changes?" })).toBeInTheDocument();
+ fireEvent.click(screen.getByRole("button", { name: "Discard and Close" }));
await waitFor(() => {
expect(store.get(activeFilePathAtomFamily("ws-1"))).toBeNull();
@@ -1693,7 +2271,7 @@ describe("CodeEditorHost", () => {
);
- fireEvent.click(screen.getByRole("button", { name: "Save File" }));
+ pressSaveShortcut();
await waitFor(() => {
expect(sendCommand).toHaveBeenCalledWith(
@@ -1717,7 +2295,7 @@ describe("CodeEditorHost", () => {
expect(screen.getByTestId("monaco-host")).toHaveTextContent("changed b");
expect(screen.queryByRole("button", { name: "Saving" })).not.toBeInTheDocument();
- fireEvent.click(screen.getByRole("button", { name: "Save File" }));
+ pressSaveShortcut();
await waitFor(() => {
expect(sendCommand).toHaveBeenCalledWith(
@@ -1737,6 +2315,126 @@ describe("CodeEditorHost", () => {
});
});
+ it("deduplicates repeated save shortcut dispatches while a save is in flight", async () => {
+ const saveDeferred = createDeferred<{ newHash: string }>();
+ const sendCommand = vi.fn().mockImplementation(async (op: string, args?: { path?: string }) => {
+ if (op === "file.write" && args?.path === "src/a.ts") {
+ return saveDeferred.promise;
+ }
+
+ if (op === "file.read") {
+ return {
+ kind: "text",
+ content: "hello world",
+ baseHash: "abc123",
+ encoding: "utf-8",
+ };
+ }
+
+ return null;
+ });
+ const { store } = setupStore({
+ activePath: "src/a.ts",
+ sendCommand,
+ openFiles: {
+ "src/a.ts": {
+ kind: "text",
+ path: "src/a.ts",
+ content: "changed a",
+ savedContent: "saved a",
+ baseHash: "hash-a",
+ isDirty: true,
+ },
+ },
+ });
+
+ render(
+
+
+
+ );
+
+ pressSaveShortcut();
+ pressSaveShortcut();
+
+ await waitFor(() => {
+ expect(sendCommand).toHaveBeenCalledWith(
+ "file.write",
+ {
+ workspaceId: "ws-1",
+ path: "src/a.ts",
+ content: "changed a",
+ baseHash: "hash-a",
+ },
+ undefined
+ );
+ });
+ expect(sendCommand.mock.calls.filter(([op]) => op === "file.write")).toHaveLength(1);
+
+ await act(async () => {
+ saveDeferred.resolve({ newHash: "hash-a-2" });
+ });
+ });
+
+ it("deduplicates overlapping save requests before saving state rerenders", async () => {
+ const saveDeferred = createDeferred<{ newHash: string }>();
+ const sendCommand = vi.fn().mockImplementation(async (op: string, args?: { path?: string }) => {
+ if (op === "file.write" && args?.path === "src/a.ts") {
+ return saveDeferred.promise;
+ }
+
+ if (op === "file.read") {
+ return {
+ kind: "text",
+ content: "hello world",
+ baseHash: "abc123",
+ encoding: "utf-8",
+ };
+ }
+
+ return null;
+ });
+ const { store } = setupStore({
+ activePath: "src/a.ts",
+ sendCommand,
+ openFiles: {
+ "src/a.ts": {
+ kind: "text",
+ path: "src/a.ts",
+ content: "changed a",
+ savedContent: "saved a",
+ baseHash: "hash-a",
+ isDirty: true,
+ },
+ },
+ });
+
+ const { result } = renderHook(() => useCodeEditorActions(), {
+ wrapper: wrapperFor(store),
+ });
+
+ void result.current.handleSave();
+ void result.current.handleSave();
+
+ await waitFor(() => {
+ expect(sendCommand).toHaveBeenCalledWith(
+ "file.write",
+ {
+ workspaceId: "ws-1",
+ path: "src/a.ts",
+ content: "changed a",
+ baseHash: "hash-a",
+ },
+ undefined
+ );
+ });
+ expect(sendCommand.mock.calls.filter(([op]) => op === "file.write")).toHaveLength(1);
+
+ await act(async () => {
+ saveDeferred.resolve({ newHash: "hash-a-2" });
+ });
+ });
+
it("ignores a stale save success after close all preserves commit preview and the file is reopened", async () => {
const staleSave = createDeferred<{ newHash: string }>();
const sendCommand = vi.fn().mockImplementation(async (op: string, args?: { path?: string }) => {
@@ -1781,7 +2479,7 @@ describe("CodeEditorHost", () => {
wrapper: wrapperFor(store),
});
- fireEvent.click(screen.getByRole("button", { name: "Save File" }));
+ pressSaveShortcut();
await waitFor(() => {
expect(sendCommand).toHaveBeenCalledWith(
@@ -1798,26 +2496,54 @@ describe("CodeEditorHost", () => {
act(() => {
store.set(gitDiffPreviewAtomFamily("ws-1"), {
+ kind: "commit-file-list",
path: "abc123",
title: "abc123 · commit subject",
- diff: "diff --git a/src/app.tsx b/src/app.tsx",
- source: "commit",
+ commit: {
+ sha: "abc123",
+ shortSha: "abc123",
+ subject: "commit subject",
+ authorName: "Spencer",
+ authoredAt: 1,
+ },
+ files: [
+ {
+ path: "src/app.tsx",
+ status: "modified",
+ renderAs: "text",
+ },
+ ],
});
});
await waitFor(() => {
- expect(screen.getByTestId("monaco-diff-host")).toBeInTheDocument();
+ expect(screen.getByTestId("commit-file-list-preview")).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: "Close all" }));
+ expect(screen.getByRole("dialog", { name: "Discard unsaved changes?" })).toBeInTheDocument();
+ fireEvent.click(screen.getByRole("button", { name: "Discard and Close" }));
expect(store.get(activeFilePathAtomFamily("ws-1"))).toBeNull();
expect(store.get(openFilesAtomFamily("ws-1"))).toEqual({});
expect(store.get(gitDiffPreviewAtomFamily("ws-1"))).toEqual({
+ kind: "commit-file-list",
path: "abc123",
title: "abc123 · commit subject",
- diff: "diff --git a/src/app.tsx b/src/app.tsx",
- source: "commit",
+ commit: {
+ sha: "abc123",
+ shortSha: "abc123",
+ subject: "commit subject",
+ authorName: "Spencer",
+ authoredAt: 1,
+ },
+ files: [
+ {
+ path: "src/app.tsx",
+ status: "modified",
+ renderAs: "text",
+ },
+ ],
});
await act(async () => {
@@ -1898,7 +2624,7 @@ describe("CodeEditorHost", () => {
wrapper: wrapperFor(store),
});
- fireEvent.click(screen.getByRole("button", { name: "Save File" }));
+ pressSaveShortcut();
await waitFor(() => {
expect(sendCommand).toHaveBeenCalledWith(
@@ -1915,18 +2641,33 @@ describe("CodeEditorHost", () => {
act(() => {
store.set(gitDiffPreviewAtomFamily("ws-1"), {
+ kind: "commit-file-list",
path: "abc123",
title: "abc123 · commit subject",
- diff: "diff --git a/src/app.tsx b/src/app.tsx",
- source: "commit",
+ commit: {
+ sha: "abc123",
+ shortSha: "abc123",
+ subject: "commit subject",
+ authorName: "Spencer",
+ authoredAt: 1,
+ },
+ files: [
+ {
+ path: "src/app.tsx",
+ status: "modified",
+ renderAs: "text",
+ },
+ ],
});
});
await waitFor(() => {
- expect(screen.getByTestId("monaco-diff-host")).toBeInTheDocument();
+ expect(screen.getByTestId("commit-file-list-preview")).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: "Close all" }));
+ expect(screen.getByRole("dialog", { name: "Discard unsaved changes?" })).toBeInTheDocument();
+ fireEvent.click(screen.getByRole("button", { name: "Discard and Close" }));
await act(async () => {
await result.current.openLocation({
diff --git a/packages/web/src/features/code-editor/lsp/bridge.test.tsx b/packages/web/src/features/code-editor/lsp/bridge.test.tsx
index 4da3fb5a..403766c3 100644
--- a/packages/web/src/features/code-editor/lsp/bridge.test.tsx
+++ b/packages/web/src/features/code-editor/lsp/bridge.test.tsx
@@ -39,6 +39,7 @@ vi.mock("monaco-editor", () => ({
registerHoverProvider: vi.fn(),
registerReferenceProvider: vi.fn(),
registerDocumentSymbolProvider: vi.fn(),
+ registerDocumentSemanticTokensProvider: vi.fn(),
SymbolKind: {
Variable: 13,
},
@@ -210,6 +211,55 @@ describe("createLspBridge", () => {
});
});
+ it("opens vue documents through the lazy lsp bridge using the vue language id", async () => {
+ const sendCommand = vi
+ .fn()
+ .mockResolvedValueOnce({
+ kind: "ready",
+ displayName: "Vue language server",
+ source: "managed",
+ summary: {
+ workspaceId: "ws-1",
+ serverKind: "vue",
+ status: "ready",
+ capabilities: {
+ definition: true,
+ references: true,
+ hover: true,
+ documentSymbols: true,
+ diagnostics: true,
+ },
+ },
+ })
+ .mockResolvedValue(undefined);
+
+ const bridge = createLspBridge({
+ sendCommand: sendCommand as BridgeSendCommand,
+ subscribe: vi.fn(() => () => {}),
+ });
+
+ bridge.attachModel({
+ workspaceId: "ws-1",
+ workspaceRootPath: "/repo",
+ path: "src/App.vue",
+ monacoLanguage: "vue",
+ model: createMockModel(
+ "
\n",
+ 1,
+ monaco.Uri.file("/repo/src/App.vue")
+ ),
+ });
+
+ await vi.waitFor(() => {
+ expect(sendCommand).toHaveBeenCalledWith("lsp.openDocument", {
+ workspaceId: "ws-1",
+ path: "src/App.vue",
+ languageId: "vue",
+ text: "
\n",
+ });
+ });
+ });
+
it("does not open a document when ensureSession returns disabled", async () => {
const sendCommand = vi.fn().mockResolvedValueOnce({
kind: "disabled",
diff --git a/packages/web/src/features/code-editor/lsp/bridge.ts b/packages/web/src/features/code-editor/lsp/bridge.ts
index 44a41a5e..ff150831 100644
--- a/packages/web/src/features/code-editor/lsp/bridge.ts
+++ b/packages/web/src/features/code-editor/lsp/bridge.ts
@@ -4,6 +4,7 @@ import type {
LspEnsureSessionResult,
LspHoverResult,
LspLocation,
+ LspSemanticTokens,
LspToolInstallJobSnapshot,
} from "@coder-studio/core";
import { Topics } from "@coder-studio/core";
@@ -49,15 +50,19 @@ const noopTransport: LspBridgeTransport = {
subscribe: () => () => {},
};
-type MissingOrFailedReadiness = Exclude<
+type InstallableReadiness = Extract<
LspEnsureSessionResult,
- { kind: "ready" | "unsupported_language" }
+ { kind: "tool_missing" | "installing" | "failed" }
>;
-function isMissingOrFailedReadiness(
+function isInstallableReadiness(
readiness: LspEnsureSessionResult
-): readiness is MissingOrFailedReadiness {
- return readiness.kind !== "ready" && readiness.kind !== "unsupported_language";
+): readiness is InstallableReadiness {
+ return (
+ readiness.kind === "tool_missing" ||
+ readiness.kind === "installing" ||
+ readiness.kind === "failed"
+ );
}
export function createLspBridge(initialTransport: Partial = {}) {
@@ -123,6 +128,11 @@ export function createLspBridge(initialTransport: Partial =
workspaceId: meta.workspaceId,
path: meta.path,
}),
+ requestSemanticTokens: async ({ meta }) =>
+ await transport.sendCommand("lsp.semanticTokens", {
+ workspaceId: meta.workspaceId,
+ path: meta.path,
+ }),
});
function configure(nextTransport: Partial): void {
@@ -173,7 +183,7 @@ export function createLspBridge(initialTransport: Partial =
if (readiness.kind !== "ready") {
onStateChange?.(readiness);
- if (isMissingOrFailedReadiness(readiness) && readiness.installJob) {
+ if (isInstallableReadiness(readiness) && readiness.installJob) {
currentJobId = readiness.installJob.jobId;
schedulePoll();
}
@@ -360,6 +370,7 @@ export function createLspBridge(initialTransport: Partial =
provideHover: providers.provideHover,
provideReferences: providers.provideReferences,
provideDocumentSymbols: providers.provideDocumentSymbols,
+ provideDocumentSemanticTokens: providers.provideDocumentSemanticTokens,
};
}
diff --git a/packages/web/src/features/code-editor/lsp/language-map.test.ts b/packages/web/src/features/code-editor/lsp/language-map.test.ts
new file mode 100644
index 00000000..46663821
--- /dev/null
+++ b/packages/web/src/features/code-editor/lsp/language-map.test.ts
@@ -0,0 +1,8 @@
+import { describe, expect, it } from "vitest";
+import { resolveLspServerKind } from "./language-map";
+
+describe("resolveLspServerKind", () => {
+ it("prefers the vue server kind for vue files even when Monaco reports typescript", () => {
+ expect(resolveLspServerKind("src/App.vue", "typescript")).toBe("vue");
+ });
+});
diff --git a/packages/web/src/features/code-editor/lsp/language-map.ts b/packages/web/src/features/code-editor/lsp/language-map.ts
index 2e9e7c8c..8645e021 100644
--- a/packages/web/src/features/code-editor/lsp/language-map.ts
+++ b/packages/web/src/features/code-editor/lsp/language-map.ts
@@ -4,6 +4,7 @@ const TYPESCRIPT_EXTENSIONS = new Set(["ts", "tsx", "js", "jsx", "mts", "cts", "
const PYTHON_EXTENSIONS = new Set(["py"]);
const GO_EXTENSIONS = new Set(["go"]);
const RUST_EXTENSIONS = new Set(["rs"]);
+const VUE_EXTENSIONS = new Set(["vue"]);
export function resolveLspServerKind(
filePath: string,
@@ -11,6 +12,9 @@ export function resolveLspServerKind(
): LspServerKind | null {
const extension = filePath.split(".").pop()?.toLowerCase() ?? "";
+ if (VUE_EXTENSIONS.has(extension) || monacoLanguage === "vue") {
+ return "vue";
+ }
if (TYPESCRIPT_EXTENSIONS.has(extension) || monacoLanguage === "typescript") {
return "typescript";
}
diff --git a/packages/web/src/features/code-editor/lsp/providers.test.ts b/packages/web/src/features/code-editor/lsp/providers.test.ts
index f39674f6..32ef4e61 100644
--- a/packages/web/src/features/code-editor/lsp/providers.test.ts
+++ b/packages/web/src/features/code-editor/lsp/providers.test.ts
@@ -27,6 +27,7 @@ vi.mock("monaco-editor", () => ({
registerHoverProvider: vi.fn(),
registerReferenceProvider: vi.fn(),
registerDocumentSymbolProvider: vi.fn(),
+ registerDocumentSemanticTokensProvider: vi.fn(),
registerLinkProvider: vi.fn(),
SymbolKind: {
Variable: 13,
@@ -72,6 +73,34 @@ function createMockModel(
}
describe("LSP providers", () => {
+ it("registers Monaco providers for vue files on the vue language", () => {
+ const bridge = createLspBridge({
+ sendCommand: vi.fn() as BridgeSendCommand,
+ subscribe: vi.fn(() => () => {}),
+ });
+
+ const registerDefinitionProvider = vi.mocked(monaco.languages.registerDefinitionProvider);
+
+ bridge.attachModel({
+ workspaceId: "ws-1",
+ workspaceRootPath: "/repo",
+ path: "src/App.vue",
+ monacoLanguage: "vue",
+ model: createMockModel(
+ " \n",
+ 1,
+ monaco.Uri.file("/repo/src/App.vue")
+ ),
+ });
+
+ expect(registerDefinitionProvider).toHaveBeenCalledWith(
+ "vue",
+ expect.objectContaining({
+ provideDefinition: expect.any(Function),
+ })
+ );
+ });
+
it("registers a link provider that resolves relative import specifiers to workspace files", async () => {
const registerLinkProvider = vi.mocked(monaco.languages.registerLinkProvider);
const requestDefinition = vi.fn(async () => [
@@ -98,6 +127,7 @@ describe("LSP providers", () => {
requestHover: async () => null,
requestReferences: async () => [],
requestDocumentSymbols: async () => [],
+ requestSemanticTokens: async () => null,
});
registry.register("typescript");
@@ -155,6 +185,138 @@ describe("LSP providers", () => {
);
});
+ it("registers semantic token providers and converts LSP token data for Monaco", async () => {
+ const registerDocumentSemanticTokensProvider = vi.mocked(
+ monaco.languages.registerDocumentSemanticTokensProvider
+ );
+ const requestSemanticTokens = vi.fn(async () => ({
+ resultId: "semantic-1",
+ data: [0, 13, 11, 8, 1],
+ }));
+
+ const registry = createLspProviderRegistry({
+ lookupModelMetadata: () => ({
+ workspaceId: "ws-1",
+ workspaceRootPath: "/repo",
+ path: "src/main.go",
+ }),
+ requestDefinition: async () => [],
+ requestDeclaration: async () => [],
+ requestTypeDefinition: async () => [],
+ requestHover: async () => null,
+ requestReferences: async () => [],
+ requestDocumentSymbols: async () => [],
+ requestSemanticTokens,
+ });
+
+ registry.register("go");
+
+ expect(registerDocumentSemanticTokensProvider).toHaveBeenCalledWith(
+ "go",
+ expect.objectContaining({
+ getLegend: expect.any(Function),
+ provideDocumentSemanticTokens: expect.any(Function),
+ releaseDocumentSemanticTokens: expect.any(Function),
+ })
+ );
+
+ const provider =
+ registerDocumentSemanticTokensProvider.mock.calls[
+ registerDocumentSemanticTokensProvider.mock.calls.length - 1
+ ]![1];
+ const model = createMockModel(
+ "package main\n\nfunc sharedValue() {}\n",
+ 1,
+ monaco.Uri.file("/repo/src/main.go")
+ );
+
+ expect(provider.getLegend().tokenTypes).toContain("variable");
+
+ const tokens = await provider.provideDocumentSemanticTokens(model, null, {
+ isCancellationRequested: false,
+ } as never);
+
+ expect(requestSemanticTokens).toHaveBeenCalledWith({
+ meta: {
+ workspaceId: "ws-1",
+ workspaceRootPath: "/repo",
+ path: "src/main.go",
+ },
+ version: 1,
+ });
+ expect(tokens).toEqual({
+ resultId: "semantic-1",
+ data: new Uint32Array([0, 13, 11, 8, 1]),
+ });
+ });
+
+ it("wires Monaco semantic token requests through the LSP bridge", async () => {
+ const registerDocumentSemanticTokensProvider = vi.mocked(
+ monaco.languages.registerDocumentSemanticTokensProvider
+ );
+ const sendCommand = vi.fn(async (op) => {
+ if (op === "lsp.ensureSession") {
+ return {
+ kind: "ready",
+ displayName: "Rust language server",
+ source: "managed",
+ summary: {
+ workspaceId: "ws-1",
+ serverKind: "rust",
+ status: "ready",
+ capabilities: {
+ definition: true,
+ references: true,
+ hover: true,
+ documentSymbols: true,
+ semanticTokens: true,
+ diagnostics: true,
+ },
+ },
+ };
+ }
+
+ if (op === "lsp.semanticTokens") {
+ return {
+ resultId: "semantic-rust",
+ data: [0, 7, 5, 8, 0],
+ };
+ }
+
+ return null;
+ }) as BridgeSendCommand;
+ const bridge = createLspBridge({
+ sendCommand,
+ subscribe: vi.fn(() => () => {}),
+ });
+ const model = createMockModel("fn main() {}\n", 1, monaco.Uri.file("/repo/src/main.rs"));
+
+ bridge.attachModel({
+ workspaceId: "ws-1",
+ workspaceRootPath: "/repo",
+ path: "src/main.rs",
+ monacoLanguage: "rust",
+ model,
+ });
+
+ const provider =
+ registerDocumentSemanticTokensProvider.mock.calls[
+ registerDocumentSemanticTokensProvider.mock.calls.length - 1
+ ]![1];
+ const tokens = await provider.provideDocumentSemanticTokens(model, null, {
+ isCancellationRequested: false,
+ } as never);
+
+ expect(sendCommand).toHaveBeenCalledWith("lsp.semanticTokens", {
+ workspaceId: "ws-1",
+ path: "src/main.rs",
+ });
+ expect(tokens).toEqual({
+ resultId: "semantic-rust",
+ data: new Uint32Array([0, 7, 5, 8, 0]),
+ });
+ });
+
it("returns same-file definitions as Monaco locations", async () => {
const bridge = createLspBridge({
sendCommand: vi.fn(async (op) => {
diff --git a/packages/web/src/features/code-editor/lsp/providers.ts b/packages/web/src/features/code-editor/lsp/providers.ts
index 0d4c00f8..ebaf8aae 100644
--- a/packages/web/src/features/code-editor/lsp/providers.ts
+++ b/packages/web/src/features/code-editor/lsp/providers.ts
@@ -1,7 +1,19 @@
-import type { LspDocumentSymbol, LspHoverResult, LspLocation } from "@coder-studio/core";
+import {
+ LSP_SEMANTIC_TOKEN_MODIFIERS,
+ LSP_SEMANTIC_TOKEN_TYPES,
+ type LspDocumentSymbol,
+ type LspHoverResult,
+ type LspLocation,
+ type LspSemanticTokens,
+} from "@coder-studio/core";
import * as monaco from "monaco-editor";
import { toWorkspaceFileUri } from "../monaco/uri";
+const SEMANTIC_TOKENS_LEGEND: monaco.languages.SemanticTokensLegend = {
+ tokenTypes: [...LSP_SEMANTIC_TOKEN_TYPES],
+ tokenModifiers: [...LSP_SEMANTIC_TOKEN_MODIFIERS],
+};
+
export interface LspModelMetadata {
workspaceId: string;
workspaceRootPath: string;
@@ -44,6 +56,10 @@ export interface LspProviderRegistryDeps {
meta: LspModelMetadata;
version: number;
}) => Promise;
+ requestSemanticTokens: (input: {
+ meta: LspModelMetadata;
+ version: number;
+ }) => Promise;
}
export function createLspProviderRegistry(deps: LspProviderRegistryDeps) {
@@ -74,6 +90,11 @@ export function createLspProviderRegistry(deps: LspProviderRegistryDeps) {
monaco.languages.registerDocumentSymbolProvider(languageId, {
provideDocumentSymbols,
});
+ monaco.languages.registerDocumentSemanticTokensProvider(languageId, {
+ getLegend,
+ provideDocumentSemanticTokens,
+ releaseDocumentSemanticTokens,
+ });
if (supportsImportSpecifierLinks(languageId)) {
monaco.languages.registerLinkProvider?.(languageId, {
provideLinks,
@@ -244,6 +265,42 @@ export function createLspProviderRegistry(deps: LspProviderRegistryDeps) {
return result.map(toMonacoSymbol);
}
+ function getLegend(): monaco.languages.SemanticTokensLegend {
+ return SEMANTIC_TOKENS_LEGEND;
+ }
+
+ async function provideDocumentSemanticTokens(
+ model: monaco.editor.ITextModel,
+ _lastResultId: string | null,
+ token: monaco.CancellationToken
+ ): Promise {
+ if (token.isCancellationRequested) {
+ return null;
+ }
+
+ const meta = deps.lookupModelMetadata(model);
+ if (!meta) {
+ return null;
+ }
+
+ const requestVersion = model.getVersionId();
+ const result = await deps.requestSemanticTokens({
+ meta,
+ version: requestVersion,
+ });
+
+ if (token.isCancellationRequested || !result || model.getVersionId() !== requestVersion) {
+ return null;
+ }
+
+ return {
+ resultId: result.resultId,
+ data: new Uint32Array(result.data),
+ };
+ }
+
+ function releaseDocumentSemanticTokens(_resultId: string | undefined): void {}
+
async function provideLinks(
model: monaco.editor.ITextModel
): Promise {
@@ -306,6 +363,7 @@ export function createLspProviderRegistry(deps: LspProviderRegistryDeps) {
provideHover,
provideReferences,
provideDocumentSymbols,
+ provideDocumentSemanticTokens,
provideLinks,
resolveLink,
};
diff --git a/packages/web/src/features/code-editor/monaco/language-tokenization.test.ts b/packages/web/src/features/code-editor/monaco/language-tokenization.test.ts
new file mode 100644
index 00000000..6433b13c
--- /dev/null
+++ b/packages/web/src/features/code-editor/monaco/language-tokenization.test.ts
@@ -0,0 +1,45 @@
+import { beforeAll, describe, expect, it } from "vitest";
+
+const samples = [
+ { languageId: "python", source: "def main():\n return 1\n" },
+ { languageId: "go", source: "package main\nfunc main() {}\n" },
+ { languageId: "rust", source: "fn main() {\n let value = 1;\n}\n" },
+ {
+ languageId: "vue",
+ source: '{{ count }} \n',
+ },
+] as const;
+
+let monaco: typeof import("monaco-editor");
+let ensureVueLanguageRegistered: typeof import("./vue-language").ensureVueLanguageRegistered;
+
+describe("Monaco language tokenization", () => {
+ it.each(samples)("tokenizes $languageId code with non-plaintext tokens", async ({
+ languageId,
+ source,
+ }) => {
+ await monaco.editor.colorize(source, languageId, {});
+
+ const tokens = monaco.editor.tokenize(source, languageId).flat();
+
+ expect(tokens.some((token) => token.type && token.type !== "source")).toBe(true);
+ });
+});
+
+beforeAll(async () => {
+ window.matchMedia ??= () =>
+ ({
+ matches: false,
+ media: "",
+ onchange: null,
+ addEventListener() {},
+ removeEventListener() {},
+ addListener() {},
+ removeListener() {},
+ dispatchEvent: () => false,
+ }) as MediaQueryList;
+
+ monaco = await import("monaco-editor");
+ ({ ensureVueLanguageRegistered } = await import("./vue-language"));
+ ensureVueLanguageRegistered();
+}, 30_000);
diff --git a/packages/web/src/features/code-editor/monaco/vue-language.test.ts b/packages/web/src/features/code-editor/monaco/vue-language.test.ts
new file mode 100644
index 00000000..165ceac4
--- /dev/null
+++ b/packages/web/src/features/code-editor/monaco/vue-language.test.ts
@@ -0,0 +1,133 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const { mockRegisterLanguage, mockSetLanguageConfiguration, mockSetMonarchTokensProvider } =
+ vi.hoisted(() => ({
+ mockRegisterLanguage: vi.fn(),
+ mockSetLanguageConfiguration: vi.fn(),
+ mockSetMonarchTokensProvider: vi.fn(),
+ }));
+
+const VUE_LANGUAGE_REGISTERED_KEY = Symbol.for("coder-studio.monaco.vue-language.registered");
+
+vi.mock("monaco-editor", () => ({
+ languages: {
+ register: mockRegisterLanguage,
+ setLanguageConfiguration: mockSetLanguageConfiguration,
+ setMonarchTokensProvider: mockSetMonarchTokensProvider,
+ IndentAction: { Indent: 1, IndentOutdent: 2 },
+ },
+}));
+
+interface MonarchProvider {
+ tokenizer: {
+ root: Array;
+ templateBlock?: Array;
+ tagAttributes?: Array;
+ scriptTsEmbedded?: Array;
+ scriptJsEmbedded?: Array;
+ styleCssEmbedded?: Array;
+ styleScssEmbedded?: Array;
+ [state: string]: Array | undefined;
+ };
+}
+
+function getRegisteredProvider(): MonarchProvider {
+ const lastCall = mockSetMonarchTokensProvider.mock.calls.at(-1);
+ expect(lastCall?.[0]).toBe("vue");
+ return lastCall?.[1] as MonarchProvider;
+}
+
+function stringifyRules(rules: Array | undefined): string {
+ return rules
+ ? JSON.stringify(rules, (_, value) => (value instanceof RegExp ? value.source : value))
+ : "";
+}
+
+describe("ensureVueLanguageRegistered", () => {
+ beforeEach(() => {
+ vi.resetModules();
+ mockRegisterLanguage.mockClear();
+ mockSetLanguageConfiguration.mockClear();
+ mockSetMonarchTokensProvider.mockClear();
+ delete (globalThis as Record)[VUE_LANGUAGE_REGISTERED_KEY];
+ });
+
+ it("registers the vue language exactly once", async () => {
+ const monaco = await import("monaco-editor");
+ const { ensureVueLanguageRegistered } = await import("./vue-language");
+
+ ensureVueLanguageRegistered();
+ ensureVueLanguageRegistered();
+
+ expect(monaco.languages.register).toHaveBeenCalledWith({ id: "vue" });
+ expect(monaco.languages.register).toHaveBeenCalledTimes(1);
+ expect(monaco.languages.setLanguageConfiguration).toHaveBeenCalledWith(
+ "vue",
+ expect.objectContaining({
+ comments: { blockComment: [""] },
+ brackets: expect.arrayContaining([
+ ["<", ">"],
+ ["{", "}"],
+ ]),
+ autoClosingPairs: expect.arrayContaining([{ open: "{", close: "}" }]),
+ })
+ );
+ expect(monaco.languages.setMonarchTokensProvider).toHaveBeenCalledWith(
+ "vue",
+ expect.any(Object)
+ );
+ });
+
+ it("delegates
+
+
+ {{ count }} {{ doubled }}
+
diff --git a/scripts/probe-fixtures/tsconfig.json b/scripts/probe-fixtures/tsconfig.json
new file mode 100644
index 00000000..14980239
--- /dev/null
+++ b/scripts/probe-fixtures/tsconfig.json
@@ -0,0 +1,15 @@
+{
+ "compilerOptions": {
+ "target": "ESNext",
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "strict": true,
+ "jsx": "preserve",
+ "lib": ["ESNext", "DOM"],
+ "types": ["vue/types"],
+ "allowImportingTsExtensions": false,
+ "skipLibCheck": true,
+ "noEmit": true
+ },
+ "include": ["**/*.vue", "**/*.ts"]
+}
diff --git a/scripts/probe-rust.mjs b/scripts/probe-rust.mjs
new file mode 100644
index 00000000..c1f77c2a
--- /dev/null
+++ b/scripts/probe-rust.mjs
@@ -0,0 +1,160 @@
+#!/usr/bin/env node
+// Quick LSP probe for rust-analyzer against `lsp-test/probe.rs` (or any path
+// passed on argv). Spawns rust-analyzer directly, sends initialize +
+// didOpen, then dumps the response shape for hover at a few canonical
+// positions. Useful for verifying the protocol contract without going
+// through the coder-studio LSP layer.
+//
+// Usage: node scripts/probe-rust.mjs [path/to/file.rs]
+
+import { spawn } from "node:child_process";
+import { readFileSync } from "node:fs";
+import { createRequire } from "node:module";
+import { join, resolve } from "node:path";
+import { pathToFileURL } from "node:url";
+
+const require = createRequire(
+ pathToFileURL(join(process.cwd(), "packages", "server", "package.json")).toString()
+);
+const {
+ createMessageConnection,
+ StreamMessageReader,
+ StreamMessageWriter,
+} = require("vscode-jsonrpc/node.js");
+
+const RUST_ANALYZER = process.env.RUST_ANALYZER ?? "rust-analyzer";
+const sample = resolve(process.argv[2] ?? "lsp-test/probe.rs");
+const text = readFileSync(sample, "utf8");
+const uri = pathToFileURL(sample).toString();
+const rootDir = process.cwd();
+
+console.log("rust-analyzer:", RUST_ANALYZER);
+console.log("sample: ", sample);
+console.log("rootDir: ", rootDir);
+
+const child = spawn(RUST_ANALYZER, [], {
+ stdio: ["pipe", "pipe", "pipe"],
+ shell: false,
+ windowsHide: true,
+});
+child.stderr.on("data", (b) => process.stderr.write("[ra stderr] " + b.toString()));
+child.on("exit", (code) => console.log("[ra] exit:", code));
+
+const conn = createMessageConnection(
+ new StreamMessageReader(child.stdout),
+ new StreamMessageWriter(child.stdin)
+);
+conn.onUnhandledNotification((n) =>
+ console.log("<- notification:", n.method, JSON.stringify(n.params).slice(0, 160))
+);
+conn.listen();
+
+(async () => {
+ try {
+ const tInit = Date.now();
+ console.log("-> initialize");
+ const init = await Promise.race([
+ conn.sendRequest("initialize", {
+ processId: process.pid,
+ rootUri: pathToFileURL(rootDir).toString(),
+ workspaceFolders: [{ uri: pathToFileURL(rootDir).toString(), name: "probe-ws" }],
+ capabilities: {},
+ initializationOptions: {},
+ }),
+ new Promise((_, r) => setTimeout(() => r(new Error("init timeout 30s")), 30000)),
+ ]);
+ console.log(
+ "initialize returned in",
+ Date.now() - tInit,
+ "ms, hoverProvider:",
+ !!init?.capabilities?.hoverProvider
+ );
+ conn.sendNotification("initialized", {});
+
+ console.log("-> didOpen", uri);
+ conn.sendNotification("textDocument/didOpen", {
+ textDocument: { uri, languageId: "rust", version: 1, text },
+ });
+
+ // Reproduce the user's bug: hover *immediately*, before indexing is done.
+ // With a tight timeout this should fail to return anything within budget.
+ const tEarly = Date.now();
+ try {
+ const early = await Promise.race([
+ conn.sendRequest("textDocument/hover", {
+ textDocument: { uri },
+ position: { line: 16, character: 5 },
+ }),
+ new Promise((_, rj) => setTimeout(() => rj(new Error("early hover timeout 8s")), 8000)),
+ ]);
+ console.log(
+ `early hover after ${Date.now() - tEarly}ms:`,
+ JSON.stringify(early, null, 0).slice(0, 160)
+ );
+ } catch (e) {
+ console.log(`early hover failed after ${Date.now() - tEarly}ms:`, e.message);
+ }
+
+ // Wait for rust-analyzer to load (cold start can be slow). Wait either
+ // for a "Loading: " progress notification ending or a fixed timeout.
+ let loadDone = false;
+ conn.onUnhandledNotification?.((n) => {
+ if (n.method === "$/progress") {
+ const v = n.params?.value ?? {};
+ if (v.kind === "end") loadDone = true;
+ console.log("[progress]", v.kind ?? "?", v.title ?? "", v.message ?? "");
+ }
+ });
+ const start = Date.now();
+ while (!loadDone && Date.now() - start < 25_000) {
+ await new Promise((r) => setTimeout(r, 500));
+ }
+ console.log("ready in", Date.now() - start, "ms");
+
+ // Find positions of interest — `anchor` is the substring whose middle we
+ // want to land on (so we don't hover the leading keyword).
+ const lines = text.split(/\r?\n/);
+ async function hoverAt(label, lineFragment, anchor) {
+ let line = -1,
+ ch = 0;
+ for (let i = 0; i < lines.length; i++) {
+ const lineIdx = lines[i].indexOf(lineFragment);
+ if (lineIdx >= 0) {
+ line = i;
+ const anchorIdx = lines[i].indexOf(anchor, lineIdx);
+ ch = anchorIdx + Math.floor(anchor.length / 2);
+ break;
+ }
+ }
+ if (line < 0) {
+ console.log(`hover[${label}] - line fragment not found: '${lineFragment}'`);
+ return;
+ }
+ const r = await Promise.race([
+ conn.sendRequest("textDocument/hover", {
+ textDocument: { uri },
+ position: { line, character: ch },
+ }),
+ new Promise((_, rj) => setTimeout(() => rj(new Error(label + " timeout")), 15000)),
+ ]).catch((e) => ({ __error: e.message }));
+ console.log(
+ `hover[${label}] L${line + 1}:${ch + 1}:`,
+ JSON.stringify(r, null, 0).slice(0, 300)
+ );
+ }
+
+ await hoverAt("fn-multiply_by-decl", "fn multiply_by", "multiply_by");
+ await hoverAt("fn-multiply_by-call", "multiply_by(*n,", "multiply_by");
+ await hoverAt("var-total", "let mut total", "total");
+ await hoverAt("struct-Greeter", "struct Greeter", "Greeter");
+ await hoverAt("method-greet", "fn greet(&self)", "greet");
+ } catch (e) {
+ console.error("PROBE FAILED:", e.message);
+ } finally {
+ try {
+ await conn.sendRequest("shutdown", null);
+ } catch {}
+ child.kill();
+ setTimeout(() => process.exit(0), 200).unref?.();
+ }
+})();
diff --git a/scripts/probe-vue-bridge.mjs b/scripts/probe-vue-bridge.mjs
new file mode 100644
index 00000000..8c1c95e3
--- /dev/null
+++ b/scripts/probe-vue-bridge.mjs
@@ -0,0 +1,340 @@
+#!/usr/bin/env node
+// Probe the Vue + tsserver bridge end-to-end against a real Volar + TS server.
+// Usage: node scripts/probe-vue-bridge.mjs [path/to/some.vue]
+//
+// Spawns @vue/language-server (managed install) and typescript-language-server
+// (bundled), initializes both with the same payloads our LspSession uses, opens
+// a .vue document on Volar, and asks Volar for hover at a specific position.
+// Bridges tsserver/request <-> workspace/executeCommand inline so we can print
+// each step of the round-trip.
+
+import { spawn } from "node:child_process";
+import { existsSync, readFileSync } from "node:fs";
+import { createRequire } from "node:module";
+import { tmpdir } from "node:os";
+import { dirname, join, resolve } from "node:path";
+import { pathToFileURL } from "node:url";
+
+// Resolve from packages/server which has vscode-jsonrpc in its node_modules.
+const require = createRequire(
+ pathToFileURL(join(process.cwd(), "packages", "server", "package.json")).toString()
+);
+const jsonrpc = require("vscode-jsonrpc/node.js");
+const { createMessageConnection, StreamMessageReader, StreamMessageWriter } = jsonrpc;
+
+const STATE_DIR = process.env.STATE_DIR ?? join(tmpdir(), "coder-studio-dev");
+const VUE_INSTALL_ROOT = join(STATE_DIR, "lsp-tools", "vue", "3.3.2-typescript-6.0.3");
+const VUE_BIN =
+ process.platform === "win32"
+ ? join(VUE_INSTALL_ROOT, "node_modules", ".bin", "vue-language-server.cmd")
+ : join(VUE_INSTALL_ROOT, "node_modules", ".bin", "vue-language-server");
+const VUE_PKG = join(VUE_INSTALL_ROOT, "node_modules", "@vue", "language-server");
+const TSDK = join(VUE_INSTALL_ROOT, "node_modules", "typescript", "lib");
+
+const TSLS_CLI = require.resolve("typescript-language-server/lib/cli.mjs", {
+ paths: [join(process.cwd(), "packages", "server"), process.cwd()],
+});
+
+const sample = process.argv[2] ? resolve(process.argv[2]) : writeSample();
+
+const sampleText = readFileSync(sample, "utf8");
+const sampleUri = pathToFileURL(sample).toString();
+const rootDir = dirname(sample);
+const rootUri = pathToFileURL(rootDir).toString();
+
+console.log("paths:");
+console.log(" vue bin: ", VUE_BIN);
+console.log(" vue install: ", VUE_PKG);
+console.log(" tsdk: ", TSDK);
+console.log(" tsls cli: ", TSLS_CLI);
+console.log(" sample: ", sample);
+console.log(" sample exists? ", existsSync(sample));
+console.log(" vue bin exists? ", existsSync(VUE_BIN));
+console.log();
+
+if (!existsSync(VUE_BIN)) {
+ console.error("Vue bin missing; run the app once so it installs Volar.");
+ process.exit(1);
+}
+
+const volar = spawn(VUE_BIN, ["--stdio"], {
+ cwd: rootDir,
+ stdio: ["pipe", "pipe", "pipe"],
+ shell: process.platform === "win32",
+ windowsHide: true,
+});
+const tsls = spawn(process.execPath, [TSLS_CLI, "--stdio"], {
+ cwd: rootDir,
+ stdio: ["pipe", "pipe", "pipe"],
+ windowsHide: true,
+});
+
+volar.stderr.on("data", (b) => process.stderr.write("[volar stderr] " + b.toString()));
+tsls.stderr.on("data", (b) => process.stderr.write("[tsls stderr] " + b.toString()));
+volar.on("exit", (code) => console.log("[volar] exit code:", code));
+tsls.on("exit", (code) => console.log("[tsls] exit code:", code));
+
+const volarConn = createMessageConnection(
+ new StreamMessageReader(volar.stdout),
+ new StreamMessageWriter(volar.stdin)
+);
+const tslsConn = createMessageConnection(
+ new StreamMessageReader(tsls.stdout),
+ new StreamMessageWriter(tsls.stdin)
+);
+
+volarConn.onUnhandledNotification((n) =>
+ console.log("[volar->] unhandled notification:", n.method, JSON.stringify(n.params).slice(0, 200))
+);
+tslsConn.onUnhandledNotification((n) =>
+ console.log("[tsls->] unhandled notification:", n.method, JSON.stringify(n.params).slice(0, 200))
+);
+
+function unwrap(raw) {
+ if (raw === null || raw === undefined) return null;
+ if (typeof raw !== "object") return raw;
+ if (!("body" in raw) && raw.type !== "response") return raw;
+ if (raw.success === false) return null;
+ return raw.body ?? null;
+}
+
+// Bridge tsserver/request -> workspace/executeCommand on tsls
+volarConn.onNotification("tsserver/request", async (payload) => {
+ if (!Array.isArray(payload) || payload.length < 2) {
+ console.log("[bridge] malformed tsserver/request payload:", payload);
+ return;
+ }
+ const [id, command, args] = payload;
+ console.log("[bridge] tsserver/request id=", id, "command=", command);
+ try {
+ const raw = await Promise.race([
+ tslsConn.sendRequest("workspace/executeCommand", {
+ command: "typescript.tsserverRequest",
+ arguments: [command, args],
+ }),
+ new Promise((_, reject) => setTimeout(() => reject(new Error("bridge timeout")), 8000)),
+ ]);
+ const unwrapped = unwrap(raw);
+ console.log("[bridge] tsserver response (unwrapped):", trim(unwrapped));
+ volarConn.sendNotification("tsserver/response", [id, unwrapped]);
+ } catch (e) {
+ console.log("[bridge] tsserver request failed:", e.message);
+ volarConn.sendNotification("tsserver/response", [id, null]);
+ }
+});
+
+volarConn.listen();
+tslsConn.listen();
+
+const VUE_INIT_OPTIONS = { typescript: { tsdk: TSDK } };
+// Override location with PROBE_LOCATION env if set, so we can try alternative
+// paths without editing the file.
+const LOCATION = process.env.PROBE_LOCATION ?? VUE_PKG;
+console.log("plugin location:", LOCATION);
+const TSLS_INIT_OPTIONS = {
+ plugins: [
+ {
+ name: "@vue/typescript-plugin",
+ location: LOCATION,
+ languages: ["vue"],
+ configNamespace: "typescript",
+ },
+ ],
+ tsserver: {
+ logVerbosity: "verbose",
+ logDirectory: process.env.TSSERVER_LOG_DIR ?? join(tmpdir(), "tsserver-probe-logs"),
+ trace: "verbose",
+ },
+};
+
+const initParams = {
+ processId: process.pid,
+ rootUri,
+ workspaceFolders: [{ uri: rootUri, name: "probe-workspace" }],
+ capabilities: {},
+};
+
+(async () => {
+ try {
+ console.log("-> initialize both servers in parallel");
+ const [vInit, tInit] = await Promise.all([
+ volarConn.sendRequest("initialize", {
+ ...initParams,
+ initializationOptions: VUE_INIT_OPTIONS,
+ }),
+ tslsConn.sendRequest("initialize", {
+ ...initParams,
+ initializationOptions: TSLS_INIT_OPTIONS,
+ }),
+ ]);
+ console.log("volar capabilities.hoverProvider:", !!vInit?.capabilities?.hoverProvider);
+ console.log(
+ "tsls capabilities.executeCommandProvider:",
+ trim(tInit?.capabilities?.executeCommandProvider)
+ );
+
+ volarConn.sendNotification("initialized", {});
+ tslsConn.sendNotification("initialized", {});
+
+ console.log("-> didOpen on both ends");
+ volarConn.sendNotification("textDocument/didOpen", {
+ textDocument: {
+ uri: sampleUri,
+ languageId: "vue",
+ version: 1,
+ text: sampleText,
+ },
+ });
+ tslsConn.sendNotification("textDocument/didOpen", {
+ textDocument: {
+ uri: sampleUri,
+ languageId: "vue",
+ version: 1,
+ text: sampleText,
+ },
+ });
+
+ // Wait longer so tsserver fully boots and indexes the plugin.
+ await new Promise((r) => setTimeout(r, 3500));
+
+ const lines = sampleText.split(/\r?\n/);
+ async function probeAt(label, target) {
+ let line = 0;
+ let char = 0;
+ for (let i = 0; i < lines.length; i++) {
+ const idx = lines[i].indexOf(target);
+ if (idx >= 0) {
+ line = i;
+ char = idx + Math.max(0, Math.floor(target.length / 2));
+ break;
+ }
+ }
+ console.log(
+ `\n>>> ${label} at L${line + 1}:${char + 1} >> '${lines[line]?.slice(Math.max(0, char - 3), char + target.length + 3)}'`
+ );
+ const position = { line, character: char };
+
+ // Fan out: ask Volar and TSLS in parallel, then merge as the real
+ // LspSession does. This mirrors what coder-studio's server does today
+ // and is the actual user-visible behavior.
+ const tasks = [
+ Promise.race([
+ volarConn.sendRequest("textDocument/hover", {
+ textDocument: { uri: sampleUri },
+ position,
+ }),
+ new Promise((_, reject) =>
+ setTimeout(() => reject(new Error("volar hover timeout")), 8000)
+ ),
+ ]).catch((e) => ({ __error: e.message })),
+ Promise.race([
+ tslsConn.sendRequest("textDocument/hover", {
+ textDocument: { uri: sampleUri },
+ position,
+ }),
+ new Promise((_, reject) =>
+ setTimeout(() => reject(new Error("tsls hover timeout")), 8000)
+ ),
+ ]).catch((e) => ({ __error: e.message })),
+ ];
+ const [vh, th] = await Promise.all(tasks);
+ console.log(`hover[${label}] volar :`, JSON.stringify(vh, null, 0));
+ console.log(`hover[${label}] tsls :`, JSON.stringify(th, null, 0));
+ const mergedContents = [];
+ for (const r of [vh, th]) {
+ if (r && !r.__error && r?.contents) {
+ if (typeof r.contents === "string") mergedContents.push(r.contents);
+ else if (typeof r.contents?.value === "string") mergedContents.push(r.contents.value);
+ else if (Array.isArray(r.contents))
+ for (const c of r.contents) {
+ if (typeof c === "string") mergedContents.push(c);
+ else if (typeof c?.value === "string") mergedContents.push(c.value);
+ }
+ }
+ }
+ console.log(`MERGED[${label}]:`, mergedContents.length ? mergedContents : "(empty)");
+ }
+
+ async function probeTslsHoverAt(label, target) {
+ let line = 0;
+ let char = 0;
+ for (let i = 0; i < lines.length; i++) {
+ const idx = lines[i].indexOf(target);
+ if (idx >= 0) {
+ line = i;
+ char = idx + Math.max(0, Math.floor(target.length / 2));
+ break;
+ }
+ }
+ try {
+ const hover = await Promise.race([
+ tslsConn.sendRequest("textDocument/hover", {
+ textDocument: { uri: sampleUri },
+ position: { line, character: char },
+ }),
+ new Promise((_, reject) =>
+ setTimeout(() => reject(new Error(`tsls hover timeout`)), 8000)
+ ),
+ ]);
+ console.log(`tslsHover[${label}]:`, JSON.stringify(hover, null, 0));
+ } catch (e) {
+ console.log(`tslsHover[${label}] failed:`, e.message);
+ }
+ }
+
+ await probeAt("count-decl", "const count");
+ await probeTslsHoverAt("count-decl", "const count");
+ await probeAt("ref-import", "ref, computed");
+ await probeTslsHoverAt("ref-import", "ref, computed");
+ await probeAt("count-usage-in-template", "{{ count");
+ await probeTslsHoverAt("count-usage-in-template", "{{ count");
+
+ // Inspect document symbols to confirm Volar parses the SFC at all.
+ try {
+ const symbols = await volarConn.sendRequest("textDocument/documentSymbol", {
+ textDocument: { uri: sampleUri },
+ });
+ console.log("\ndocumentSymbols:", trim(symbols));
+ } catch (e) {
+ console.log("documentSymbol failed:", e.message);
+ }
+ } catch (e) {
+ console.error("PROBE FAILED:", e.message);
+ } finally {
+ console.log("-> shutting down");
+ try {
+ await volarConn.sendRequest("shutdown", null);
+ } catch {}
+ try {
+ await tslsConn.sendRequest("shutdown", null);
+ } catch {}
+ volar.kill();
+ tsls.kill();
+ setTimeout(() => process.exit(0), 500).unref?.();
+ }
+})();
+
+function trim(value) {
+ const s = JSON.stringify(value);
+ return s == null ? String(value) : s.length > 240 ? s.slice(0, 240) + "..." : s;
+}
+
+function writeSample() {
+ const path = join(tmpdir(), "probe-vue-bridge-sample.vue");
+ const content = `
+
+
+ {{ count }} {{ doubled }}
+
+`;
+ if (!existsSync(path)) {
+ const fs = require("node:fs");
+ fs.writeFileSync(path, content);
+ }
+ return path;
+}