fix: keep diff previews tied to the correct file (#1618)

* fix: keep diff previews tied to the correct file

* fix: handle legacy file editor panel restores

* test: align file editor image mock

* fix: ignore sibling repo editor sync changes

* test: use repo scoped file keys in save tests

* fix: guard file editor preview restoration

* fix: remove redundant preview path fallback

* test: cover repo scoped image previews

* test: use repo scoped keys in sync tests

* fix: guard stale file editor open fetches

* fix: distinguish repeated file editor requests

* fix: open single-repo PR diffs without repo filter

* fix: keep pending file opens alive
This commit is contained in:
José Almeida
2026-07-09 17:25:33 +01:00
committed by GitHub
parent 259ff30d9c
commit 9c73a2d8c3
28 changed files with 1142 additions and 209 deletions
@@ -92,7 +92,7 @@ export type ChangesPanelBodyProps = {
loadingOperation: string | null;
dialogs: DialogsType;
onOpenDiffFile: (path: string, options?: OpenDiffOptions) => void;
onEditFile: (path: string) => void;
onEditFile: (path: string, repo?: string) => void;
onOpenCommitDetail?: (sha: string, repo?: string) => void;
onOpenReview?: () => void;
onRevertCommit?: (sha: string, repo?: string) => void;
@@ -265,7 +265,7 @@ export function useChangesPanelData() {
type ChangesPanelCallbacks = {
onOpenDiffFile: (path: string, options?: OpenDiffOptions) => void;
onEditFile: (path: string) => void;
onEditFile: (path: string, repo?: string) => void;
onOpenCommitDetail?: (sha: string, repo?: string) => void;
onOpenReview?: () => void;
};
@@ -108,14 +108,18 @@ describe("FileRow truncation (regression: path overlaps diff stats in narrow pan
});
});
it("opens image files in the file preview instead of the diff viewer", () => {
it("opens image files in the file preview with repository context", () => {
const onOpenDiff = vi.fn();
const onEditFile = vi.fn();
const { container } = render(
<TooltipProvider>
<ul>
<FileRow
file={{ ...baseFile, path: "docs/screenshots/mobile-chat.webp" }}
file={{
...baseFile,
path: "docs/screenshots/mobile-chat.webp",
repositoryName: "frontend",
}}
isPending={false}
onSelect={noopSelect}
onOpenDiff={onOpenDiff}
@@ -134,7 +138,7 @@ describe("FileRow truncation (regression: path overlaps diff stats in narrow pan
expect(row).not.toBeNull();
fireEvent.click(row!);
expect(onEditFile).toHaveBeenCalledWith("docs/screenshots/mobile-chat.webp");
expect(onEditFile).toHaveBeenCalledWith("docs/screenshots/mobile-chat.webp", "frontend");
expect(onOpenDiff).not.toHaveBeenCalled();
});
});
@@ -42,7 +42,7 @@ type FileRowProps = {
onStage: (path: string, repo?: string) => void;
onUnstage: (path: string, repo?: string) => void;
onDiscard: (path: string, repo?: string) => void;
onEditFile: (path: string) => void;
onEditFile: (path: string, repo?: string) => void;
/**
* Tree mode: skip the folder prefix, swap the left-side stage button for a
* filetype icon, and surface the stage action only on row hover (VS Code-
@@ -76,7 +76,7 @@ export function FileRow({
const consumed = onSelect?.(file.path, e);
if (!consumed) {
if (getFileCategory(file.path) === "image") {
onEditFile(file.path);
onEditFile(file.path, file.repositoryName);
return;
}
onOpenDiff(file.path, {
@@ -254,7 +254,7 @@ function FileRowActions({
path: string;
repo?: string;
onDiscard: (path: string, repo?: string) => void;
onEditFile: (path: string) => void;
onEditFile: (path: string, repo?: string) => void;
}) {
return (
<div
@@ -283,7 +283,7 @@ function FileRowActions({
className="text-muted-foreground hover:text-foreground cursor-pointer"
onClick={(e) => {
e.stopPropagation();
onEditFile(path);
onEditFile(path, repo);
}}
>
<IconPencil className="h-3.5 w-3.5" />
@@ -1,8 +1,10 @@
import { describe, it, expect, vi } from "vitest";
import { render, fireEvent, screen } from "@testing-library/react";
import { describe, it, expect, vi, afterEach } from "vitest";
import { render, fireEvent, screen, cleanup } from "@testing-library/react";
import { PRFilesGroupedList } from "./changes-panel-pr-files";
describe("PRFilesGroupedList", () => {
afterEach(() => cleanup());
it("passes diff source + repository context on open diff", () => {
const onOpenDiff = vi.fn();
render(
@@ -27,4 +29,29 @@ describe("PRFilesGroupedList", () => {
repositoryName: "backend",
});
});
it("does not pass an empty single-repo stamp as repository context", () => {
const onOpenDiff = vi.fn();
render(
<PRFilesGroupedList
files={[
{
path: "README.md",
status: "modified",
plus: 1,
minus: 0,
oldPath: undefined,
repository_name: "",
},
]}
onOpenDiff={onOpenDiff}
/>,
);
fireEvent.click(screen.getByText("README.md"));
expect(onOpenDiff).toHaveBeenCalledWith("README.md", {
source: "pr",
repositoryName: undefined,
});
});
});
@@ -128,7 +128,7 @@ function PRFileRow({
onClick={() =>
onOpenDiff(file.path, {
source: "pr",
repositoryName: file.repository_name,
repositoryName: file.repository_name || undefined,
})
}
>
@@ -232,7 +232,7 @@ type FileListSectionProps = {
isSecondaryActionLoading?: boolean;
onSecondaryAction?: () => void;
onOpenDiff: (path: string, options?: OpenDiffOptions) => void;
onEditFile: (path: string) => void;
onEditFile: (path: string, repo?: string) => void;
// Multi-repo: handlers receive the file's repositoryName so each per-file op
// hits the right git repo. Same-named files across repos collide by path.
onStage: (path: string, repo?: string) => void;
@@ -263,7 +263,7 @@ type FileListBodyProps = {
multiSelect: ReturnType<typeof useMultiSelect>;
onKeyDown: (e: React.KeyboardEvent) => void;
onOpenDiff: (path: string, options?: OpenDiffOptions) => void;
onEditFile: (path: string) => void;
onEditFile: (path: string, repo?: string) => void;
onStage: (path: string, repo?: string) => void;
onUnstage: (path: string, repo?: string) => void;
onDiscard: (path: string, repo?: string) => void;
@@ -78,7 +78,7 @@ type ChangesTreeProps = {
files: ChangedFile[];
pendingStageFiles: Set<string>;
onOpenDiff: (path: string, options?: OpenDiffOptions) => void;
onEditFile: (path: string) => void;
onEditFile: (path: string, repo?: string) => void;
onStage: (path: string, repo?: string) => void;
onUnstage: (path: string, repo?: string) => void;
onDiscard: (path: string, repo?: string) => void;
@@ -164,7 +164,7 @@ type RepoTreeGroupProps = {
collapsed: boolean;
onToggle: () => void;
onOpenDiff: (path: string, options?: OpenDiffOptions) => void;
onEditFile: (path: string) => void;
onEditFile: (path: string, repo?: string) => void;
onStage: (path: string, repo?: string) => void;
onUnstage: (path: string, repo?: string) => void;
onDiscard: (path: string, repo?: string) => void;
+1 -1
View File
@@ -13,7 +13,7 @@ export { filterUnpushedCommits, mergeCommits };
type ChangesPanelProps = {
onOpenDiffFile: (path: string, options?: OpenDiffOptions) => void;
onEditFile: (path: string) => void;
onEditFile: (path: string, repo?: string) => void;
onOpenCommitDetail?: (sha: string, repo?: string) => void;
onOpenDiffAll?: () => void;
onOpenReview?: () => void;
@@ -0,0 +1,68 @@
import { render } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
const taskChangesPanel = vi.hoisted(() => ({
props: null as Record<string, unknown> | null,
}));
vi.mock("./task-changes-panel", () => ({
TaskChangesPanel: (props: Record<string, unknown>) => {
taskChangesPanel.props = props;
return <div data-testid="task-changes-panel" />;
},
}));
vi.mock("@/hooks/use-file-editors", () => ({
useFileEditors: () => ({ openFile: vi.fn() }),
}));
vi.mock("@/lib/state/dockview-store", () => ({
useDockviewStore: (selector: (state: Record<string, unknown>) => unknown) =>
selector({
selectedDiff: null,
setSelectedDiff: vi.fn(),
api: { getPanel: vi.fn(), removePanel: vi.fn() },
}),
}));
vi.mock("@/components/state-provider", () => ({
useAppStore: (selector: (state: Record<string, unknown>) => unknown) =>
selector({
tasks: { activeSessionId: null, activeTaskId: null },
taskSessions: { items: {} },
agentProfiles: { items: [] },
kanban: { tasks: [] },
kanbanMulti: { snapshots: {} },
}),
useAppStoreApi: () => ({
getState: () => ({
tasks: { activeSessionId: null, activeTaskId: null },
kanban: { tasks: [] },
kanbanMulti: { snapshots: {} },
}),
}),
}));
import { renderPanel } from "./dockview-panel-content";
describe("dockview diff panel content", () => {
it("passes repositoryName from panel params into the file-mode diff view", () => {
render(
<>
{renderPanel("preview:file-diff", "diff-viewer", {
kind: "file",
path: "README.md",
source: "uncommitted",
repositoryName: "frontend",
})}
</>,
);
expect(taskChangesPanel.props).toMatchObject({
mode: "file",
filePath: "README.md",
sourceFilter: "uncommitted",
fileRepositoryName: "frontend",
});
});
});
@@ -94,6 +94,8 @@ function DiffViewerContent({
const { openFile } = useFileEditors();
const panelKind = (params?.kind as string) ?? "all";
const selectedPath = panelKind === "file" ? (params?.path as string) : undefined;
const selectedRepositoryName =
panelKind === "file" ? (params?.repositoryName as string | undefined) : undefined;
const sourceFilter = ((params?.source as string) || "all") as "all" | ReviewSource;
const panelSelectedDiff = panelKind === "all" ? selectedDiff : null;
const handleClosePanel = useCallback(() => {
@@ -106,6 +108,7 @@ function DiffViewerContent({
<TaskChangesPanel
mode={panelKind as "all" | "file"}
filePath={selectedPath}
fileRepositoryName={selectedRepositoryName}
sourceFilter={sourceFilter}
selectedDiff={panelSelectedDiff}
onClearSelected={() => setSelectedDiff(null)}
@@ -189,7 +192,10 @@ function ChangesContent({ panelId }: { panelId: string }) {
setPanelTitle(panelId, title);
}, [totalCount, panelId]);
const handleEditFile = useCallback((path: string) => openFile(path), [openFile]);
const handleEditFile = useCallback(
(path: string, repo?: string) => openFile(path, repo),
[openFile],
);
const handleOpenDiffFile = useCallback(
(path: string, options?: OpenDiffOptions) =>
addFileDiffPanel(path, { source: options?.source, repositoryName: options?.repositoryName }),
+11 -3
View File
@@ -32,6 +32,7 @@ import { TerminalPanel } from "./terminal-panel";
import { BrowserPanel } from "./browser-panel";
import { VscodePanel } from "./vscode-panel";
import { CommitDetailPanel } from "./commit-detail-panel";
import type { OpenDiffOptions } from "./changes-diff-target";
import { PRDetailPanelComponent } from "@/components/github/pr-detail-panel";
import { setPanelTitle, panelPortalManager } from "@/lib/layout/panel-portal-manager";
@@ -254,6 +255,8 @@ function DiffViewerContent({
const activeSessionId = useAppStore((state) => state.tasks.activeSessionId);
const panelKind = (params?.kind as string) ?? "all";
const selectedPath = panelKind === "file" ? (params?.path as string) : undefined;
const selectedRepositoryName =
panelKind === "file" ? (params?.repositoryName as string | undefined) : undefined;
const panelSelectedDiff = panelKind === "all" ? selectedDiff : null;
useResyncGitStatusOnTabActivate(panelId, activeSessionId);
const handleClosePanel = useCallback(() => {
@@ -266,6 +269,7 @@ function DiffViewerContent({
<TaskChangesPanel
mode={panelKind as "all" | "file"}
filePath={selectedPath}
fileRepositoryName={selectedRepositoryName}
selectedDiff={panelSelectedDiff}
onClearSelected={() => setSelectedDiff(null)}
onOpenFile={openFile}
@@ -293,13 +297,17 @@ function ChangesContent({ panelId }: { panelId: string }) {
setPanelTitle(panelId, title);
}, [totalCount, panelId]);
const handleEditFile = useCallback((path: string) => openFile(path), [openFile]);
const handleEditFile = useCallback(
(path: string, repo?: string) => openFile(path, repo),
[openFile],
);
const handleOpenDiffFile = useCallback(
(path: string) => addFileDiffPanel(path),
(path: string, options?: OpenDiffOptions) =>
addFileDiffPanel(path, { source: options?.source, repositoryName: options?.repositoryName }),
[addFileDiffPanel],
);
const handleOpenCommitDetail = useCallback(
(sha: string) => addCommitDetailPanel(sha),
(sha: string, repo?: string) => addCommitDetailPanel(sha, { repo }),
[addCommitDetailPanel],
);
const handleOpenDiffAll = useCallback(() => addDiffViewerPanel(), [addDiffViewerPanel]);
@@ -0,0 +1,108 @@
import { act, render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { useDockviewStore, type FileEditorState } from "@/lib/state/dockview-store";
import { buildRepoScopedItemId } from "@/lib/state/dockview-panel-actions";
vi.mock("./file-image-viewer", () => ({
FileImageViewer: ({ path, content }: { path: string; content: string }) => (
<div data-testid="image-viewer" data-path={path}>
{content}
</div>
),
}));
vi.mock("@/components/state-provider", () => ({
useAppStore: (selector: (state: Record<string, unknown>) => unknown) =>
selector({
tasks: { activeSessionId: null },
taskSessions: { items: {} },
}),
}));
vi.mock("@/hooks/use-file-editors", () => ({
useFileEditors: () => ({
savingFiles: new Set<string>(),
handleFileChange: vi.fn(),
saveFile: vi.fn(),
deleteFile: vi.fn(),
openFile: vi.fn(),
openFileInMarkdownPreview: vi.fn(),
applyRemoteUpdate: vi.fn(),
}),
}));
vi.mock("@/hooks/domains/session/use-session-git-status", () => ({
useSessionGitStatus: () => undefined,
}));
import { FileEditorPanel } from "./file-editor-panel";
const PREVIEW_PANEL_ID = "preview:file-editor";
const IMAGE_VIEWER_TEST_ID = "image-viewer";
const SHARED_IMAGE_PATH = "docs/shared.png";
const makeImageState = (path: string, content: string): FileEditorState => ({
path,
name: path.split("/").pop() ?? path,
content,
originalContent: content,
originalHash: `h:${content}`,
isDirty: false,
isBinary: true,
});
function seedImage(path: string, content: string, repo?: string) {
useDockviewStore.getState().setFileState(buildRepoScopedItemId(path, repo), {
...makeImageState(path, content),
repo,
});
}
describe("FileEditorPanel image preview", () => {
beforeEach(() => {
act(() => {
useDockviewStore.getState().clearFileStates();
});
});
it("updates displayed image content when a reused preview tab switches files", async () => {
act(() => seedImage("docs/first.png", "first-image"));
const { rerender } = render(
<FileEditorPanel panelId={PREVIEW_PANEL_ID} params={{ path: "docs/first.png" }} />,
);
expect(screen.getByTestId(IMAGE_VIEWER_TEST_ID).textContent).toBe("first-image");
await act(async () => {
await Promise.resolve();
});
act(() => seedImage("docs/second.png", "second-image"));
rerender(<FileEditorPanel panelId={PREVIEW_PANEL_ID} params={{ path: "docs/second.png" }} />);
expect(screen.getByTestId(IMAGE_VIEWER_TEST_ID).textContent).toBe("second-image");
});
it("shows different image content for the same path across repos", () => {
act(() => {
seedImage(SHARED_IMAGE_PATH, "content-from-repo-a", "repo-a");
seedImage(SHARED_IMAGE_PATH, "content-from-repo-b", "repo-b");
});
const { rerender } = render(
<FileEditorPanel
panelId={PREVIEW_PANEL_ID}
params={{ path: SHARED_IMAGE_PATH, repo: "repo-a" }}
/>,
);
expect(screen.getByTestId(IMAGE_VIEWER_TEST_ID).textContent).toBe("content-from-repo-a");
rerender(
<FileEditorPanel
panelId={PREVIEW_PANEL_ID}
params={{ path: SHARED_IMAGE_PATH, repo: "repo-b" }}
/>,
);
expect(screen.getByTestId(IMAGE_VIEWER_TEST_ID).textContent).toBe("content-from-repo-b");
});
});
Binary file not shown.
@@ -17,7 +17,7 @@ import type { OpenDiffOptions, DiffSheetMode } from "../changes-diff-target";
type MobileChangesPanelProps = {
selectedDiff: SelectedDiff | null;
onClearSelected: () => void;
onOpenFile?: (filePath: string) => void;
onOpenFile?: (filePath: string, repo?: string) => void;
};
/**
@@ -15,7 +15,7 @@ const MOBILE_DIFF_SOURCE_FILTER_KEY = "mobile-diff-source-filter";
type MobileDiffSheetProps = {
mode: DiffSheetMode | null;
onClose: () => void;
onOpenFile?: (filePath: string) => void;
onOpenFile?: (filePath: string, repo?: string) => void;
selectedDiff: SelectedDiff | null;
onClearSelected: () => void;
sourceCounts: SourceCounts;
@@ -157,7 +157,7 @@ function renderPanel(
activeSource: ReviewSource,
selectedDiff: SelectedDiff | null,
onClearSelected: () => void,
onOpenFile?: (filePath: string) => void,
onOpenFile?: (filePath: string, repo?: string) => void,
): React.ReactNode {
if (!mode) return null;
if (mode.kind === "commit") {
@@ -30,7 +30,7 @@ type TaskChangesPanelProps = {
onClearSelected: () => void;
onBecameEmpty?: () => void;
/** Callback to open file in editor */
onOpenFile?: (filePath: string) => void;
onOpenFile?: (filePath: string, repo?: string) => void;
/**
* Restrict the diff list to a single source. Defaults to `"all"` (no
* filter). Mobile uses this for source tabs; desktop omits it.
@@ -558,7 +558,7 @@ function ChangesPanelContent({
selectedFile?: string | null;
onToggleReviewed: (path: string, reviewed: boolean) => void;
onDiscard: (path: string) => Promise<void>;
onOpenFile: (path: string) => void;
onOpenFile: (path: string, repo?: string) => void;
onPreviewMarkdown?: (path: string) => void;
fileRefs: Map<string, React.RefObject<HTMLDivElement | null>>;
}) {
@@ -640,7 +640,6 @@ test.describe("PR diff regression", () => {
// Assert PR diff content appears (not "No changes")
await assertPRDiffContains(testPage, "DESKTOP_PR_OVERLAP_MARKER_A");
git.exec("git checkout -- .");
git.exec("git clean -fd");
});
});
+137
View File
@@ -0,0 +1,137 @@
"use client";
import type { MutableRefObject } from "react";
import { useDockviewStore, type FileEditorState } from "@/lib/state/dockview-store";
import { buildRepoScopedItemId, PREVIEW_FILE_EDITOR_ID } from "@/lib/state/dockview-panel-actions";
import { calculateHash } from "@/lib/utils/file-diff";
import { getWebSocketClient } from "@/lib/ws/connection";
import { requestFileContent } from "@/lib/ws/workspace-files";
import type { FileContentResponse } from "@/lib/types/backend";
type DockApi = ReturnType<typeof useDockviewStore.getState>["api"];
export type FileEditorRequestToken = {
fileKey: string;
generation: number;
};
/**
* Build a FileEditorState from a file content response. `repo` is the
* multi-repo subpath (repository_name) the file belongs to; it is recorded on
* the state so subsequent save/sync/delete requests stay scoped to the right
* repository instead of resolving against the bare task root.
*/
export async function buildFileEditorState(
filePath: string,
response: FileContentResponse,
repo?: string,
): Promise<FileEditorState> {
const fileName = filePath.split("/").pop() || filePath;
const hash = await calculateHash(response.content);
return {
path: filePath,
repo,
name: fileName,
content: response.content,
originalContent: response.content,
originalHash: hash,
isDirty: false,
isBinary: response.is_binary,
resolvedPath: response.resolved_path,
};
}
type FetchFileEditorStateParams = {
client: NonNullable<ReturnType<typeof getWebSocketClient>>;
sessionId: string;
filePath: string;
repo: string | undefined;
activeSessionIdRef: MutableRefObject<string | null>;
activeRequestRef?: MutableRefObject<FileEditorRequestToken | null>;
requestToken?: FileEditorRequestToken;
};
/**
* Fetch a file's content and build its editor state, returning null if the
* active session or requested file changed while the request was in flight.
*/
export async function fetchFileEditorState({
client,
sessionId,
filePath,
repo,
activeSessionIdRef,
activeRequestRef,
requestToken = { fileKey: buildRepoScopedItemId(filePath, repo), generation: 0 },
}: FetchFileEditorStateParams): Promise<FileEditorState | null> {
const requestMatches = () =>
!activeRequestRef ||
(activeRequestRef.current?.fileKey === requestToken.fileKey &&
activeRequestRef.current.generation === requestToken.generation);
const isCurrent = () => activeSessionIdRef.current === sessionId && requestMatches();
const response = await requestFileContent(client, sessionId, filePath, repo);
if (!isCurrent()) return null;
const state = await buildFileEditorState(filePath, response, repo);
if (!isCurrent()) return null;
return state;
}
function panelParamsMatchFile(
params: Record<string, unknown> | undefined,
path: string,
repo?: string,
) {
if (params?.path !== path) return false;
let panelRepo: string | undefined;
if (typeof params.repo === "string") {
panelRepo = params.repo;
} else if (typeof params.repositoryName === "string") {
panelRepo = params.repositoryName;
}
return panelRepo === repo;
}
export function isFileEditorPanelAlreadyRestored(
dockApi: DockApi,
path: string,
repo?: string,
): boolean {
if (!dockApi) return false;
const itemId = buildRepoScopedItemId(path, repo);
if (dockApi.getPanel(`file:${itemId}`)) return true;
const legacyPanel = dockApi.getPanel(`file:${path}`);
const legacyParams = legacyPanel?.params as Record<string, unknown> | undefined;
if (legacyPanel && panelParamsMatchFile(legacyParams, path, repo)) return true;
const previewParams = dockApi.getPanel(PREVIEW_FILE_EDITOR_ID)?.params as
| Record<string, unknown>
| undefined;
return (
previewParams?.previewItemId === itemId ||
(previewParams?.previewItemId === path && panelParamsMatchFile(previewParams, path, repo))
);
}
export function isRestoreWriteCurrent(
restoredSessionId: string | null,
activeSessionId: string,
activeSessionIdRef: MutableRefObject<string | null>,
): boolean {
return restoredSessionId === activeSessionId && activeSessionIdRef.current === activeSessionId;
}
export function getPreviewItemIdToRemoveOnReplace(
dockApi: DockApi,
nextItemId: string,
): string | null {
if (!dockApi) return null;
const previewParams = dockApi.getPanel(PREVIEW_FILE_EDITOR_ID)?.params as
| Record<string, unknown>
| undefined;
const previousItemId =
typeof previewParams?.previewItemId === "string" ? previewParams.previewItemId : null;
if (!previousItemId || previousItemId === nextItemId) return null;
if (previewParams?.promoted === true) return null;
if (dockApi.getPanel(`file:${previousItemId}`)) return null;
if (dockApi.getPanel(`file:${nextItemId}`)) return null;
return previousItemId;
}
+73 -5
View File
@@ -4,6 +4,7 @@ import { useRef } from "react";
import type { FileEditorState } from "@/lib/state/dockview-store";
import type { FileInfo } from "@/lib/state/store";
import type { GitStatusEntry } from "@/lib/state/slices/session-runtime/types";
import { buildRepoScopedItemId } from "@/lib/state/dockview-panel-actions";
const mockRequestFileContent = vi.fn();
const mockGetWebSocketClient = vi.fn();
@@ -41,10 +42,15 @@ const FAKE_CLIENT = {} as ReturnType<typeof import("@/lib/ws/connection").getWeb
const SESSION_ID = "sess-1";
const PATH = "src/foo.ts";
function fileKey(repo?: string) {
return buildRepoScopedItemId(PATH, repo);
}
function seedOpenFile(state: Partial<FileEditorState> = {}) {
const key = fileKey(state.repo);
openFilesMap = new Map<string, FileEditorState>([
[
PATH,
key,
{
path: PATH,
name: "foo.ts",
@@ -56,6 +62,7 @@ function seedOpenFile(state: Partial<FileEditorState> = {}) {
},
],
]);
return key;
}
describe("buildGitFileSignature", () => {
@@ -106,6 +113,7 @@ describe("syncOpenFileFromWorkspace", () => {
await syncOpenFileFromWorkspace({
client: FAKE_CLIENT,
sessionId: SESSION_ID,
fileKey: PATH,
path: PATH,
updateFileState,
});
@@ -142,6 +150,7 @@ describe("syncOpenFileFromWorkspace", () => {
await syncOpenFileFromWorkspace({
client: FAKE_CLIENT,
sessionId: SESSION_ID,
fileKey: PATH,
path: PATH,
updateFileState,
});
@@ -167,6 +176,7 @@ describe("syncOpenFileFromWorkspace", () => {
await syncOpenFileFromWorkspace({
client: FAKE_CLIENT,
sessionId: SESSION_ID,
fileKey: PATH,
path: PATH,
updateFileState,
});
@@ -188,7 +198,7 @@ describe("syncOpenFileFromWorkspace repo scoping", () => {
// open editor buffer carries that repo. Re-syncing must pass `repo` to the
// backend, otherwise it stats <workDir>/src/foo.ts (bare task root) and
// fails with "file not found" — the reported "Failed to edit" bug.
seedOpenFile({
const key = seedOpenFile({
content: "v1",
originalContent: "v1",
originalHash: "h:2:v1",
@@ -203,7 +213,9 @@ describe("syncOpenFileFromWorkspace repo scoping", () => {
await syncOpenFileFromWorkspace({
client: FAKE_CLIENT,
sessionId: SESSION_ID,
fileKey: key,
path: PATH,
repo: "enrichment-commons",
updateFileState,
});
@@ -219,16 +231,31 @@ describe("syncOpenFileFromWorkspace repo scoping", () => {
// The fetch is issued for repo "repoA". While it is in flight the same path
// key is reused for a file from "repoB". Writing repoA's content into the
// repoB buffer would be wrong, so the stale response must be discarded.
seedOpenFile({ content: "v1", originalContent: "v1", repo: "repoA" });
const key = seedOpenFile({ content: "v1", originalContent: "v1", repo: "repoA" });
mockRequestFileContent.mockImplementationOnce(async () => {
seedOpenFile({ content: "other", originalContent: "other", repo: "repoB" });
openFilesMap = new Map<string, FileEditorState>([
[
key,
{
path: PATH,
name: "foo.ts",
content: "other",
originalContent: "other",
originalHash: "h:5:other",
isDirty: false,
repo: "repoB",
},
],
]);
return { content: "repoA-content", is_binary: false, resolved_path: PATH };
});
await syncOpenFileFromWorkspace({
client: FAKE_CLIENT,
sessionId: SESSION_ID,
fileKey: key,
path: PATH,
repo: "repoA",
updateFileState,
});
@@ -236,7 +263,11 @@ describe("syncOpenFileFromWorkspace repo scoping", () => {
});
});
function makeStatus(files: Record<string, FileInfo>, timestamp: string): GitStatusEntry {
function makeStatus(
files: Record<string, FileInfo>,
timestamp: string,
repo?: string,
): GitStatusEntry {
return {
branch: "main",
remote_branch: null,
@@ -249,6 +280,7 @@ function makeStatus(files: Record<string, FileInfo>, timestamp: string): GitStat
behind: 0,
files,
timestamp,
repository_name: repo,
} as GitStatusEntry;
}
@@ -331,4 +363,40 @@ describe("useOpenFileWorkspaceSync", () => {
cleanup();
});
it("ignores sibling-repo git statuses for an open file at the same path", async () => {
seedOpenFile({
content: "user edits",
originalContent: "v1",
originalHash: "h:2:v1",
isDirty: true,
repo: "frontend",
});
const updateFileState = vi.fn();
mockRequestFileContent.mockResolvedValue({
content: "backend changed",
is_binary: false,
resolved_path: PATH,
});
const initialStatus = makeStatus({}, "2026-05-08T11:00:00.000Z", "frontend");
const { rerender } = renderSyncHook({
gitStatus: initialStatus,
openFiles: openFilesMap,
updateFileState,
});
const siblingRepoStatus = makeStatus(
{ [PATH]: modifiedFile("@@ -1 +1 @@\n-v1\n+backend") },
"2026-05-08T11:00:02.000Z",
"backend",
);
rerender({ gitStatus: siblingRepoStatus, openFiles: openFilesMap, updateFileState });
await Promise.resolve();
expect(mockRequestFileContent).not.toHaveBeenCalled();
expect(updateFileState).not.toHaveBeenCalled();
cleanup();
});
});
+23 -12
View File
@@ -30,7 +30,9 @@ export function buildGitFileSignature(file: FileInfo | undefined): string {
export type SyncOpenFileArgs = {
client: ReturnType<typeof getWebSocketClient>;
sessionId: string;
fileKey: string;
path: string;
repo?: string;
updateFileState: (path: string, updates: Partial<FileEditorState>) => void;
};
@@ -47,14 +49,15 @@ export type SyncOpenFileArgs = {
export async function syncOpenFileFromWorkspace({
client,
sessionId,
fileKey,
path,
repo,
updateFileState,
}: SyncOpenFileArgs): Promise<void> {
if (!client) return;
try {
const repo = useDockviewStore.getState().openFiles.get(path)?.repo;
const response = await requestFileContent(client, sessionId, path, repo);
const latest = useDockviewStore.getState().openFiles.get(path);
const latest = useDockviewStore.getState().openFiles.get(fileKey);
if (!latest) return;
// The tab may have been swapped to a different repo's file at the same path
// key while the fetch was in flight; the response is then for the old repo.
@@ -64,7 +67,7 @@ export async function syncOpenFileFromWorkspace({
if (latest.isDirty) {
if (response.content === latest.content) {
updateFileState(path, {
updateFileState(fileKey, {
originalContent: response.content,
originalHash: remoteHash,
isDirty: false,
@@ -72,11 +75,11 @@ export async function syncOpenFileFromWorkspace({
remoteContent: undefined,
remoteOriginalHash: undefined,
});
updatePanelAfterSave(path, latest.name);
updatePanelAfterSave(path, latest.name, latest.repo);
return;
}
if (latest.hasRemoteUpdate && latest.remoteContent === response.content) return;
updateFileState(path, {
updateFileState(fileKey, {
hasRemoteUpdate: true,
remoteContent: response.content,
remoteOriginalHash: remoteHash,
@@ -92,7 +95,7 @@ export async function syncOpenFileFromWorkspace({
return;
}
updateFileState(path, {
updateFileState(fileKey, {
content: response.content,
originalContent: response.content,
originalHash: remoteHash,
@@ -142,20 +145,28 @@ export function useOpenFileWorkspaceSync({
const gitFiles = gitStatus?.files ?? {};
const sigMap = gitFileSignaturesRef.current;
for (const [path, file] of openFiles.entries()) {
for (const [fileKey, file] of openFiles.entries()) {
if ((gitStatus?.repository_name ?? "") !== (file.repo ?? "")) continue;
// For symlinks, also check the resolved target path in git status
const gitFileInfo = (gitFiles[path] ??
const gitFileInfo = (gitFiles[file.path] ??
(file.resolvedPath ? gitFiles[file.resolvedPath] : undefined)) as FileInfo | undefined;
const nextSignature = buildGitFileSignature(gitFileInfo);
const prevSignature = sigMap.get(path);
const prevSignature = sigMap.get(fileKey);
if (prevSignature === undefined) {
sigMap.set(path, nextSignature);
sigMap.set(fileKey, nextSignature);
continue;
}
if (prevSignature === nextSignature) continue;
sigMap.set(path, nextSignature);
void syncOpenFileFromWorkspace({ client, sessionId, path, updateFileState });
sigMap.set(fileKey, nextSignature);
void syncOpenFileFromWorkspace({
client,
sessionId,
fileKey,
path: file.path,
repo: file.repo,
updateFileState,
});
}
}, [gitStatus, openFiles, updateFileState, activeSessionIdRef, gitFileSignaturesRef]);
}
@@ -15,7 +15,14 @@ vi.mock("@/lib/ws/connection", () => ({
getWebSocketClient: () => ({}),
}));
import { buildFileEditorState, fetchFileEditorState } from "./use-file-editors";
import {
buildFileEditorState,
fetchFileEditorState,
getPreviewItemIdToRemoveOnReplace,
isFileEditorPanelAlreadyRestored,
isRestoreWriteCurrent,
} from "./file-editor-state";
import { buildRepoScopedItemId, PREVIEW_FILE_EDITOR_ID } from "@/lib/state/dockview-panel-actions";
const PATH = "src/foo.ts";
const REPO = "enrichment-commons";
@@ -31,6 +38,14 @@ const FAKE_CLIENT = {} as NonNullable<
ReturnType<typeof import("@/lib/ws/connection").getWebSocketClient>
>;
function makeDockApi(panels: Record<string, { params?: Record<string, unknown> }>) {
return {
getPanel: vi.fn((id: string) => panels[id]),
} as unknown as ReturnType<
typeof import("@/lib/state/dockview-store").useDockviewStore.getState
>["api"];
}
describe("buildFileEditorState", () => {
it("carries the repo subpath so subsequent save/sync calls scope to the right repository", async () => {
// Multi-repo open: opening foo.ts from the "enrichment-commons" repo must
@@ -55,7 +70,13 @@ describe("fetchFileEditorState", () => {
mockRequestFileContent.mockResolvedValueOnce(RESPONSE);
const ref = { current: SESSION_ID };
const state = await fetchFileEditorState(FAKE_CLIENT, SESSION_ID, PATH, REPO, ref);
const state = await fetchFileEditorState({
client: FAKE_CLIENT,
sessionId: SESSION_ID,
filePath: PATH,
repo: REPO,
activeSessionIdRef: ref,
});
expect(mockRequestFileContent).toHaveBeenCalledWith(FAKE_CLIENT, SESSION_ID, PATH, REPO);
expect(state?.repo).toBe(REPO);
@@ -70,8 +91,136 @@ describe("fetchFileEditorState", () => {
return RESPONSE;
});
const state = await fetchFileEditorState(FAKE_CLIENT, SESSION_ID, PATH, undefined, ref);
const state = await fetchFileEditorState({
client: FAKE_CLIENT,
sessionId: SESSION_ID,
filePath: PATH,
repo: undefined,
activeSessionIdRef: ref,
});
expect(state).toBeNull();
});
it("returns null when another file in the same session became the latest request", async () => {
const fileKey = buildRepoScopedItemId(PATH, REPO);
const requestToken = { fileKey, generation: 1 };
const activeRequestRef = { current: requestToken };
mockRequestFileContent.mockImplementationOnce(async () => {
activeRequestRef.current = {
fileKey: buildRepoScopedItemId("src/bar.ts", REPO),
generation: 2,
};
return RESPONSE;
});
const state = await fetchFileEditorState({
client: FAKE_CLIENT,
sessionId: SESSION_ID,
filePath: PATH,
repo: REPO,
activeSessionIdRef: { current: SESSION_ID },
activeRequestRef,
requestToken,
});
expect(state).toBeNull();
});
it("returns null when a newer request for the same file superseded it", async () => {
const fileKey = buildRepoScopedItemId(PATH, REPO);
const requestToken = { fileKey, generation: 1 };
const activeRequestRef = { current: requestToken };
mockRequestFileContent.mockImplementationOnce(async () => {
activeRequestRef.current = { fileKey, generation: 2 };
return RESPONSE;
});
const state = await fetchFileEditorState({
client: FAKE_CLIENT,
sessionId: SESSION_ID,
filePath: PATH,
repo: REPO,
activeSessionIdRef: { current: SESSION_ID },
activeRequestRef,
requestToken,
});
expect(state).toBeNull();
});
});
describe("isRestoreWriteCurrent", () => {
it("rejects a restore write when the active session ref changed", () => {
expect(isRestoreWriteCurrent(SESSION_ID, SESSION_ID, { current: "sess-2" })).toBe(false);
});
it("allows a restore write only when both guards still match", () => {
expect(isRestoreWriteCurrent(SESSION_ID, SESSION_ID, { current: SESSION_ID })).toBe(true);
});
});
describe("isFileEditorPanelAlreadyRestored", () => {
it("detects legacy pinned editor ids when restored panel params match the repo file", () => {
const dockApi = makeDockApi({
[`file:${PATH}`]: { params: { path: PATH, repo: REPO } },
});
expect(isFileEditorPanelAlreadyRestored(dockApi, PATH, REPO)).toBe(true);
});
it("ignores a legacy bare-path panel when its repo params do not match", () => {
const dockApi = makeDockApi({
[`file:${PATH}`]: { params: { path: PATH, repo: "other-repo" } },
});
expect(isFileEditorPanelAlreadyRestored(dockApi, PATH, REPO)).toBe(false);
});
});
describe("getPreviewItemIdToRemoveOnReplace", () => {
const previousItemId = "frontend:README.md";
const nextItemId = "backend:README.md";
it("returns the previous preview item when an unpinned preview is replaced", () => {
const dockApi = makeDockApi({
[PREVIEW_FILE_EDITOR_ID]: { params: { previewItemId: previousItemId } },
});
expect(getPreviewItemIdToRemoveOnReplace(dockApi, nextItemId)).toBe(previousItemId);
});
it("keeps state when the previous preview was promoted for materialization", () => {
const dockApi = makeDockApi({
[PREVIEW_FILE_EDITOR_ID]: { params: { previewItemId: previousItemId, promoted: true } },
});
expect(getPreviewItemIdToRemoveOnReplace(dockApi, nextItemId)).toBeNull();
});
it("keeps state when a pinned panel owns the previous preview item", () => {
const dockApi = makeDockApi({
[PREVIEW_FILE_EDITOR_ID]: { params: { previewItemId: previousItemId } },
[`file:${previousItemId}`]: {},
});
expect(getPreviewItemIdToRemoveOnReplace(dockApi, nextItemId)).toBeNull();
});
it("keeps the current preview state when opening an already pinned next item", () => {
const dockApi = makeDockApi({
[PREVIEW_FILE_EDITOR_ID]: { params: { previewItemId: previousItemId } },
[`file:${nextItemId}`]: {},
});
expect(getPreviewItemIdToRemoveOnReplace(dockApi, nextItemId)).toBeNull();
});
it("does nothing when reopening the same preview item", () => {
const dockApi = makeDockApi({
[PREVIEW_FILE_EDITOR_ID]: { params: { previewItemId: previousItemId } },
});
expect(getPreviewItemIdToRemoveOnReplace(dockApi, previousItemId)).toBeNull();
});
});
@@ -0,0 +1,212 @@
import { renderHook, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { FileContentResponse } from "@/lib/types/backend";
import type { FileEditorState } from "@/lib/state/dockview-store";
const mocks = vi.hoisted(() => {
const fakeClient = {};
const toast = vi.fn();
const requestFileContent = vi.fn();
const addFileEditorPanel = vi.fn();
const promotePreviewToPinned = vi.fn();
type MockFileState = {
path: string;
repo?: string;
name: string;
content: string;
originalContent: string;
originalHash: string;
isDirty: boolean;
isBinary?: boolean;
markdownPreview?: boolean;
};
type DockState = {
api: null;
openFiles: Map<string, MockFileState>;
isRestoringLayout: boolean;
setFileState: (key: string, state: MockFileState) => void;
updateFileState: (key: string, updates: Partial<MockFileState>) => void;
removeFileState: (key: string) => void;
clearFileStates: () => void;
addFileEditorPanel: ReturnType<typeof vi.fn>;
promotePreviewToPinned: ReturnType<typeof vi.fn>;
};
let dockState: DockState;
const resetDockState = () => {
dockState = {
api: null,
openFiles: new Map(),
isRestoringLayout: false,
setFileState: vi.fn((key: string, state: MockFileState) => {
dockState.openFiles.set(key, state);
}),
updateFileState: vi.fn((key: string, updates: Partial<MockFileState>) => {
const current = dockState.openFiles.get(key);
if (current) dockState.openFiles.set(key, { ...current, ...updates });
}),
removeFileState: vi.fn((key: string) => {
dockState.openFiles.delete(key);
}),
clearFileStates: vi.fn(() => {
dockState.openFiles.clear();
}),
addFileEditorPanel,
promotePreviewToPinned,
};
};
resetDockState();
return {
fakeClient,
toast,
requestFileContent,
addFileEditorPanel,
promotePreviewToPinned,
getDockState: () => dockState,
resetDockState,
};
});
vi.mock("@/components/editors/monaco/monaco-init", () => ({
getMonacoInstance: () => null,
}));
vi.mock("@/components/state-provider", () => ({
useAppStore: (selector: (state: { tasks: { activeSessionId: string } }) => unknown) =>
selector({ tasks: { activeSessionId: "sess-1" } }),
}));
vi.mock("@/components/toast-provider", () => ({
useToast: () => ({ toast: mocks.toast }),
}));
vi.mock("@/hooks/domains/session/use-session-git-status", () => ({
useSessionGitStatus: () => null,
}));
vi.mock("@/lib/local-storage", () => ({
getOpenFileTabs: () => [],
setOpenFileTabs: vi.fn(),
getActiveTabForSession: () => "chat",
setActiveTabForSession: vi.fn(),
}));
vi.mock("@/lib/state/dockview-store", () => {
const useDockviewStore = Object.assign(
(selector: (state: ReturnType<typeof mocks.getDockState>) => unknown) =>
selector(mocks.getDockState()),
{
getState: () => mocks.getDockState(),
subscribe: vi.fn(() => vi.fn()),
},
);
return { useDockviewStore };
});
vi.mock("@/lib/utils/file-diff", () => ({
calculateHash: async (content: string) => `h:${content.length}`,
generateUnifiedDiff: vi.fn(),
}));
vi.mock("@/lib/ws/connection", () => ({
getWebSocketClient: () => mocks.fakeClient,
}));
vi.mock("@/lib/ws/workspace-files", () => ({
requestFileContent: (...args: unknown[]) => mocks.requestFileContent(...args),
updateFileContent: vi.fn(),
deleteFile: vi.fn(),
}));
vi.mock("./file-editors-sync", () => ({
useOpenFileWorkspaceSync: vi.fn(),
}));
import { useFileEditors } from "./use-file-editors";
const FIRST_PATH = "src/first.ts";
const SECOND_PATH = "src/second.ts";
function defer<T>() {
let resolve: (value: T) => void = () => undefined;
const promise = new Promise<T>((innerResolve) => {
resolve = innerResolve;
});
return { promise, resolve };
}
function fileState(path: string, content: string): FileEditorState {
return {
path,
name: path.split("/").pop() ?? path,
content,
originalContent: content,
originalHash: `h:${content.length}`,
isDirty: false,
};
}
function fileResponse(path: string, content: string): FileContentResponse {
return {
path,
content,
is_binary: false,
} as FileContentResponse;
}
describe("useFileEditors open actions", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.resetDockState();
});
it("keeps a pending file open request alive when focusing an already-open file", async () => {
const first = defer<FileContentResponse>();
mocks.requestFileContent.mockReturnValueOnce(first.promise);
const { result } = renderHook(() => useFileEditors());
mocks.getDockState().openFiles.set(SECOND_PATH, fileState(SECOND_PATH, "second"));
const pendingOpen = result.current.openFile(FIRST_PATH);
await waitFor(() => expect(mocks.requestFileContent).toHaveBeenCalledTimes(1));
await result.current.openFile(SECOND_PATH);
first.resolve(fileResponse(FIRST_PATH, "first"));
await pendingOpen;
expect(mocks.getDockState().openFiles.get(FIRST_PATH)?.content).toBe("first");
expect(mocks.addFileEditorPanel).toHaveBeenCalledWith(FIRST_PATH, "first.ts", {
repo: undefined,
});
});
it("keeps a pending markdown preview request alive when focusing an already-open file", async () => {
const first = defer<FileContentResponse>();
mocks.requestFileContent.mockReturnValueOnce(first.promise);
const { result } = renderHook(() => useFileEditors());
mocks.getDockState().openFiles.set(SECOND_PATH, fileState(SECOND_PATH, "second"));
const pendingOpen = result.current.openFileInMarkdownPreview(FIRST_PATH);
await waitFor(() => expect(mocks.requestFileContent).toHaveBeenCalledTimes(1));
await result.current.openFileInMarkdownPreview(SECOND_PATH);
first.resolve(fileResponse(FIRST_PATH, "first"));
await pendingOpen;
expect(mocks.getDockState().openFiles.get(FIRST_PATH)).toMatchObject({
content: "first",
markdownPreview: true,
});
expect(mocks.addFileEditorPanel).toHaveBeenCalledWith(FIRST_PATH, "first.ts", {
repo: undefined,
});
});
});
+218 -128
View File
@@ -14,11 +14,17 @@ import {
import { calculateHash } from "@/lib/utils/file-diff";
import { useToast } from "@/components/toast-provider";
import { useSessionGitStatus } from "@/hooks/domains/session/use-session-git-status";
import type { FileContentResponse } from "@/lib/types/backend";
import { useSaveDeleteActions } from "./use-file-save-delete";
import { PREVIEW_FILE_EDITOR_ID } from "@/lib/state/dockview-panel-actions";
import { buildRepoScopedItemId, PREVIEW_FILE_EDITOR_ID } from "@/lib/state/dockview-panel-actions";
import { useOpenFileWorkspaceSync } from "./file-editors-sync";
import { getMonacoInstance } from "@/components/editors/monaco/monaco-init";
import {
fetchFileEditorState,
getPreviewItemIdToRemoveOnReplace,
isFileEditorPanelAlreadyRestored,
isRestoreWriteCurrent,
type FileEditorRequestToken,
} from "./file-editor-state";
// Module-level guard: ensures restoration only runs once across all hook instances
let _restoredSessionId: string | null = null;
@@ -100,49 +106,6 @@ function getOpenFiles() {
return useDockviewStore.getState().openFiles;
}
/**
* Build a FileEditorState from a file content response. `repo` is the
* multi-repo subpath (repository_name) the file belongs to; it is recorded on
* the state so subsequent save/sync/delete requests stay scoped to the right
* repository instead of resolving against the bare task root.
*/
export async function buildFileEditorState(
filePath: string,
response: FileContentResponse,
repo?: string,
): Promise<FileEditorState> {
const fileName = filePath.split("/").pop() || filePath;
const hash = await calculateHash(response.content);
return {
path: filePath,
repo,
name: fileName,
content: response.content,
originalContent: response.content,
originalHash: hash,
isDirty: false,
isBinary: response.is_binary,
resolvedPath: response.resolved_path,
};
}
/**
* Fetch a file's content and build its editor state, returning null if the
* active session changed while the request was in flight — a late response must
* not write content for a file the user has navigated away from.
*/
export async function fetchFileEditorState(
client: NonNullable<ReturnType<typeof getWebSocketClient>>,
sessionId: string,
filePath: string,
repo: string | undefined,
activeSessionIdRef: React.MutableRefObject<string | null>,
): Promise<FileEditorState | null> {
const response = await requestFileContent(client, sessionId, filePath, repo);
if (activeSessionIdRef.current !== sessionId) return null;
return buildFileEditorState(filePath, response, repo);
}
/**
* Apply an editor buffer change, auto-promoting a preview tab to pinned on the
* first edit so the user's unsaved changes aren't discarded when another file
@@ -151,20 +114,22 @@ export async function fetchFileEditorState(
*/
function applyFileChange(
path: string,
repo: string | undefined,
newContent: string,
updateFileState: (path: string, updates: Partial<FileEditorState>) => void,
promotePreviewToPinned: (type: "file-editor") => void,
) {
const file = getOpenFiles().get(path);
const fileKey = buildRepoScopedItemId(path, repo);
const file = getOpenFiles().get(fileKey);
if (!file) return;
const nextIsDirty = newContent !== file.originalContent;
if (nextIsDirty && !file.isDirty) {
const preview = useDockviewStore.getState().api?.getPanel(PREVIEW_FILE_EDITOR_ID);
if ((preview?.params as Record<string, unknown> | undefined)?.previewItemId === path) {
if ((preview?.params as Record<string, unknown> | undefined)?.previewItemId === fileKey) {
promotePreviewToPinned("file-editor");
}
}
updateFileState(path, { content: newContent, isDirty: nextIsDirty });
updateFileState(fileKey, { content: newContent, isDirty: nextIsDirty });
}
/** Build the sessionStorage tab records from live openFiles + dockview state. */
@@ -174,11 +139,12 @@ function buildPersistedTabs(
) {
const preview = api?.getPanel(PREVIEW_FILE_EDITOR_ID);
const previewParams = preview?.params as Record<string, unknown> | undefined;
const previewPath = (previewParams?.previewItemId ?? null) as string | null;
const previewItemId = (previewParams?.previewItemId ?? null) as string | null;
const isPromoted = previewParams?.promoted === true;
return Array.from(openFiles.values()).flatMap(({ path, name, repo, markdownPreview }) => {
const isPinned = !!api?.getPanel(`file:${path}`);
const isPreview = !isPinned && path === previewPath;
const itemId = buildRepoScopedItemId(path, repo);
const isPinned = !!api?.getPanel(`file:${itemId}`);
const isPreview = !isPinned && itemId === previewItemId;
if (!isPinned && !isPreview) return [];
// Promoted previews persist as pinned so edits survive refresh
const persistAsPinned = isPinned || (isPreview && isPromoted);
@@ -196,6 +162,7 @@ function buildPersistedTabs(
type RestoreTabsParams = {
activeSessionId: string;
activeSessionIdRef: React.MutableRefObject<string | null>;
savedTabs: Array<{
path: string;
name: string;
@@ -212,27 +179,15 @@ type RestoreTabsParams = {
) => void;
};
/**
* Skip persisted tabs the dockview snapshot already restored (preview slot or
* existing pinned panel). Re-adding with `pin: true` here would create a
* duplicate `file:<path>` alongside the existing `preview:file-editor` —
* see the round-trip task switch where a promoted preview was persisted as
* pinned in localStorage and then re-opened on top of the restored preview.
*/
function isAlreadyRestored(
dockApi: ReturnType<typeof useDockviewStore.getState>["api"],
path: string,
): boolean {
if (!dockApi) return false;
if (dockApi.getPanel(`file:${path}`)) return true;
const previewParams = dockApi.getPanel(PREVIEW_FILE_EDITOR_ID)?.params as
| Record<string, unknown>
| undefined;
return previewParams?.previewItemId === path;
}
async function loadAndRestoreTabs(params: RestoreTabsParams, retryCount = 0): Promise<void> {
const { activeSessionId, savedTabs, savedActiveTab, setFileState, addFileEditorPanel } = params;
const {
activeSessionId,
activeSessionIdRef,
savedTabs,
savedActiveTab,
setFileState,
addFileEditorPanel,
} = params;
const client = getWebSocketClient();
if (!client) {
if (retryCount < 5) {
@@ -251,7 +206,8 @@ async function loadAndRestoreTabs(params: RestoreTabsParams, retryCount = 0): Pr
// FileEditorPanel retries when the executor becomes available.
const dockApi = useDockviewStore.getState().api;
for (const savedTab of savedTabs) {
if (isAlreadyRestored(dockApi, savedTab.path)) continue;
const itemId = buildRepoScopedItemId(savedTab.path, savedTab.repo);
if (isFileEditorPanelAlreadyRestored(dockApi, savedTab.path, savedTab.repo)) continue;
addFileEditorPanel(savedTab.path, savedTab.name, {
quiet: true,
pin: savedTab.pinned,
@@ -264,7 +220,7 @@ async function loadAndRestoreTabs(params: RestoreTabsParams, retryCount = 0): Pr
// setFileState (a wholesale replace), and useFileLoader's state has no
// markdownPreview — so when it wins the race (common under CPU load) the
// restored preview flag is clobbered and the tab reopens in code view.
setFileState(savedTab.path, {
setFileState(itemId, {
path: savedTab.path,
repo: savedTab.repo,
name: savedTab.name,
@@ -277,6 +233,7 @@ async function loadAndRestoreTabs(params: RestoreTabsParams, retryCount = 0): Pr
}
for (const savedTab of savedTabs) {
try {
const itemId = buildRepoScopedItemId(savedTab.path, savedTab.repo);
const response = await requestFileContent(
client,
activeSessionId,
@@ -284,7 +241,11 @@ async function loadAndRestoreTabs(params: RestoreTabsParams, retryCount = 0): Pr
savedTab.repo,
);
const hash = await calculateHash(response.content);
setFileState(savedTab.path, {
if (!isRestoreWriteCurrent(_restoredSessionId, activeSessionId, activeSessionIdRef)) {
if (_restoredSessionId === activeSessionId) _restorationInProgress = false;
return;
}
setFileState(itemId, {
path: savedTab.path,
repo: savedTab.repo,
name: savedTab.name,
@@ -342,6 +303,7 @@ function useFileEditorEffects({
}
void loadAndRestoreTabs({
activeSessionId,
activeSessionIdRef,
savedTabs,
savedActiveTab,
setFileState,
@@ -382,10 +344,10 @@ function useFileEditorEffects({
// pinned panel; wiping the file state here would drop the user's dirty
// buffer during auto-promote-on-edit).
if (event.id === PREVIEW_FILE_EDITOR_ID) {
const path = (event.params?.previewItemId as string | undefined) ?? null;
if (!path) return;
const pinnedStillOpen = !!api.getPanel(`file:${path}`);
if (!pinnedStillOpen) removeFileState(path);
const itemId = (event.params?.previewItemId as string | undefined) ?? null;
if (!itemId) return;
const pinnedStillOpen = !!api.getPanel(`file:${itemId}`);
if (!pinnedStillOpen) removeFileState(itemId);
}
});
return () => disposable.dispose();
@@ -396,6 +358,7 @@ type FileEditorActionsParams = {
activeSessionIdRef: React.MutableRefObject<string | null>;
setFileState: (path: string, state: FileEditorState) => void;
updateFileState: (path: string, updates: Partial<FileEditorState>) => void;
removeFileState: (path: string) => void;
addFileEditorPanel: (
path: string,
name: string,
@@ -406,40 +369,91 @@ type FileEditorActionsParams = {
toast: ReturnType<typeof useToast>["toast"];
};
function useFileEditorActions({
function addFileEditorPanelWithPreviewCleanup(
filePath: string,
name: string,
repo: string | undefined,
addFileEditorPanel: FileEditorActionsParams["addFileEditorPanel"],
removeFileState: FileEditorActionsParams["removeFileState"],
) {
const itemId = buildRepoScopedItemId(filePath, repo);
const previewItemIdToRemove = getPreviewItemIdToRemoveOnReplace(
useDockviewStore.getState().api,
itemId,
);
addFileEditorPanel(filePath, name, { repo });
if (previewItemIdToRemove) removeFileState(previewItemIdToRemove);
}
function markActiveFileRequest(
ref: React.MutableRefObject<FileEditorRequestToken | null>,
fileKey: string,
): FileEditorRequestToken {
const requestToken = {
fileKey,
generation: (ref.current?.generation ?? 0) + 1,
};
ref.current = requestToken;
return requestToken;
}
type OpenFileActionParams = Pick<
FileEditorActionsParams,
"activeSessionIdRef" | "setFileState" | "removeFileState" | "addFileEditorPanel" | "toast"
> & {
activeFileRequestRef: React.MutableRefObject<FileEditorRequestToken | null>;
};
function useOpenFileAction({
activeSessionIdRef,
activeFileRequestRef,
setFileState,
updateFileState,
removeFileState,
addFileEditorPanel,
promotePreviewToPinned,
setSavingFiles,
toast,
}: FileEditorActionsParams) {
const openFile = useCallback(
}: OpenFileActionParams) {
return useCallback(
async (filePath: string, repo?: string) => {
const client = getWebSocketClient();
const currentSessionId = activeSessionIdRef.current;
if (!client || !currentSessionId) return;
const fileKey = buildRepoScopedItemId(filePath, repo);
const files = getOpenFiles();
if (files.has(filePath)) {
if (files.has(fileKey)) {
const existing = files.get(fileKey);
const tabName = filePath.split("/").pop() || filePath;
addFileEditorPanel(filePath, tabName, { repo: files.get(filePath)?.repo });
addFileEditorPanelWithPreviewCleanup(
filePath,
tabName,
existing?.repo,
addFileEditorPanel,
removeFileState,
);
return;
}
const requestToken = markActiveFileRequest(activeFileRequestRef, fileKey);
try {
const state = await fetchFileEditorState(
const state = await fetchFileEditorState({
client,
currentSessionId,
sessionId: currentSessionId,
filePath,
repo,
activeSessionIdRef,
);
activeRequestRef: activeFileRequestRef,
requestToken,
});
if (!state) return;
// Create the panel BEFORE setting file state. The openFiles subscription
// triggers tab persistence — it needs the dockview panel to already exist
// so buildPersistedTabs can detect whether the file is preview or pinned.
addFileEditorPanel(filePath, state.name, { repo });
setFileState(filePath, state);
addFileEditorPanelWithPreviewCleanup(
filePath,
state.name,
repo,
addFileEditorPanel,
removeFileState,
);
setFileState(fileKey, state);
} catch (error) {
toast({
title: "Failed to open file",
@@ -448,12 +462,121 @@ function useFileEditorActions({
});
}
},
[activeSessionIdRef, addFileEditorPanel, setFileState, toast],
[
activeSessionIdRef,
activeFileRequestRef,
addFileEditorPanel,
removeFileState,
setFileState,
toast,
],
);
}
type MarkdownPreviewActionParams = Pick<
FileEditorActionsParams,
| "activeSessionIdRef"
| "setFileState"
| "updateFileState"
| "removeFileState"
| "addFileEditorPanel"
| "toast"
> & {
activeFileRequestRef: React.MutableRefObject<FileEditorRequestToken | null>;
};
function useMarkdownPreviewAction({
activeSessionIdRef,
activeFileRequestRef,
setFileState,
updateFileState,
removeFileState,
addFileEditorPanel,
toast,
}: MarkdownPreviewActionParams) {
return useCallback(
async (filePath: string, repo?: string) => {
const client = getWebSocketClient();
const currentSessionId = activeSessionIdRef.current;
if (!client || !currentSessionId) return;
const fileKey = buildRepoScopedItemId(filePath, repo);
const files = getOpenFiles();
if (files.has(fileKey)) {
updateFileState(fileKey, { markdownPreview: true });
const name = filePath.split("/").pop() || filePath;
addFileEditorPanelWithPreviewCleanup(
filePath,
name,
files.get(fileKey)?.repo,
addFileEditorPanel,
removeFileState,
);
return;
}
const requestToken = markActiveFileRequest(activeFileRequestRef, fileKey);
try {
const state = await fetchFileEditorState({
client,
sessionId: currentSessionId,
filePath,
repo,
activeSessionIdRef,
activeRequestRef: activeFileRequestRef,
requestToken,
});
if (!state) return;
addFileEditorPanelWithPreviewCleanup(
filePath,
state.name,
repo,
addFileEditorPanel,
removeFileState,
);
setFileState(fileKey, { ...state, markdownPreview: true });
} catch (error) {
toast({
title: "Failed to open file",
description: error instanceof Error ? error.message : "Unknown error",
variant: "error",
});
}
},
[activeSessionIdRef, setFileState, updateFileState, addFileEditorPanel, removeFileState, toast],
);
}
function useFileEditorActions({
activeSessionIdRef,
setFileState,
updateFileState,
removeFileState,
addFileEditorPanel,
promotePreviewToPinned,
setSavingFiles,
toast,
}: FileEditorActionsParams) {
const activeFileRequestRef = useRef<FileEditorRequestToken | null>(null);
const openFile = useOpenFileAction({
activeSessionIdRef,
activeFileRequestRef,
setFileState,
removeFileState,
addFileEditorPanel,
toast,
});
const openFileInMarkdownPreview = useMarkdownPreviewAction({
activeSessionIdRef,
activeFileRequestRef,
setFileState,
updateFileState,
removeFileState,
addFileEditorPanel,
toast,
});
const handleFileChange = useCallback(
(path: string, newContent: string) =>
applyFileChange(path, newContent, updateFileState, promotePreviewToPinned),
(path: string, newContent: string, repo?: string) =>
applyFileChange(path, repo, newContent, updateFileState, promotePreviewToPinned),
[updateFileState, promotePreviewToPinned],
);
@@ -464,40 +587,6 @@ function useFileEditorActions({
toast,
});
const openFileInMarkdownPreview = useCallback(
async (filePath: string, repo?: string) => {
const client = getWebSocketClient();
const currentSessionId = activeSessionIdRef.current;
if (!client || !currentSessionId) return;
const files = getOpenFiles();
if (files.has(filePath)) {
updateFileState(filePath, { markdownPreview: true });
const name = filePath.split("/").pop() || filePath;
addFileEditorPanel(filePath, name, { repo: files.get(filePath)?.repo });
return;
}
try {
const state = await fetchFileEditorState(
client,
currentSessionId,
filePath,
repo,
activeSessionIdRef,
);
if (!state) return;
addFileEditorPanel(filePath, state.name, { repo });
setFileState(filePath, { ...state, markdownPreview: true });
} catch (error) {
toast({
title: "Failed to open file",
description: error instanceof Error ? error.message : "Unknown error",
variant: "error",
});
}
},
[activeSessionIdRef, setFileState, updateFileState, addFileEditorPanel, toast],
);
return {
openFile,
openFileInMarkdownPreview,
@@ -556,6 +645,7 @@ export function useFileEditors() {
activeSessionIdRef,
setFileState,
updateFileState,
removeFileState,
addFileEditorPanel,
promotePreviewToPinned,
setSavingFiles,
+5 -3
View File
@@ -29,6 +29,7 @@ vi.mock("@/lib/utils/file-diff", () => ({
}));
import { useSaveDeleteActions, type SaveDeleteParams } from "./use-file-save-delete";
import { buildRepoScopedItemId } from "@/lib/state/dockview-panel-actions";
const FAKE_CLIENT = {} as ReturnType<typeof import("@/lib/ws/connection").getWebSocketClient>;
const SESSION_ID = "sess-1";
@@ -36,9 +37,10 @@ const PATH = "src/foo.ts";
const REPO = "enrichment-commons";
function seedOpenFile(state: Partial<FileEditorState> = {}) {
const key = buildRepoScopedItemId(PATH, state.repo);
openFilesMap = new Map<string, FileEditorState>([
[
PATH,
key,
{
path: PATH,
name: "foo.ts",
@@ -80,7 +82,7 @@ describe("useSaveDeleteActions repo threading", () => {
const { result } = renderActions();
await act(async () => {
await result.current.saveFile(PATH);
await result.current.saveFile(PATH, REPO);
});
expect(mockUpdateFileContent).toHaveBeenCalledWith(
@@ -96,7 +98,7 @@ describe("useSaveDeleteActions repo threading", () => {
const { result } = renderActions();
await act(async () => {
await result.current.deleteFileAction(PATH);
await result.current.deleteFileAction(PATH, REPO);
});
expect(mockDeleteFile).toHaveBeenCalledWith(FAKE_CLIENT, SESSION_ID, PATH, REPO);
+32 -24
View File
@@ -6,7 +6,7 @@ import { getWebSocketClient } from "@/lib/ws/connection";
import { updateFileContent, deleteFile } from "@/lib/ws/workspace-files";
import { generateUnifiedDiff, calculateHash } from "@/lib/utils/file-diff";
import type { useToast } from "@/components/toast-provider";
import { PREVIEW_FILE_EDITOR_ID } from "@/lib/state/dockview-panel-actions";
import { buildRepoScopedItemId, PREVIEW_FILE_EDITOR_ID } from "@/lib/state/dockview-panel-actions";
/** Read openFiles from the store without subscribing to changes. */
function getOpenFiles() {
@@ -14,13 +14,14 @@ function getOpenFiles() {
}
/** Update dockview panel dirty state after a successful save. */
export function updatePanelAfterSave(path: string, name: string) {
export function updatePanelAfterSave(path: string, name: string, repo?: string) {
const dockApi = useDockviewStore.getState().api;
const itemId = buildRepoScopedItemId(path, repo);
const panel =
dockApi?.getPanel(`file:${path}`) ??
dockApi?.getPanel(`file:${itemId}`) ??
(() => {
const preview = dockApi?.getPanel(PREVIEW_FILE_EDITOR_ID);
return (preview?.params as Record<string, unknown> | undefined)?.previewItemId === path
return (preview?.params as Record<string, unknown> | undefined)?.previewItemId === itemId
? preview
: undefined;
})();
@@ -31,15 +32,16 @@ export function updatePanelAfterSave(path: string, name: string) {
}
/** Close the pinned (or preview) editor panel for a path after a remote delete. */
function closeFileEditorPanel(path: string) {
function closeFileEditorPanel(path: string, repo?: string) {
const dockApi = useDockviewStore.getState().api;
const pinned = dockApi?.getPanel(`file:${path}`);
const itemId = buildRepoScopedItemId(path, repo);
const pinned = dockApi?.getPanel(`file:${itemId}`);
if (pinned) {
dockApi?.removePanel(pinned);
return;
}
const preview = dockApi?.getPanel(PREVIEW_FILE_EDITOR_ID);
if (preview && (preview.params as Record<string, unknown>)?.previewItemId === path) {
if (preview && (preview.params as Record<string, unknown>)?.previewItemId === itemId) {
dockApi?.removePanel(preview);
}
}
@@ -51,17 +53,18 @@ export type SaveDeleteParams = {
toast: ReturnType<typeof useToast>["toast"];
};
async function performSaveFile(path: string, params: SaveDeleteParams) {
const file = getOpenFiles().get(path);
async function performSaveFile(path: string, repo: string | undefined, params: SaveDeleteParams) {
const fileKey = buildRepoScopedItemId(path, repo);
const file = getOpenFiles().get(fileKey);
if (!file || !file.isDirty) return;
const client = getWebSocketClient();
const currentSessionId = params.activeSessionIdRef.current;
if (!client || !currentSessionId) return;
params.setSavingFiles((prev) => new Set(prev).add(path));
params.setSavingFiles((prev) => new Set(prev).add(fileKey));
try {
const diff = generateUnifiedDiff(file.originalContent, file.content, file.path);
const response = await updateFileContent(client, currentSessionId, {
path,
path: file.path,
diff,
originalHash: file.originalHash,
desiredContent: file.content,
@@ -70,9 +73,9 @@ async function performSaveFile(path: string, params: SaveDeleteParams) {
if (response.success && response.new_hash) {
// Re-read current state: user may have typed more while the save was
// in flight. Only mark clean if content still matches what was saved.
const current = getOpenFiles().get(path);
const current = getOpenFiles().get(fileKey);
const stillClean = current?.content === file.content;
params.updateFileState(path, {
params.updateFileState(fileKey, {
originalContent: file.content,
originalHash: response.new_hash,
isDirty: !stillClean,
@@ -80,7 +83,7 @@ async function performSaveFile(path: string, params: SaveDeleteParams) {
remoteContent: undefined,
remoteOriginalHash: undefined,
});
if (stillClean) updatePanelAfterSave(path, file.name);
if (stillClean) updatePanelAfterSave(file.path, file.name, file.repo);
if (response.resolution === "overwritten") {
params.toast({
title: "File saved (overwritten)",
@@ -105,7 +108,7 @@ async function performSaveFile(path: string, params: SaveDeleteParams) {
} finally {
params.setSavingFiles((prev) => {
const next = new Set(prev);
next.delete(path);
next.delete(fileKey);
return next;
});
}
@@ -114,16 +117,20 @@ async function performSaveFile(path: string, params: SaveDeleteParams) {
export function useSaveDeleteActions(params: SaveDeleteParams) {
const { activeSessionIdRef, updateFileState, toast } = params;
const saveFile = useCallback((path: string) => performSaveFile(path, params), [params]);
const saveFile = useCallback(
(path: string, repo?: string) => performSaveFile(path, repo, params),
[params],
);
const deleteFileAction = useCallback(
async (path: string) => {
async (path: string, repo?: string) => {
const client = getWebSocketClient();
const currentSessionId = activeSessionIdRef.current;
if (!client || !currentSessionId) return;
try {
const repo = getOpenFiles().get(path)?.repo;
const response = await deleteFile(client, currentSessionId, path, repo);
const fileKey = buildRepoScopedItemId(path, repo);
const fileRepo = getOpenFiles().get(fileKey)?.repo ?? repo;
const response = await deleteFile(client, currentSessionId, path, fileRepo);
if (!response.success) {
toast({
title: "Delete failed",
@@ -142,17 +149,18 @@ export function useSaveDeleteActions(params: SaveDeleteParams) {
return;
}
// Close the panel only after the remote delete succeeds.
closeFileEditorPanel(path);
closeFileEditorPanel(path, repo);
},
[activeSessionIdRef, toast],
);
const applyRemoteUpdate = useCallback(
async (path: string) => {
const file = getOpenFiles().get(path);
async (path: string, repo?: string) => {
const fileKey = buildRepoScopedItemId(path, repo);
const file = getOpenFiles().get(fileKey);
if (!file || !file.hasRemoteUpdate || file.remoteContent === undefined) return;
const remoteHash = file.remoteOriginalHash ?? (await calculateHash(file.remoteContent));
updateFileState(path, {
updateFileState(fileKey, {
content: file.remoteContent,
originalContent: file.remoteContent,
originalHash: remoteHash,
@@ -161,7 +169,7 @@ export function useSaveDeleteActions(params: SaveDeleteParams) {
remoteContent: undefined,
remoteOriginalHash: undefined,
});
updatePanelAfterSave(path, file.name);
updatePanelAfterSave(file.path, file.name, file.repo);
},
[updateFileState],
);
@@ -125,8 +125,10 @@ function build(api: DockviewApi) {
const PATH_A = "src/a.ts";
const PATH_B = "src/b.ts";
const PATH_NESTED_B = "src/nested/b.ts";
const SHARED_PATH = "README.md";
const NAME_A = "a.ts";
const NAME_B = "b.ts";
const SHARED_NAME = "README.md";
const PINNED_FILE_A_ID = "file:src/a.ts";
const PREVIEW_FILE_ID = "preview:file-editor";
const PREVIEW_DIFF_ID = "preview:file-diff";
@@ -270,6 +272,21 @@ describe("addFileEditorPanel — preview behavior", () => {
});
});
describe("addFileEditorPanel — multi-repo identity", () => {
it("does not reuse a pinned editor for the same path in a different repo", () => {
const { api, actions } = build(makeApi());
actions.addFileEditorPanel(SHARED_PATH, SHARED_NAME, { pin: true, repo: "frontend" });
actions.addFileEditorPanel(SHARED_PATH, SHARED_NAME, { repo: "backend" });
const preview = api.getPanel(PREVIEW_FILE_ID) as unknown as MockPanel;
expect(preview).toBeDefined();
expect(preview.params.path).toBe(SHARED_PATH);
expect(preview.params.repo).toBe("backend");
expect(preview.isActive).toBe(true);
});
});
describe("addFileDiffPanel — preview behavior", () => {
let api: DockviewApi;
let actions: ReturnType<typeof buildPanelActions>;
@@ -312,6 +329,18 @@ describe("addFileDiffPanel — preview behavior", () => {
expect(preview.params.path).toBe(PATH_B);
});
it("does not reuse a pinned diff for the same path in a different repo", () => {
actions.addFileDiffPanel(SHARED_PATH, { pin: true, repositoryName: "frontend" });
actions.addFileDiffPanel(SHARED_PATH, { repositoryName: "backend" });
const preview = api.getPanel(PREVIEW_DIFF_ID) as unknown as MockPanel;
expect(preview).toBeDefined();
expect(preview.params.path).toBe(SHARED_PATH);
expect(preview.params.repositoryName).toBe("backend");
expect(preview.isActive).toBe(true);
});
it("promotePreviewToPinned sets promoted flag on the preview diff", () => {
actions.addFileDiffPanel(PATH_A);
actions.promotePreviewToPinned("file-diff");
+8 -2
View File
@@ -67,6 +67,10 @@ function getFileName(path: string): string {
return path.split("/").pop() || path;
}
export function buildRepoScopedItemId(path: string, repo?: string): string {
return repo ? `${repo}:${path}` : path;
}
type OpenPreviewArgs = {
api: DockviewApi;
type: PreviewType;
@@ -263,10 +267,11 @@ function buildFileEditorAction(get: StoreGet) {
return (path: string, name: string, opts?: OpenPanelOpts) => {
const { api, centerGroupId } = get();
if (!api) return;
const itemId = buildRepoScopedItemId(path, opts?.repo);
openOrReplacePreview({
api,
type: "file-editor",
itemId: path,
itemId,
title: name,
params: { path, ...(opts?.repo ? { repo: opts.repo } : {}) },
groupId: centerGroupId,
@@ -289,10 +294,11 @@ function buildFileDiffAction(get: StoreGet) {
) => {
const { api, centerGroupId } = get();
if (!api) return;
const itemId = buildRepoScopedItemId(path, opts?.repositoryName);
openOrReplacePreview({
api,
type: "file-diff",
itemId: path,
itemId,
title: `Diff [${getFileName(path)}]`,
params: {
kind: "file",
+6 -5
View File
@@ -902,13 +902,14 @@ export const useDockviewStore = create<DockviewStore>((set, get) => ({
if (api) {
const resolveFilePath = (panelId: string | undefined): string | null => {
if (!panelId) return null;
const panelPath = (api.getPanel(panelId)?.params as Record<string, unknown> | undefined)
?.path as string | undefined;
if (panelPath) return panelPath;
// Legacy bare-path panel IDs did not encode repository scope. Modern
// file/diff panels resolve through params above, so slicing is only a
// fallback for old non-repo-scoped IDs without params.
if (panelId.startsWith("file:")) return panelId.slice(5);
if (panelId.startsWith("diff:file:")) return panelId.slice("diff:file:".length);
if (panelId === "preview:file-editor" || panelId === "preview:file-diff") {
const path = (api.getPanel(panelId)?.params as Record<string, unknown> | undefined)
?.path as string | undefined;
return path ?? null;
}
return null;
};
api.onDidActivePanelChange((event) => {