417 lines
13 KiB
TypeScript
417 lines
13 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useMemo } from "react";
|
|
import type { AddPanelOptions, DockviewApi } from "dockview-react";
|
|
import { prTaskKey } from "@/components/github/pr-utils";
|
|
import { mrTaskKey } from "@/components/gitlab/mr-detail-panel";
|
|
import { useAppStore, useAppStoreApi } from "@/components/state-provider";
|
|
import { getPrimaryTaskPR, useTaskPR } from "@/hooks/domains/github/use-task-pr";
|
|
import { t } from "@/lib/i18n";
|
|
import { markPRPanelOffered, wasPRPanelOffered } from "@/lib/local-storage";
|
|
import { focusOrAddPanel } from "@/lib/state/dockview-layout-builders";
|
|
import { useDockviewStore } from "@/lib/state/dockview-store";
|
|
import type { ReviewItemSummary } from "@/lib/plugins/types";
|
|
import {
|
|
CENTER_GROUP,
|
|
isCenterCandidateGroupId,
|
|
type LayoutState,
|
|
} from "@/lib/state/layout-manager";
|
|
import type { TaskPR } from "@/lib/types/github";
|
|
import type { TaskMR } from "@/lib/types/gitlab";
|
|
import { useNormalizedTaskReviews } from "./review-panel-provider";
|
|
|
|
export type CanonicalReviewParams = {
|
|
providerId: string | undefined;
|
|
provider: "github" | "gitlab" | undefined;
|
|
reviewKey: string | undefined;
|
|
connectionScope: string | undefined;
|
|
repositoryId: string | undefined;
|
|
changeRequestNumber: string | number | undefined;
|
|
prKey: string | undefined;
|
|
mrKey: string | undefined;
|
|
};
|
|
|
|
export type CanonicalReviewPanelState = {
|
|
kind: "multiple" | "github" | "gitlab" | "registered" | "empty";
|
|
params: CanonicalReviewParams;
|
|
title: string;
|
|
};
|
|
|
|
export function resolveCanonicalReviewPanelState(
|
|
prs: TaskPR[] | undefined,
|
|
mrs: TaskMR[] | undefined,
|
|
registeredReviews: readonly ReviewItemSummary[] = [],
|
|
): CanonicalReviewPanelState {
|
|
const linkedProviderIds = new Set<string>();
|
|
if (prs?.length) linkedProviderIds.add("github");
|
|
if (mrs?.length) linkedProviderIds.add("gitlab");
|
|
registeredReviews.forEach((review) => linkedProviderIds.add(review.providerId));
|
|
if (linkedProviderIds.size > 1) {
|
|
return {
|
|
kind: "multiple",
|
|
params: {
|
|
providerId: undefined,
|
|
provider: undefined,
|
|
reviewKey: undefined,
|
|
connectionScope: undefined,
|
|
repositoryId: undefined,
|
|
changeRequestNumber: undefined,
|
|
prKey: undefined,
|
|
mrKey: undefined,
|
|
},
|
|
title: t("integrations:reviews", { summary: "" }),
|
|
};
|
|
}
|
|
|
|
const pr = getPrimaryTaskPR(prs);
|
|
if (pr) {
|
|
const key = prTaskKey(pr);
|
|
return {
|
|
kind: "github",
|
|
params: {
|
|
providerId: "github",
|
|
provider: "github",
|
|
reviewKey: key,
|
|
connectionScope: reviewConnectionScope(pr.pr_url, "github"),
|
|
repositoryId: pr.repository_id || `${pr.owner}/${pr.repo}`,
|
|
changeRequestNumber: pr.pr_number,
|
|
prKey: key,
|
|
mrKey: undefined,
|
|
},
|
|
title: t("task:pullRequest2"),
|
|
};
|
|
}
|
|
|
|
const mr = mrs?.[0];
|
|
if (mr) {
|
|
const key = mrTaskKey(mr);
|
|
return {
|
|
kind: "gitlab",
|
|
params: {
|
|
providerId: "gitlab",
|
|
provider: "gitlab",
|
|
reviewKey: key,
|
|
connectionScope: mr.host || reviewConnectionScope(mr.mr_url, "gitlab"),
|
|
repositoryId: mr.repository_id || mr.project_path,
|
|
changeRequestNumber: mr.mr_iid,
|
|
prKey: undefined,
|
|
mrKey: key,
|
|
},
|
|
title: t("task:mergeRequestLabel"),
|
|
};
|
|
}
|
|
|
|
const registered = registeredReviews.find(
|
|
(review) => review.providerId !== "github" && review.providerId !== "gitlab",
|
|
);
|
|
if (registered) {
|
|
return {
|
|
kind: "registered",
|
|
params: {
|
|
providerId: registered.providerId,
|
|
provider: undefined,
|
|
reviewKey: registered.reviewKey,
|
|
connectionScope: registered.connectionScope,
|
|
repositoryId: registered.repositoryId,
|
|
changeRequestNumber: registered.changeRequestNumber,
|
|
prKey: undefined,
|
|
mrKey: undefined,
|
|
},
|
|
title: registered.title,
|
|
};
|
|
}
|
|
|
|
return {
|
|
kind: "empty",
|
|
params: {
|
|
providerId: undefined,
|
|
provider: undefined,
|
|
reviewKey: undefined,
|
|
connectionScope: undefined,
|
|
repositoryId: undefined,
|
|
changeRequestNumber: undefined,
|
|
prKey: undefined,
|
|
mrKey: undefined,
|
|
},
|
|
title: t("common:prDetails"),
|
|
};
|
|
}
|
|
|
|
const CANONICAL_REVIEW_PARAM_KEYS = [
|
|
"providerId",
|
|
"provider",
|
|
"reviewKey",
|
|
"connectionScope",
|
|
"repositoryId",
|
|
"changeRequestNumber",
|
|
"prKey",
|
|
"mrKey",
|
|
] as const satisfies readonly (keyof CanonicalReviewParams)[];
|
|
|
|
function hasSameReviewParams(
|
|
current: Record<string, unknown> | undefined,
|
|
next: CanonicalReviewParams,
|
|
): boolean {
|
|
return CANONICAL_REVIEW_PARAM_KEYS.every((key) => current?.[key] === next[key]);
|
|
}
|
|
|
|
export type ConditionalReviewPanelAction = "add" | "remove" | "sync" | "none";
|
|
|
|
export function resolveConditionalReviewPanelAction(params: {
|
|
hasReview: boolean;
|
|
panelExists: boolean;
|
|
reviewsLoaded: boolean;
|
|
isRestoringLayout: boolean;
|
|
isMaximized: boolean;
|
|
wasOffered: boolean;
|
|
}): ConditionalReviewPanelAction {
|
|
if (!params.hasReview) {
|
|
if (params.panelExists && !params.reviewsLoaded) return "none";
|
|
return params.panelExists ? "remove" : "none";
|
|
}
|
|
if (params.panelExists) return "sync";
|
|
if (params.isRestoringLayout || params.isMaximized || params.wasOffered) return "none";
|
|
return "add";
|
|
}
|
|
|
|
export type ConditionalReviewPanelOptions = {
|
|
sessionId: string;
|
|
centerGroupId: string;
|
|
configuredPlacement?: ReviewPanelPlacement | null;
|
|
reviewsLoaded: boolean;
|
|
isRestoringLayout: boolean;
|
|
isMaximized: boolean;
|
|
wasOffered: boolean;
|
|
};
|
|
|
|
export type ReviewPanelPlacement = {
|
|
groupId: string;
|
|
index: number;
|
|
};
|
|
|
|
/** Resolve where a custom Default layout wants the conditional review tab. */
|
|
export function resolveConfiguredReviewPanelPlacement(
|
|
layout: LayoutState | null,
|
|
): ReviewPanelPlacement | null {
|
|
if (!layout) return null;
|
|
for (const column of layout.columns) {
|
|
for (const group of column.groups) {
|
|
const index = group.panels.findIndex((panel) => panel.id === "pr-detail");
|
|
if (index >= 0 && group.id) return { groupId: group.id, index };
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function resolveReviewPanelTargetGroup(
|
|
api: DockviewApi,
|
|
sessionId: string,
|
|
centerGroupId: string,
|
|
): string {
|
|
const sessionPanel = api.getPanel(`session:${sessionId}`);
|
|
const sessionGroupId = sessionPanel?.group?.id;
|
|
if (sessionGroupId && isCenterCandidateGroupId(sessionGroupId)) return sessionGroupId;
|
|
return isCenterCandidateGroupId(centerGroupId) ? centerGroupId : CENTER_GROUP;
|
|
}
|
|
|
|
function resolveReviewPanelTargetPosition(
|
|
api: DockviewApi,
|
|
options: ConditionalReviewPanelOptions,
|
|
): NonNullable<AddPanelOptions["position"]> {
|
|
const configured = options.configuredPlacement;
|
|
if (configured && api.groups.some((group) => group.id === configured.groupId)) {
|
|
return { referenceGroup: configured.groupId, index: configured.index };
|
|
}
|
|
return {
|
|
referenceGroup: resolveReviewPanelTargetGroup(api, options.sessionId, options.centerGroupId),
|
|
};
|
|
}
|
|
|
|
function addConditionalReviewPanel(
|
|
api: DockviewApi,
|
|
next: CanonicalReviewPanelState,
|
|
options: ConditionalReviewPanelOptions,
|
|
): void {
|
|
focusOrAddPanel(
|
|
api,
|
|
{
|
|
id: "pr-detail",
|
|
component: "pr-detail",
|
|
title: next.title || t("common:prDetails"),
|
|
position: resolveReviewPanelTargetPosition(api, options),
|
|
params: next.params,
|
|
},
|
|
true,
|
|
);
|
|
markPRPanelOffered(options.sessionId);
|
|
}
|
|
|
|
function syncExistingReviewPanel(
|
|
panel: NonNullable<ReturnType<DockviewApi["getPanel"]>>,
|
|
next: CanonicalReviewPanelState,
|
|
options: ConditionalReviewPanelOptions,
|
|
): boolean {
|
|
if (hasCanonicalReview(next)) markPRPanelOffered(options.sessionId);
|
|
const paramsChanged = !hasSameReviewParams(panel.params, next.params);
|
|
const titleChanged = panel.api.title !== next.title;
|
|
if (!paramsChanged && !titleChanged) return false;
|
|
if (paramsChanged) panel.api.updateParameters(next.params);
|
|
if (titleChanged) panel.api.setTitle(next.title);
|
|
return true;
|
|
}
|
|
|
|
function hasCanonicalReview(next: CanonicalReviewPanelState): boolean {
|
|
return next.kind !== "empty";
|
|
}
|
|
|
|
function resolveReviewPanelAction(
|
|
panel: ReturnType<DockviewApi["getPanel"]>,
|
|
next: CanonicalReviewPanelState,
|
|
options: ConditionalReviewPanelOptions,
|
|
): ConditionalReviewPanelAction {
|
|
return resolveConditionalReviewPanelAction({
|
|
hasReview: hasCanonicalReview(next),
|
|
panelExists: !!panel,
|
|
reviewsLoaded: options.reviewsLoaded,
|
|
isRestoringLayout: options.isRestoringLayout,
|
|
isMaximized: options.isMaximized,
|
|
wasOffered: options.wasOffered,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Synchronize the canonical PR Details panel and manage the conditional panel
|
|
* shown for a linked review.
|
|
*
|
|
* Review association owns panel existence. A custom Default layout can still
|
|
* provide the group and tab index used when a linked review makes it visible.
|
|
*/
|
|
export function syncCanonicalReviewPanel(
|
|
api: DockviewApi,
|
|
next: CanonicalReviewPanelState,
|
|
options: ConditionalReviewPanelOptions,
|
|
): boolean {
|
|
const panel = api.getPanel("pr-detail");
|
|
const action = resolveReviewPanelAction(panel, next, options);
|
|
|
|
if (action === "remove") {
|
|
panel?.api.close();
|
|
return true;
|
|
}
|
|
|
|
if (action === "add") {
|
|
addConditionalReviewPanel(api, next, options);
|
|
return true;
|
|
}
|
|
|
|
return action === "sync" && panel ? syncExistingReviewPanel(panel, next, options) : false;
|
|
}
|
|
|
|
function reviewIdentity(state: CanonicalReviewPanelState): string {
|
|
return [
|
|
state.kind,
|
|
state.params.providerId ?? "none",
|
|
state.params.connectionScope ?? "",
|
|
state.params.repositoryId ?? "",
|
|
String(state.params.changeRequestNumber ?? ""),
|
|
].join(":");
|
|
}
|
|
|
|
function reviewConnectionScope(url: string, fallback: string): string {
|
|
try {
|
|
return new URL(url).origin;
|
|
} catch {
|
|
return fallback;
|
|
}
|
|
}
|
|
|
|
/** Keep an existing canonical PR Details panel in sync with the active task. */
|
|
export function useSyncReviewPanel() {
|
|
const appStore = useAppStoreApi();
|
|
const taskId = useAppStore((state) => state.tasks.activeTaskId);
|
|
const { loaded: githubReviewsLoaded } = useTaskPR(taskId);
|
|
const sessionId = useAppStore((state) => state.tasks.activeSessionId);
|
|
const workspaceId = useAppStore((state) => state.workspaces.activeId);
|
|
const reviews = useNormalizedTaskReviews(taskId);
|
|
const registeredReviews = useMemo(
|
|
() =>
|
|
reviews.filter((review) => review.providerId !== "github" && review.providerId !== "gitlab"),
|
|
[reviews],
|
|
);
|
|
const gitlabReviewsLoaded = useAppStore((state) =>
|
|
workspaceId ? Object.hasOwn(state.taskMRs.byWorkspaceId, workspaceId) : false,
|
|
);
|
|
const reviewsLoaded = githubReviewsLoaded && gitlabReviewsLoaded;
|
|
const identity = useAppStore((state) => {
|
|
if (!taskId || !workspaceId) return "none";
|
|
return reviewIdentity(
|
|
resolveCanonicalReviewPanelState(
|
|
state.taskPRs.byTaskId[taskId],
|
|
state.taskMRs.byWorkspaceId[workspaceId]?.[taskId],
|
|
registeredReviews,
|
|
),
|
|
);
|
|
});
|
|
const hasApi = useDockviewStore((state) => !!state.api);
|
|
const isRestoringLayout = useDockviewStore((state) => state.isRestoringLayout);
|
|
const isMaximized = useDockviewStore((state) => state.preMaximizeLayout !== null);
|
|
const centerGroupId = useDockviewStore((state) => state.centerGroupId);
|
|
const userDefaultLayout = useDockviewStore((state) => state.userDefaultLayout);
|
|
|
|
useEffect(() => {
|
|
if (!taskId || !sessionId || !workspaceId || !hasApi) return;
|
|
|
|
let innerFrame: number | null = null;
|
|
const outerFrame = requestAnimationFrame(() => {
|
|
innerFrame = requestAnimationFrame(() => {
|
|
const live = appStore.getState();
|
|
if (
|
|
live.tasks.activeTaskId !== taskId ||
|
|
live.tasks.activeSessionId !== sessionId ||
|
|
live.workspaces.activeId !== workspaceId
|
|
)
|
|
return;
|
|
|
|
const api = useDockviewStore.getState().api;
|
|
if (!api) return;
|
|
const dockview = useDockviewStore.getState();
|
|
syncCanonicalReviewPanel(
|
|
api,
|
|
resolveCanonicalReviewPanelState(
|
|
live.taskPRs.byTaskId[taskId],
|
|
live.taskMRs.byWorkspaceId[workspaceId]?.[taskId],
|
|
registeredReviews,
|
|
),
|
|
{
|
|
sessionId,
|
|
centerGroupId: dockview.centerGroupId,
|
|
configuredPlacement: resolveConfiguredReviewPanelPlacement(dockview.userDefaultLayout),
|
|
reviewsLoaded,
|
|
isRestoringLayout: dockview.isRestoringLayout,
|
|
isMaximized: dockview.preMaximizeLayout !== null,
|
|
wasOffered: wasPRPanelOffered(sessionId),
|
|
},
|
|
);
|
|
});
|
|
});
|
|
|
|
return () => {
|
|
cancelAnimationFrame(outerFrame);
|
|
if (innerFrame !== null) cancelAnimationFrame(innerFrame);
|
|
};
|
|
}, [
|
|
appStore,
|
|
centerGroupId,
|
|
hasApi,
|
|
identity,
|
|
isMaximized,
|
|
isRestoringLayout,
|
|
reviewsLoaded,
|
|
registeredReviews,
|
|
sessionId,
|
|
taskId,
|
|
userDefaultLayout,
|
|
workspaceId,
|
|
]);
|
|
}
|