Files
kandev/apps/web/lib/state/dockview-panel-actions.ts
T

682 lines
22 KiB
TypeScript

import type { DockviewApi, DockviewGroupPanel } from "dockview-react";
import type { CommitDetailTarget } from "@/components/task/changes-diff-target";
import { t } from "@/lib/i18n";
import { focusOrAddPanel } from "./dockview-layout-builders";
import {
parsePluginPanelId,
pluginPanelId,
PLUGIN_PANEL_COMPONENT,
PLUGIN_PANEL_TAB_COMPONENT,
} from "./layout-manager/plugin-panels";
type StoreGet = () => {
api: DockviewApi | null;
centerGroupId: string;
rightTopGroupId: string;
rightBottomGroupId: string;
selectedDiff: { path: string; content?: string } | null;
};
type StoreSet = (
partial: Partial<{ selectedDiff: { path: string; content?: string } | null }>,
) => void;
type SimplePanelOpts = {
id: string;
component: string;
title: string;
tabComponent?: string;
params?: Record<string, unknown>;
};
function addSimplePanel(api: DockviewApi, groupId: string, opts: SimplePanelOpts): void {
focusOrAddPanel(api, { ...opts, position: { referenceGroup: groupId } });
}
function openBrowserPanel(api: DockviewApi, centerGroupId: string, url: string): void {
const browserPanel =
(api.activePanel?.api.component === "browser" ? api.activePanel : undefined) ??
api.panels.find((panel) => panel.api.component === "browser");
if (browserPanel) {
browserPanel.api.updateParameters({ url });
browserPanel.api.setActive();
return;
}
focusOrAddPanel(api, {
id: `browser:${url}`,
component: "browser",
title: t("task:browser"),
params: { url },
position: { referenceGroup: centerGroupId },
});
}
type SidePanelOpts = { groupId?: string; quiet?: boolean; inCenter?: boolean };
/**
* Shared placement logic for a single-instance "side" panel (Plan, a plugin
* task panel, ...): an explicit `groupId` wins, `inCenter` falls back to the
* center group, and otherwise the panel opens beside the chat panel. Extracted
* from `addPlanPanel` so `addPluginPanel` can reuse the identical placement
* rules instead of re-deriving them.
*/
function addSidePanel(
api: DockviewApi,
centerGroupId: string,
panel: SimplePanelOpts,
opts?: SidePanelOpts,
): void {
const groupId = opts?.groupId ?? (opts?.inCenter ? centerGroupId : undefined);
const position = groupId
? { referenceGroup: groupId }
: { referencePanel: "chat" as const, direction: "right" as const };
focusOrAddPanel(api, { ...panel, position }, opts?.quiet ?? false);
}
function focusMatchingLegacyPanel(
api: DockviewApi,
keyedPanelId: string,
legacyPanelId: string,
paramName: string,
key: string,
): boolean {
if (api.getPanel(keyedPanelId)) return false;
const legacy = api.getPanel(legacyPanelId);
const legacyKey = (legacy?.params as Record<string, unknown> | undefined)?.[paramName];
if (!legacy || legacyKey !== key) return false;
legacy.api.setActive();
return true;
}
// ---------------------------------------------------------------------------
// Preview-tab machinery
// ---------------------------------------------------------------------------
/** Preview types that support single-tab (VSCode-style) behavior. */
export type PreviewType = "file-editor" | "file-diff" | "commit-detail";
type PreviewSpec = {
/** Stable id for the preview panel (only one per type). */
previewId: string;
/** Dockview `component` key used for rendering. */
component: string;
/** Tab component used for preview tabs (italic title, double-click to pin). */
previewTabComponent: string;
/** Compute the per-item pinned panel id. */
pinnedId: (itemId: string) => string;
};
const PREVIEW_SPECS: Record<PreviewType, PreviewSpec> = {
"file-editor": {
previewId: "preview:file-editor",
component: "file-editor",
previewTabComponent: "previewFileTab",
pinnedId: (path) => `file:${path}`,
},
"file-diff": {
previewId: "preview:file-diff",
component: "diff-viewer",
previewTabComponent: "previewDiffTab",
pinnedId: (path) => `diff:file:${path}`,
},
"commit-detail": {
previewId: "preview:commit-detail",
component: "commit-detail",
previewTabComponent: "previewCommitTab",
pinnedId: (sha) => `commit:${sha}`,
},
};
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;
/** Stable identifier for the item (path / sha). Used to compute pinnedId and detect no-op. */
itemId: string;
/** Title rendered on the tab. */
title: string;
/** Params to pass to the panel component (path, sha, kind, etc.). */
params: Record<string, unknown>;
/** Group to place the preview in when it is first created. */
groupId: string;
/** `quiet: true` keeps the currently active panel focused. */
quiet?: boolean;
/** `pin: true` forces the per-item pinned id instead of the preview slot. */
pin?: boolean;
/** Custom tab component for pinned opens (falls back to default dockview tab). */
pinnedTabComponent?: string;
};
/** Move the preview panel into the explicitly requested group when it currently
* lives elsewhere — e.g. a saved env layout restored `preview:file-diff` into
* the right column, but the user just clicked a file in the changes panel
* expecting the diff to land in the center group. */
function movePreviewToRequestedGroup(
preview: ReturnType<DockviewApi["getPanel"]> & object,
api: DockviewApi,
groupId: string,
): void {
if (!groupId || preview.group?.id === groupId) return;
const target = api.getGroup(groupId);
if (!target) return;
// `api.getGroup` returns `IDockviewGroupPanel` but `moveTo` requires the
// concrete `DockviewGroupPanel`; at runtime they're the same object.
preview.api.moveTo({ group: target as DockviewGroupPanel });
}
/** Update an existing preview panel with new content, materializing promoted items first. */
function updateExistingPreview(
preview: ReturnType<DockviewApi["getPanel"]> & object,
args: OpenPreviewArgs,
): void {
const { api, type, itemId, title, params, groupId, quiet, pinnedTabComponent } = args;
const currentItemId = preview.params?.previewItemId as string | undefined;
if (preview.params?.promoted && currentItemId && currentItemId !== itemId) {
materializePromotedPreview(api, type, pinnedTabComponent ?? PINNED_TAB);
}
// Preserve the promoted flag when re-opening the same item; clear it when
// switching to a different item (the old promoted file was already
// materialized above).
const keepPromoted = currentItemId === itemId && !!preview.params?.promoted;
preview.api.updateParameters({
...params,
previewItemId: itemId,
promoted: keepPromoted || undefined,
});
preview.setTitle(title);
movePreviewToRequestedGroup(preview, api, groupId);
if (!quiet) preview.api.setActive();
}
/**
* Open the single "preview" panel for a given content type, VSCode-style.
*
* Lookup rules:
* 1. If a pinned panel for the item already exists → focus it.
* 2. Else if a preview panel for the type exists and already shows the
* item → focus it.
* 3. Else if a preview panel for the type exists → replace its content
* (title + params) and focus it.
* 4. Else → create a new preview panel.
*/
function openOrReplacePreview(args: OpenPreviewArgs): void {
const { api, type, itemId, title, params, groupId, quiet, pin, pinnedTabComponent } = args;
const spec = PREVIEW_SPECS[type];
const pinnedId = spec.pinnedId(itemId);
// Always prefer an existing pinned panel for this item — never disturb it.
const pinned = api.getPanel(pinnedId);
if (pinned) {
if (!quiet) pinned.api.setActive();
return;
}
if (pin) {
focusOrAddPanel(
api,
{
id: pinnedId,
component: spec.component,
title,
params,
...(pinnedTabComponent ? { tabComponent: pinnedTabComponent } : {}),
position: { referenceGroup: groupId },
},
quiet,
);
return;
}
const preview = api.getPanel(spec.previewId);
if (preview) {
updateExistingPreview(preview, args);
return;
}
focusOrAddPanel(
api,
{
id: spec.previewId,
component: spec.component,
title,
tabComponent: spec.previewTabComponent,
params: { ...params, previewItemId: itemId },
position: { referenceGroup: groupId },
},
quiet,
);
}
/**
* Mark the current preview panel as "promoted" (VSCode-style keep-open).
*
* This does NOT swap panels — it sets a `promoted` flag on the preview's
* params so the tab renders as non-italic (pinned look) while the editor
* stays mounted (no remount, no focus loss).
*
* The actual panel swap (materialization) happens lazily when the user opens
* a different file via {@link openOrReplacePreview}.
*/
export function promotePreviewToPinned(api: DockviewApi, type: PreviewType): void {
const spec = PREVIEW_SPECS[type];
const preview = api.getPanel(spec.previewId);
if (!preview || preview.params?.promoted) return;
preview.api.updateParameters({ ...(preview.params ?? {}), promoted: true });
}
/**
* Materialize a promoted preview into a proper pinned panel.
*
* Called internally by {@link openOrReplacePreview} when the preview slot is
* needed for a new item and the current preview was promoted. Also available
* for explicit "pin now" actions where an immediate panel swap is acceptable
* (e.g. the user is navigating away anyway).
*/
function materializePromotedPreview(
api: DockviewApi,
type: PreviewType,
pinnedTabComponent?: string,
): void {
const spec = PREVIEW_SPECS[type];
const preview = api.getPanel(spec.previewId);
if (!preview) return;
const itemId = preview.params?.previewItemId as string | undefined;
if (!itemId) return;
const pinnedId = spec.pinnedId(itemId);
if (api.getPanel(pinnedId)) return; // Already materialized
const groupId = preview.group?.id;
const title = preview.title;
const params = { ...(preview.params ?? {}) } as Record<string, unknown>;
delete params.previewItemId;
delete params.promoted;
focusOrAddPanel(
api,
{
id: pinnedId,
component: spec.component,
title,
params,
...(pinnedTabComponent ? { tabComponent: pinnedTabComponent } : {}),
...(groupId ? { position: { referenceGroup: groupId } } : {}),
},
true, // quiet — don't steal focus
);
}
export type OpenPanelOpts = {
/** Don't steal focus from the active panel. */
quiet?: boolean;
/** Force the per-item pinned panel instead of the shared preview slot. */
pin?: boolean;
/** Multi-repo subpath (repository_name) recorded on the panel params so
* FileEditorPanel's fallback content fetch resolves under the right repo. */
repo?: string;
};
export const PREVIEW_FILE_EDITOR_ID = "preview:file-editor";
const PINNED_TAB = "pinnedDefaultTab";
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,
title: name,
params: { path, ...(opts?.repo ? { repo: opts.repo } : {}) },
groupId: centerGroupId,
quiet: opts?.quiet,
pin: opts?.pin,
pinnedTabComponent: PINNED_TAB,
});
};
}
function buildFileDiffAction(get: StoreGet) {
return (
path: string,
opts?: OpenPanelOpts & {
content?: string;
groupId?: string;
source?: string;
repositoryName?: string;
prKey?: string;
},
) => {
const { api, centerGroupId } = get();
if (!api) return;
const itemId = buildRepoScopedItemId(path, opts?.prKey ?? opts?.repositoryName);
openOrReplacePreview({
api,
type: "file-diff",
itemId,
title: `Diff [${getFileName(path)}]`,
params: {
kind: "file",
path,
content: opts?.content,
source: opts?.source,
repositoryName: opts?.repositoryName,
prKey: opts?.prKey,
},
groupId: opts?.groupId ?? centerGroupId,
quiet: opts?.quiet,
pin: opts?.pin,
pinnedTabComponent: PINNED_TAB,
});
};
}
function buildCommitItemId(
requestedTarget: CommitDetailTarget | string,
target: CommitDetailTarget,
legacyRepo?: string,
): string {
if (typeof requestedTarget === "string") {
return legacyRepo ? `${legacyRepo}:${requestedTarget}` : requestedTarget;
}
if (target.source === "local") return `local:${target.repo ?? ""}:${target.sha}`;
return `github:${target.workspaceId}:${target.owner}/${target.repo}:${target.sha}`;
}
function buildCommitDetailAction(get: StoreGet) {
return (
requestedTarget: CommitDetailTarget | string,
opts?: OpenPanelOpts & { groupId?: string; repo?: string },
) => {
const { api, centerGroupId } = get();
if (!api) return;
const target: CommitDetailTarget =
typeof requestedTarget === "string"
? {
source: "local",
sha: requestedTarget,
...(opts?.repo ? { repo: opts.repo } : {}),
}
: requestedTarget;
// Preserve the legacy string-call identity for saved callers/tests; new
// discriminated targets include provenance and repository identity.
const itemId = buildCommitItemId(requestedTarget, target, opts?.repo);
openOrReplacePreview({
api,
type: "commit-detail",
itemId,
title: target.sha.slice(0, 7),
params: {
target,
commitSha: target.sha,
...(target.source === "local" && target.repo ? { repo: target.repo } : {}),
},
groupId: opts?.groupId ?? centerGroupId,
quiet: opts?.quiet,
pin: opts?.pin,
pinnedTabComponent: PINNED_TAB,
});
};
}
export function buildPanelActions(set: StoreSet, get: StoreGet) {
return {
addChatPanel: () => {
const { api, centerGroupId } = get();
if (!api) return;
focusOrAddPanel(api, {
id: "chat",
component: "chat",
tabComponent: "permanentTab",
title: "Agent",
position: { referenceGroup: centerGroupId },
});
},
addChangesPanel: (groupId?: string) => {
const { api, rightTopGroupId } = get();
if (!api) return;
addSimplePanel(api, groupId ?? rightTopGroupId, {
id: "changes",
component: "changes",
title: "Changes",
tabComponent: "changesTab",
});
},
addFilesPanel: (groupId?: string) => {
const { api, rightTopGroupId } = get();
if (!api) return;
addSimplePanel(api, groupId ?? rightTopGroupId, {
id: "files",
component: "files",
title: "Files",
});
},
addDiffViewerPanel: (path?: string, content?: string, groupId?: string) => {
const { api, centerGroupId } = get();
if (!api) return;
if (path) set({ selectedDiff: { path, content } });
addSimplePanel(api, groupId ?? centerGroupId, {
id: "diff-viewer",
component: "diff-viewer",
title: "Diff Viewer",
params: { kind: "all" },
});
},
addFileDiffPanel: buildFileDiffAction(get),
addCommitDetailPanel: buildCommitDetailAction(get),
addFileEditorPanel: buildFileEditorAction(get),
addBrowserPanel: (url?: string, groupId?: string) => {
const { api, centerGroupId } = get();
if (!api) return;
const browserId = url ? `browser:${url}` : `browser:${Date.now()}`;
addSimplePanel(api, groupId ?? centerGroupId, {
id: browserId,
component: "browser",
title: t("task:browser"),
params: { url: url ?? "" },
});
},
openBrowserPanel: (url: string) => {
const { api, centerGroupId } = get();
if (!api) return;
openBrowserPanel(api, centerGroupId, url);
},
promotePreviewToPinned: (type: PreviewType): void => {
const { api } = get();
if (!api) return;
promotePreviewToPinned(api, type);
},
};
}
/** Add a session tab to the center group. */
export function addSessionPanel(
api: DockviewApi,
centerGroupId: string,
sessionId: string,
title: string,
): void {
focusOrAddPanel(api, {
id: `session:${sessionId}`,
component: "chat",
tabComponent: "sessionTab",
title,
params: { sessionId },
position: { referenceGroup: centerGroupId },
});
}
/** Remove a session tab panel if it exists. */
export function removeSessionPanel(api: DockviewApi, sessionId: string): void {
const panel = api.getPanel(`session:${sessionId}`);
if (panel) api.removePanel(panel);
}
function buildReviewPanelActions(get: StoreGet) {
return {
/**
* Focus an existing PR tab in place, or add a keyed tab beside the
* layout-owned canonical PR Details panel. Fall back to the center group
* when the current layout intentionally omits PR Details.
*/
addPRPanel: (prKey?: string) => {
const { api, centerGroupId } = get();
if (!api) return;
// Multi-repo: each TaskPR opens in its own panel keyed by
// owner/repo/pr_number so multiple PRs can be tabbed side-by-side.
// Legacy single-repo callers (no key) get the historical panel id.
const id = prKey ? `pr-detail|${prKey}` : "pr-detail";
// A canonical panel already rendering this key is the task's primary
// review tab; focus it instead of creating a duplicate keyed tab.
const existing = api.getPanel(id);
if (existing) {
existing.api.setActive();
return;
}
if (prKey && focusMatchingLegacyPanel(api, id, "pr-detail", "prKey", prKey)) return;
const targetGroupId = api.getPanel("pr-detail")?.group.id ?? centerGroupId;
focusOrAddPanel(api, {
id,
component: "pr-detail",
title: prKey ? "Pull Request" : "PR Details",
position: { referenceGroup: targetGroupId },
params: prKey ? { prKey } : undefined,
});
},
addMRPanel: (mrKey: string) => {
const { api, centerGroupId } = get();
if (!api) return;
const id = `mr-detail|${mrKey}`;
const existing = api.getPanel(id);
if (existing) {
existing.api.setActive();
return;
}
if (focusMatchingLegacyPanel(api, id, "mr-detail", "mrKey", mrKey)) return;
const canonical = api.getPanel("pr-detail");
if (canonical?.params?.mrKey === mrKey) {
canonical.api.setActive();
return;
}
focusOrAddPanel(api, {
id,
component: "mr-detail",
title: "Merge Request",
position: { referenceGroup: canonical?.group.id ?? centerGroupId },
params: { mrKey },
});
},
};
}
export function buildExtraPanelActions(get: StoreGet) {
return {
addVscodePanel: () => {
const { api, centerGroupId } = get();
if (!api) return;
focusOrAddPanel(api, {
id: "vscode",
component: "vscode",
title: "VS Code",
position: { referenceGroup: centerGroupId },
});
},
openInternalVscode: (_goto: { file: string; line: number; col: number } | null) => {
const { api, centerGroupId } = get();
if (!api) return;
const existing = api.getPanel("vscode");
if (existing) {
existing.api.setActive();
return;
}
focusOrAddPanel(api, {
id: "vscode",
component: "vscode",
title: "VS Code",
position: { referenceGroup: centerGroupId },
});
},
addPlanPanel: (opts?: SidePanelOpts) => {
const { api, centerGroupId } = get();
if (!api) return;
addSidePanel(
api,
centerGroupId,
{ id: "plan", component: "plan", title: "Plan", tabComponent: "planTab" },
opts,
);
},
addPluginPanel: (pluginId: string, panelKey: string, title: string, opts?: SidePanelOpts) => {
const { api, centerGroupId } = get();
if (!api) return;
addSidePanel(
api,
centerGroupId,
{
id: pluginPanelId(pluginId, panelKey),
component: PLUGIN_PANEL_COMPONENT,
title,
tabComponent: PLUGIN_PANEL_TAB_COMPONENT,
params: { pluginId, panelKey },
},
opts,
);
},
closePluginPanels: (pluginId: string) => {
const { api } = get();
if (!api) return;
api.panels
.filter((p) => parsePluginPanelId(p.id)?.pluginId === pluginId)
.forEach((p) => api.removePanel(p));
},
addTodosPanel: (opts?: SidePanelOpts) => {
const { api, centerGroupId } = get();
if (!api) return;
addSidePanel(
api,
centerGroupId,
{ id: "todos", component: "todos", title: t("common:todos") },
opts,
);
},
...buildReviewPanelActions(get),
addTerminalPanel: (
terminalId?: string,
groupId?: string,
environmentId?: string,
taskID?: string,
title?: string,
) => {
const { api, rightBottomGroupId } = get();
if (!api) return;
const id = terminalId ?? `terminal-${Date.now()}`;
// Stamp env id + task id into the panel's params so cleanup
// (dockview-layout-setup.onDidRemovePanel) can call destroyUserShell
// with the correct task scope even after the user switches tasks.
// task_id is what the backend uses to verify ownership now — without
// it `requireOwnership` rejects with ErrTaskMismatch.
addSimplePanel(api, groupId ?? rightBottomGroupId, {
id,
component: "terminal",
// terminalTab is a custom dockview tab that adds the `#N` badge
// when there's more than one ordinary terminal in the task and
// exposes a context menu for rename / park / destroy.
tabComponent: "terminalTab",
title: title ?? "Terminal",
params: { terminalId: id, environmentId, taskID },
});
},
};
}