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:
@@ -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;
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user