Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/routes/v2/pages/Editor/EditorV2.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ import { useSelectionWindowSync } from "./hooks/useSelectionWindowSync";
import { useSpecLifecycle } from "./hooks/useSpecLifecycle";
import { useTipOfTheDayWindow } from "./hooks/useTipOfTheDayWindow";
import { useUndoRedoKeyboard } from "./hooks/useUndoRedoKeyboard";
import { useReconcileFromUrl } from "./lineage/useReconcileFromUrl";
import { editorRegistry } from "./nodes";
import { EditorSessionProvider } from "./store/EditorSessionContext";

Expand Down Expand Up @@ -132,6 +133,8 @@ const PipelineEditor = withSuspenseWrapper(
function EditorV2Content({ pipelineRef }: { pipelineRef: PipelineRef | null }) {
const { navigation } = useSharedStores();

useReconcileFromUrl();

useEffect(() => {
navigation.setRequestedPipelineName(pipelineRef?.name ?? null);
}, [navigation, pipelineRef?.name]);
Expand Down
183 changes: 183 additions & 0 deletions src/routes/v2/pages/Editor/lineage/ReconcileOverview.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
import { useLocation, useNavigate } from "@tanstack/react-router";
import { useEffect, useRef, useState } from "react";

import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Icon } from "@/components/ui/icon";
import { BlockStack, InlineStack } from "@/components/ui/layout";
import { Text } from "@/components/ui/typography";
import { APP_ROUTES } from "@/routes/router";
import { useEditorSession } from "@/routes/v2/pages/Editor/store/EditorSessionContext";
import type { HydratedComponentReference } from "@/utils/componentSpec";

import {
createReconcileSession,
type ReconcileSession,
updateReconcileSession,
} from "./reconcileSession";
import {
type PipelineLineageMatch,
scanPipelinesForLineage,
} from "./scanPipelinesForLineage";

interface ReconcileOverviewProps {
/** The edited (target) component every instance is reconciled to. */
component: HydratedComponentReference;
originId: string;
onClose: () => void;
}

/**
* Cross-pipeline reconcile overview: lists every locally-stored pipeline that
* uses the edited component's origin and lets the user reconcile each in turn.
* Clicking "Reconcile" flushes the current pipeline, then routes to the target
* pipeline in reconcile mode (`?reconcile=<sessionId>`), where the change is
* staged and committed via the node-anchored "Finish Reconciling" button.
*/
export function ReconcileOverview({
component,
originId,
onClose,
}: ReconcileOverviewProps) {
const navigate = useNavigate();
const location = useLocation();
const { autoSave } = useEditorSession();

const sessionRef = useRef<ReconcileSession | null>(null);
const [pipelines, setPipelines] = useState<PipelineLineageMatch[] | null>(
null,
);

useEffect(() => {
let cancelled = false;
void (async () => {
const results = await scanPipelinesForLineage(originId, component.digest);
if (cancelled) return;

const session = createReconcileSession({
originId,
targetDigest: component.digest,
targetComponentText: component.text,
targetName: component.name,
returnTo: "",
worklist: results.map((r) => ({
storageKey: r.storageKey,
status: "pending" as const,
})),
});
const returnTo = `${location.pathname}?reconcileOverview=${session.sessionId}`;
updateReconcileSession(session.sessionId, { returnTo });
sessionRef.current = { ...session, returnTo };

setPipelines(results);
})();
return () => {
cancelled = true;
};
// Intentionally runs once when the overview opens; props are fixed for its
// lifetime (it is mounted only while active).
}, []);

const handleReconcile = async (storageKey: string) => {
const session = sessionRef.current;
if (!session) return;
// Persist the current pipeline before navigating away.
await autoSave.save();
await navigate({
to: APP_ROUTES.EDITOR_V2_PIPELINE,
params: { pipelineName: storageKey },
search: { reconcile: session.sessionId },
});
};

const totalPending = pipelines?.reduce((n, p) => n + p.pendingCount, 0) ?? 0;

return (
<Dialog
open
onOpenChange={(isOpen) => {
if (!isOpen) onClose();
}}
>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>
Reconcile “{component.name}” across pipelines
</DialogTitle>
<DialogDescription>
Update other pipelines that use this component’s origin to your
edited version. Each opens in the editor so you can review and apply
the change in context.
</DialogDescription>
</DialogHeader>

<BlockStack gap="2" className="max-h-[55vh] overflow-y-auto py-1">
{pipelines === null && (
<Text size="sm" tone="subdued">
Scanning pipelines…
</Text>
)}

{pipelines !== null && pipelines.length === 0 && (
<Text size="sm" tone="subdued">
No other pipelines use this component.
</Text>
)}

{pipelines?.map((pipeline) => {
const done = pipeline.pendingCount === 0;
return (
<InlineStack
key={pipeline.storageKey}
align="space-between"
blockAlign="center"
gap="2"
className="rounded-md border px-3 py-2"
>
<BlockStack gap="0" className="min-w-0">
<Text size="sm" weight="semibold" className="truncate">
{pipeline.pipelineName}
</Text>
<Text size="xs" tone="subdued">
{done
? `${pipeline.reconciledCount} reconciled`
: `${pipeline.pendingCount} of ${pipeline.tasks.length} to update`}
</Text>
</BlockStack>

{done ? (
<InlineStack gap="1" blockAlign="center">
<Icon name="Check" size="sm" className="text-emerald-600" />
<Text size="xs" tone="subdued">
Reconciled
</Text>
</InlineStack>
) : (
<Button
size="sm"
onClick={() => void handleReconcile(pipeline.storageKey)}
>
Reconcile
</Button>
)}
</InlineStack>
);
})}
</BlockStack>

<DialogFooter>
<Button variant="outline" onClick={onClose}>
{totalPending === 0 ? "Done" : "Close"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
31 changes: 31 additions & 0 deletions src/routes/v2/pages/Editor/lineage/ReconcileSiblingsDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ interface ReconcileSiblingsDialogProps {
matches: LineageUsage[];
onConfirm: (taskIds: string[]) => void;
onCancel: () => void;
/** Number of OTHER pipelines that also use this origin (cross-pipeline). */
crossPipelineCount?: number;
/** Open the cross-pipeline reconcile overview. */
onReconcileAcrossPipelines?: () => void;
}

/**
Expand All @@ -35,6 +39,8 @@ export function ReconcileSiblingsDialog({
matches,
onConfirm,
onCancel,
crossPipelineCount = 0,
onReconcileAcrossPipelines,
}: ReconcileSiblingsDialogProps) {
const [selected, setSelected] = useState<Set<string>>(
() => new Set(matches.map((m) => m.taskId)),
Expand Down Expand Up @@ -106,6 +112,31 @@ export function ReconcileSiblingsDialog({
))}
</BlockStack>

{crossPipelineCount > 0 && onReconcileAcrossPipelines && (
<button
type="button"
onClick={onReconcileAcrossPipelines}
className="flex w-full items-center justify-between rounded-md border border-dashed px-3 py-2 text-left hover:bg-accent"
>
<InlineStack gap="2" blockAlign="center">
<Icon
name="Workflow"
size="sm"
className="shrink-0 text-muted-foreground"
/>
<Text size="sm">
Also used in {crossPipelineCount} other{" "}
{crossPipelineCount === 1 ? "pipeline" : "pipelines"}
</Text>
</InlineStack>
<Icon
name="ArrowRight"
size="sm"
className="shrink-0 text-muted-foreground"
/>
</button>
)}

<AlertDialogFooter>
<AlertDialogCancel onClick={onCancel}>Not now</AlertDialogCancel>
<AlertDialogAction
Expand Down
34 changes: 34 additions & 0 deletions src/routes/v2/pages/Editor/lineage/reconcileModeStore.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { action, computed, makeObservable, observable } from "mobx";

import type { ReconcileSession } from "./reconcileSession";

/**
* Tracks whether the editor is in cross-pipeline "reconcile mode" — driven by
* the `?reconcile=<id>` URL param via {@link useReconcileFromUrl}. When active,
* the canvas stages the edited component on matching tasks (autosave held), the
* UI focuses on reconciling (chrome hidden), and navigation is guarded.
*/
class ReconcileModeStore {
@observable accessor session: ReconcileSession | null = null;

constructor() {
makeObservable(this);
}

@computed
get active(): boolean {
return this.session !== null;
}

@action
enter(session: ReconcileSession): void {
this.session = session;
}

@action
exit(): void {
this.session = null;
}
}

export const reconcileModeStore = new ReconcileModeStore();
80 changes: 80 additions & 0 deletions src/routes/v2/pages/Editor/lineage/reconcileSession.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

import {
createReconcileSession,
deleteReconcileSession,
getReconcileSession,
type ReconcileSession,
sweepExpiredReconcileSessions,
updateReconcileSession,
} from "./reconcileSession";

function fakeLocalStorage(): Storage {
const map = new Map<string, string>();
return {
get length() {
return map.size;
},
key: (i: number) => [...map.keys()][i] ?? null,
getItem: (k: string) => map.get(k) ?? null,
setItem: (k: string, v: string) => void map.set(k, v),
removeItem: (k: string) => void map.delete(k),
clear: () => map.clear(),
};
}

const baseInput: Omit<ReconcileSession, "sessionId" | "createdAt"> = {
originId: "https://x/train.yaml",
targetDigest: "edited-digest",
targetComponentText: "name: Train\n",
targetName: "Train",
returnTo: "/editor-v2/Origin?reconcileOverview=X",
worklist: [{ storageKey: "Pipeline A", status: "pending" }],
};

describe("reconcileSession", () => {
beforeEach(() => {
vi.useFakeTimers();
vi.stubGlobal("localStorage", fakeLocalStorage());
vi.stubGlobal("crypto", { randomUUID: () => "uuid-1" });
});
afterEach(() => {
vi.unstubAllGlobals();
vi.useRealTimers();
});

it("creates, reads, updates, and deletes a session", () => {
const created = createReconcileSession(baseInput);
expect(created.sessionId).toBe("uuid-1");
expect(getReconcileSession("uuid-1")).toMatchObject(baseInput);

updateReconcileSession("uuid-1", {
worklist: [{ storageKey: "Pipeline A", status: "skipped" }],
});
expect(getReconcileSession("uuid-1")?.worklist[0].status).toBe("skipped");

deleteReconcileSession("uuid-1");
expect(getReconcileSession("uuid-1")).toBeUndefined();
});

it("returns undefined for missing or malformed sessions", () => {
expect(getReconcileSession("nope")).toBeUndefined();
localStorage.setItem("reconcile:bad", "{not json");
expect(getReconcileSession("bad")).toBeUndefined();
});

it("sweeps sessions older than the max age, keeping fresh ones", () => {
const now = Date.now();
vi.setSystemTime(now);
createReconcileSession(baseInput);

// Not expired yet.
sweepExpiredReconcileSessions(1000);
expect(getReconcileSession("uuid-1")).toBeDefined();

// Advance past the TTL.
vi.setSystemTime(now + 5000);
sweepExpiredReconcileSessions(1000);
expect(getReconcileSession("uuid-1")).toBeUndefined();
});
});
Loading
Loading