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
2 changes: 2 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 { ReconcileOverviewHost } from "./lineage/ReconcileOverviewHost";
import { useReconcileFromUrl } from "./lineage/useReconcileFromUrl";
import { editorRegistry } from "./nodes";
import { EditorSessionProvider } from "./store/EditorSessionContext";
Expand Down Expand Up @@ -152,6 +153,7 @@ function EditorV2Content({ pipelineRef }: { pipelineRef: PipelineRef | null }) {
<EmptyEditorState />
)}
</ForcedSearchProvider>
<ReconcileOverviewHost />
</ReactFlowProvider>
</ComponentLibraryProvider>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { cn } from "@/lib/utils";
import type { ComponentSpec } from "@/models/componentSpec";
import { useAnalytics } from "@/providers/AnalyticsProvider";
import { useAutoLayout } from "@/routes/v2/pages/Editor/hooks/useAutoLayout";
import { ReconcileModeController } from "@/routes/v2/pages/Editor/lineage/ReconcileModeController";
import { SubgraphBreadcrumbs } from "@/routes/v2/shared/components/SubgraphBreadcrumbs";
import { FLOW_CANVAS_DEFAULT_PROPS } from "@/routes/v2/shared/flowCanvasDefaults";
import { useDoubleClickBehavior } from "@/routes/v2/shared/hooks/useDoubleClickBehavior";
Expand Down Expand Up @@ -122,6 +123,7 @@ export const FlowCanvas = observer(function FlowCanvas({
)}
>
<FloatingSelectionToolbar spec={spec} />
<ReconcileModeController />
<Background gap={10} className="bg-slate-50!" />
<Controls
position="bottom-right"
Expand Down
156 changes: 156 additions & 0 deletions src/routes/v2/pages/Editor/lineage/ReconcileModeController.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import { useNavigate } from "@tanstack/react-router";
import { NodeToolbar, Position } from "@xyflow/react";
import { observer } from "mobx-react-lite";
import { useEffect, useRef, useState } from "react";

import { Button } from "@/components/ui/button";
import { Icon } from "@/components/ui/icon";
import { InlineStack } from "@/components/ui/layout";
import { Text } from "@/components/ui/typography";
import { APP_ROUTES } from "@/routes/router";
import { useTaskActions } from "@/routes/v2/pages/Editor/store/actions/useTaskActions";
import { useEditorSession } from "@/routes/v2/pages/Editor/store/EditorSessionContext";
import { useSpec } from "@/routes/v2/shared/providers/SpecContext";
import { useSharedStores } from "@/routes/v2/shared/store/SharedStoreContext";
import { hydrateComponentReference } from "@/services/componentService";

import { collectLineageUsages } from "./collectLineageUsages";
import { findTaskContext } from "./findTaskContext";
import { reconcileModeStore } from "./reconcileModeStore";

/**
* Drives the in-canvas reconcile experience when reconcile mode is active:
* holds autosave, stages the edited component onto matching tasks in-memory
* (rendered immediately, fully undoable), spotlights them, and surfaces a
* node-anchored "Finish Reconciling" button (the explicit commit) plus a banner.
*
* Rendered inside `<ReactFlow>` so `NodeToolbar` can anchor to the target nodes.
* Nothing is persisted until Finish; Cancel / leaving discards the staged change
* (the pipeline reloads fresh from storage).
*/
export const ReconcileModeController = observer(
function ReconcileModeController() {
const session = reconcileModeStore.session;
const spec = useSpec();
const navigate = useNavigate();
const { autoSave, undo } = useEditorSession();
const { replaceTask } = useTaskActions();
const { editor } = useSharedStores();

const [ready, setReady] = useState(false);
const [matchTaskIds, setMatchTaskIds] = useState<string[]>([]);
const stagedSessionRef = useRef<string | null>(null);

useEffect(() => {
if (!session) {
stagedSessionRef.current = null;
setReady(false);
setMatchTaskIds([]);
}
}, [session]);

useEffect(() => {
if (!session || !spec) return;
if (stagedSessionRef.current === session.sessionId) return;

let cancelled = false;
void (async () => {
const component = await hydrateComponentReference({
text: session.targetComponentText,
});
if (cancelled || !component) return;

const matches = collectLineageUsages(spec, session.originId).filter(
(m) => m.digest !== session.targetDigest,
);

stagedSessionRef.current = session.sessionId;

if (matches.length > 0) {
autoSave.setSuspended(true);
undo.withGroup("Reconcile component", () => {
for (const match of matches) {
const ctx = findTaskContext(spec, match.taskId);
if (ctx) replaceTask(ctx.spec, match.taskId, component);
}
});
editor.setPendingFocusNode(matches[0].taskId);
editor.setSpotlightNode(matches[0].taskId);
}

if (!cancelled) {
setMatchTaskIds(matches.map((m) => m.taskId));
setReady(true);
}
})();

return () => {
cancelled = true;
};
// Stage once per session; deps kept stable intentionally.
}, [session?.sessionId, spec]);

if (!session || !ready) return null;

const returnToOverview = () =>
navigate({
to: APP_ROUTES.EDITOR_V2_PIPELINE,
params: { pipelineName: session.returnToPipeline },
search: { reconcileOverview: session.sessionId },
});

const finish = async () => {
autoSave.setSuspended(false);
await autoSave.save();
reconcileModeStore.exit();
await returnToOverview();
};

const leave = async () => {
// Leave without saving — the staged change is discarded on reload.
reconcileModeStore.exit();
await returnToOverview();
};

const count = matchTaskIds.length;

return (
<>
{count > 0 && (
<NodeToolbar
nodeId={matchTaskIds}
isVisible
position={Position.Top}
offset={12}
>
<Button size="sm" onClick={() => void finish()}>
<Icon name="Check" size="sm" />
Finish Reconciling{count > 1 ? ` (${count} tasks)` : ""}
</Button>
</NodeToolbar>
)}

<div className="fixed left-1/2 top-3 z-[60] -translate-x-1/2">
<InlineStack
gap="3"
blockAlign="center"
className="rounded-full border bg-white px-4 py-1.5 shadow-md"
>
<Icon name="RefreshCw" size="sm" className="text-blue-600" />
{count > 0 ? (
<Text size="sm">
Reconciling <strong>{session.targetName}</strong> · {count}{" "}
{count === 1 ? "task" : "tasks"} staged
</Text>
) : (
<Text size="sm">Nothing to reconcile in this pipeline</Text>
)}
<Button size="sm" variant="ghost" onClick={() => void leave()}>
{count > 0 ? "Cancel" : "Back"}
</Button>
</InlineStack>
</div>
</>
);
},
);
65 changes: 19 additions & 46 deletions src/routes/v2/pages/Editor/lineage/ReconcileOverview.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useLocation, useNavigate } from "@tanstack/react-router";
import { useEffect, useRef, useState } from "react";
import { useNavigate } from "@tanstack/react-router";
import { useEffect, useState } from "react";

import { Button } from "@/components/ui/button";
import {
Expand All @@ -15,80 +15,53 @@ 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 { ReconcileSession } from "./reconcileSession";
import {
type PipelineLineageMatch,
scanPipelinesForLineage,
} from "./scanPipelinesForLineage";

interface ReconcileOverviewProps {
/** The edited (target) component every instance is reconciled to. */
component: HydratedComponentReference;
originId: string;
session: ReconcileSession;
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.
* Cross-pipeline reconcile overview (URL-driven via `?reconcileOverview=<id>`):
* lists every locally-stored pipeline using the edited component's origin and
* lets the user reconcile each in turn. Status is recomputed by re-scan on each
* open, so it is self-healing across refresh / back / "reconcile next". Clicking
* "Reconcile" flushes the current pipeline, then routes to the target in
* reconcile mode (`?reconcile=<id>`), where the change is staged and committed
* via the node-anchored "Finish Reconciling" button.
*/
export function ReconcileOverview({
component,
originId,
session,
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);
const results = await scanPipelinesForLineage(
session.originId,
session.targetDigest,
);
if (!cancelled) setPipelines(results);
})();
return () => {
cancelled = true;
};
// Intentionally runs once when the overview opens; props are fixed for its
// lifetime (it is mounted only while active).
}, []);
}, [session.originId, session.targetDigest]);

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,
Expand All @@ -109,7 +82,7 @@ export function ReconcileOverview({
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>
Reconcile “{component.name}” across pipelines
Reconcile “{session.targetName}” across pipelines
</DialogTitle>
<DialogDescription>
Update other pipelines that use this component’s origin to your
Expand Down
43 changes: 43 additions & 0 deletions src/routes/v2/pages/Editor/lineage/ReconcileOverviewHost.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { useNavigate, useParams, useSearch } from "@tanstack/react-router";

import { APP_ROUTES } from "@/routes/router";

import { ReconcileOverview } from "./ReconcileOverview";
import { getReconcileSession } from "./reconcileSession";

/**
* Renders the cross-pipeline reconcile overview when the URL carries
* `?reconcileOverview=<sessionId>` and the session still exists. Being URL-driven
* means the overview opens on launch and reopens automatically when a target
* pipeline routes back here after committing ("reconcile next").
*/
export function ReconcileOverviewHost() {
const search = useSearch({ strict: false });
const params = useParams({ strict: false });
const navigate = useNavigate();

const overviewId =
"reconcileOverview" in search &&
typeof search.reconcileOverview === "string"
? search.reconcileOverview
: undefined;

const session = overviewId ? getReconcileSession(overviewId) : undefined;
if (!overviewId || !session) return null;

const close = () => {
const pipelineName =
"pipelineName" in params && typeof params.pipelineName === "string"
? params.pipelineName
: undefined;
if (pipelineName) {
void navigate({
to: APP_ROUTES.EDITOR_V2_PIPELINE,
params: { pipelineName },
search: {},
});
}
};

return <ReconcileOverview session={session} onClose={close} />;
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ const baseInput: Omit<ReconcileSession, "sessionId" | "createdAt"> = {
targetDigest: "edited-digest",
targetComponentText: "name: Train\n",
targetName: "Train",
returnTo: "/editor-v2/Origin?reconcileOverview=X",
returnToPipeline: "Origin",
worklist: [{ storageKey: "Pipeline A", status: "pending" }],
};

Expand Down
4 changes: 2 additions & 2 deletions src/routes/v2/pages/Editor/lineage/reconcileSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,8 @@ const reconcileSessionSchema = z.object({
/** Self-contained edited component YAML, so the session needs no store lookup. */
targetComponentText: z.string(),
targetName: z.string(),
/** Where to return when reconciling ends (origin editor + reconcile overview). */
returnTo: z.string(),
/** Origin pipeline (storage key) to return to — reopens the overview there. */
returnToPipeline: z.string(),
worklist: z.array(worklistItemSchema),
createdAt: z.number(),
});
Expand Down
Loading
Loading