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
4 changes: 4 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,8 @@ import { useSelectionWindowSync } from "./hooks/useSelectionWindowSync";
import { useSpecLifecycle } from "./hooks/useSpecLifecycle";
import { useTipOfTheDayWindow } from "./hooks/useTipOfTheDayWindow";
import { useUndoRedoKeyboard } from "./hooks/useUndoRedoKeyboard";
import { CopyLineageModal } from "./lineage/CopyLineageModal";
import { PasteLineagePrompt } from "./lineage/PasteLineagePrompt";
import { reconcileModeStore } from "./lineage/reconcileModeStore";
import { ReconcileNavigationGuard } from "./lineage/ReconcileNavigationGuard";
import { ReconcileOverviewHost } from "./lineage/ReconcileOverviewHost";
Expand Down Expand Up @@ -123,6 +125,8 @@ const PipelineEditor = withSuspenseWrapper(
className="h-full"
/>
<WindowContainer />
<CopyLineageModal />
<PasteLineagePrompt />
</div>
<DockArea side="right" />
</InlineStack>
Expand Down
109 changes: 109 additions & 0 deletions src/routes/v2/pages/Editor/lineage/CopyLineageModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { observer } from "mobx-react-lite";
import { useState } from "react";

import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { BlockStack } from "@/components/ui/layout";
import { Text } from "@/components/ui/typography";
import { useEditorSession } from "@/routes/v2/pages/Editor/store/EditorSessionContext";
import { useSpec } from "@/routes/v2/shared/providers/SpecContext";
import { LINEAGE_ORIGIN_ANNOTATION } from "@/utils/lineage";

/**
* Modal shown when the user initiates a copy (Cmd+C / Copy button) and the
* copied tasks have no lineage annotation. The clipboard write is deferred
* until the user clicks "Copy". Checking the box stamps the source tasks with
* a shared origin first, so any future paste — including cross-pipeline —
* inherits the lineage and can participate in reconcile detection.
*
* Dismissing via ✕ or Escape completes the copy without tracking (same as
* clicking "Copy" with the checkbox unchecked).
*/
export const CopyLineageModal = observer(function CopyLineageModal() {
const spec = useSpec();
const { clipboard } = useEditorSession();
const ctx = clipboard.pendingCopyContext;

const [track, setTrack] = useState(false);

if (!ctx || !spec) return null;

// Collect task names for display.
const sourceNames = ctx.nodeIds
.map((id) => spec.tasks.find((t) => t.$id === id)?.name)
.filter(Boolean) as string[];

const label =
sourceNames.length === 1
? `"${sourceNames[0]}"`
: `${sourceNames.length} tasks`;

// Check which tasks actually still lack lineage (defensive — could have been
// stamped by another operation since the copy was initiated).
const hasAnyUnlinked = ctx.nodeIds.some((id) => {
const task = spec.tasks.find((t) => t.$id === id);
return task && !task.annotations.has(LINEAGE_ORIGIN_ANNOTATION);
});

if (!hasAnyUnlinked) {
// All tasks are already linked — just commit the copy silently.
clipboard.executeCopy(false, spec);
return null;
}

const handleCopy = (shouldTrack: boolean) => {
setTrack(false);
clipboard.executeCopy(shouldTrack, spec);
};

return (
<Dialog
open
onOpenChange={(open) => {
// X / Escape / backdrop click → copy without tracking.
if (!open) handleCopy(false);
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Copy {label}</DialogTitle>
<DialogDescription>
Would you like to track changes between {label} and any copies you
paste? Edits to either will offer to update the other.
</DialogDescription>
</DialogHeader>

<BlockStack gap="4">
<label className="flex cursor-pointer items-start gap-3 rounded-md border p-3 hover:bg-accent select-none">
<Checkbox
className="mt-0.5 shrink-0"
checked={track}
onCheckedChange={(val) => setTrack(val === true)}
/>
<BlockStack gap="1">
<Text size="sm" weight="semibold">
Track changes to {label}
</Text>
<Text size="xs" tone="subdued">
Link the original and its copies so edits to either will offer
to update the other — even across different pipelines.
</Text>
</BlockStack>
</label>
</BlockStack>

<DialogFooter>
<Button onClick={() => handleCopy(track)}>Copy</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
});
116 changes: 116 additions & 0 deletions src/routes/v2/pages/Editor/lineage/PasteLineagePrompt.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { observer } from "mobx-react-lite";
import { useState } from "react";

import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { BlockStack } from "@/components/ui/layout";
import { Text } from "@/components/ui/typography";
import { useEditorSession } from "@/routes/v2/pages/Editor/store/EditorSessionContext";
import { useSpec } from "@/routes/v2/shared/providers/SpecContext";
import { LINEAGE_ORIGIN_ANNOTATION } from "@/utils/lineage";

/**
* Modal shown after pasting or duplicating a task that had no lineage, when the
* source task is still in the current pipeline. The pasted copy has already been
* auto-stamped with a fresh origin id. The user can optionally check "Track
* changes to the original" to back-link the source task to the same origin so
* edits to either will offer to update the other.
*
* Shown only for same-pipeline operations — cross-pipeline source tasks won't
* be found in the current spec, so no modal fires for those.
*/
export const PasteLineagePrompt = observer(function PasteLineagePrompt() {
const spec = useSpec();
const { clipboard, undo } = useEditorSession();
const ctx = clipboard.latestPasteContext;

const [track, setTrack] = useState(false);

if (!ctx || !spec) return null;

// Find pairs where the source exists in the current spec but has no lineage,
// and the new task was freshly stamped. These are the linkable candidates.
const candidates = [...ctx.idMap.entries()].flatMap(
([sourceEntityId, newTaskId]) => {
const sourceTask = spec.tasks.find((t) => t.$id === sourceEntityId);
const newTask = spec.tasks.find((t) => t.$id === newTaskId);
if (!sourceTask || !newTask) return [];
if (sourceTask.annotations.has(LINEAGE_ORIGIN_ANNOTATION)) return [];
const lineage = newTask.annotations.get(LINEAGE_ORIGIN_ANNOTATION);
if (!lineage) return [];
return [{ sourceTask, newTaskId, originId: lineage.originId }];
},
);

if (candidates.length === 0) return null;

const sourceNames =
candidates.length === 1
? `"${candidates[0].sourceTask.name}"`
: `${candidates.length} tasks`;

const handleDone = () => {
if (track) {
undo.withGroup("Link task lineage", () => {
for (const { sourceTask, originId } of candidates) {
sourceTask.annotations.set(LINEAGE_ORIGIN_ANNOTATION, {
originId,
originDigest: sourceTask.componentRef.digest,
originName: sourceTask.componentRef.name,
});
}
});
}
clipboard.clearPasteContext();
};

return (
<Dialog
open
onOpenChange={(open) => {
if (!open) clipboard.clearPasteContext();
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Track changes to the original?</DialogTitle>
<DialogDescription>
You pasted a copy of {sourceNames}. If you track it, edits to either
the original or the copy will offer to update the other.
</DialogDescription>
</DialogHeader>

<BlockStack gap="4">
<label className="flex cursor-pointer items-start gap-3 rounded-md border p-3 hover:bg-accent select-none">
<Checkbox
className="mt-0.5 shrink-0"
checked={track}
onCheckedChange={(val) => setTrack(val === true)}
/>
<BlockStack gap="1">
<Text size="sm" weight="semibold">
Track changes to {sourceNames}
</Text>
<Text size="xs" tone="subdued">
Link the original and this copy so edits to either will offer to
update the other.
</Text>
</BlockStack>
</label>
</BlockStack>

<DialogFooter>
<Button onClick={handleDone}>Done</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
});
39 changes: 34 additions & 5 deletions src/routes/v2/pages/Editor/lineage/ReconcileModeController.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,29 @@ 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 type { NavigationStore } from "@/routes/v2/shared/store/navigationStore";
import { useSharedStores } from "@/routes/v2/shared/store/SharedStoreContext";
import { hydrateComponentReference } from "@/services/componentService";

/**
* Walk a subgraph path (array of task names), calling navigateToSubgraph at
* each level. MobX updates navigation.activeSpec synchronously after each call,
* so reading it in the next iteration gives the correct deeper spec.
*/
function navigateToSubgraphPath(
navigation: NavigationStore,
path: string[],
): void {
for (const taskName of path) {
const currentSpec = navigation.activeSpec;
if (!currentSpec) break;
const task = currentSpec.tasks.find((t) => t.name === taskName);
if (task?.subgraphSpec) {
navigation.navigateToSubgraph(currentSpec, task.$id);
}
}
}

import { collectLineageUsages } from "./collectLineageUsages";
import { findTaskContext } from "./findTaskContext";
import { reconcileModeStore } from "./reconcileModeStore";
Expand All @@ -35,7 +55,7 @@ export const ReconcileModeController = observer(
const navigate = useNavigate();
const { autoSave, undo } = useEditorSession();
const { replaceTask } = useTaskActions();
const { editor } = useSharedStores();
const { editor, navigation } = useSharedStores();

const [ready, setReady] = useState(false);
const [matchTaskIds, setMatchTaskIds] = useState<string[]>([]);
Expand All @@ -60,17 +80,26 @@ export const ReconcileModeController = observer(
});
if (cancelled || !component) return;

const matches = collectLineageUsages(spec, session.originId).filter(
(m) => m.digest !== session.targetDigest,
);
// Navigate into the target subgraph depth before staging. MobX updates
// navigation.activeSpec synchronously, so we read it immediately after.
navigateToSubgraphPath(navigation, session.targetSubgraphPath ?? []);

// Use navigation.activeSpec (reflects any subgraph navigation above)
// rather than the spec prop, which still reflects the pre-nav render.
const targetSpec = navigation.activeSpec ?? spec;

const matches = collectLineageUsages(
targetSpec,
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);
const ctx = findTaskContext(targetSpec, match.taskId);
if (ctx) replaceTask(ctx.spec, match.taskId, component);
}
});
Expand Down
Loading
Loading