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
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { useState } from "react";
import { BlockStack } from "@/components/ui/layout";
import { VerticalResizeHandle } from "@/components/ui/resize-handle";
import { Separator } from "@/components/ui/separator";
import { Text } from "@/components/ui/typography";
import { useTaskActions } from "@/routes/v2/pages/Editor/store/actions/useTaskActions";
import { useSpec } from "@/routes/v2/shared/providers/SpecContext";
import { useOptionalWindowContext } from "@/routes/v2/shared/windows/ContentWindowStateContext";
Expand All @@ -18,9 +19,61 @@ import { useSelectionSet } from "./hooks/useSelectionSet";
import { useUpgradeCandidatesFromOutdated } from "./hooks/useUpgradeCandidatesFromOutdated";
import { useUpgradePreviewOverlay } from "./hooks/useUpgradePreviewOverlay";
import { candidateHasIssues, type UpgradeCandidate } from "./types";
import { groupCandidatesByOrigin } from "./utils/groupCandidatesByOrigin";

const DEFAULT_LEFT_PANEL_WIDTH = 340;

/** Flat or grouped list of candidates depending on whether grouping is helpful. */
function CandidateList({
candidates,
selection,
focusedId,
onToggle,
onFocus,
}: {
candidates: UpgradeCandidate[];
selection: Set<string>;
focusedId: string | null;
onToggle: (id: string, checked: boolean) => void;
onFocus: (id: string) => void;
}) {
const groups = groupCandidatesByOrigin(candidates);

const renderRow = (candidate: UpgradeCandidate) => (
<UpgradeCandidateRow
key={candidate.taskId}
candidate={candidate}
checked={selection.has(candidate.taskId)}
selected={candidate.taskId === focusedId}
onCheckedChange={(checked) => onToggle(candidate.taskId, checked)}
onSelect={() => onFocus(candidate.taskId)}
/>
);

if (!groups) {
return (
<BlockStack className="py-1">{candidates.map(renderRow)}</BlockStack>
);
}

return (
<BlockStack className="py-1">
{groups.map((group) => (
<BlockStack key={group.originId ?? "__ungrouped"}>
{group.componentName && (
<div className="px-3 pt-2 pb-0.5">
<Text size="xs" tone="subdued" className="truncate">
{group.componentName}
</Text>
</div>
)}
{group.candidates.map(renderRow)}
</BlockStack>
))}
</BlockStack>
);
}

type UpgradeComponentsDataSource = "real" | "mock";

interface UpgradeComponentsContentProps {
Expand Down Expand Up @@ -83,20 +136,13 @@ const UpgradeComponentsInner = observer(function UpgradeComponentsInner({
style={{ width: DEFAULT_LEFT_PANEL_WIDTH }}
>
<div className="h-full overflow-y-auto">
<BlockStack className="py-1">
{candidates.map((candidate) => (
<UpgradeCandidateRow
key={candidate.taskId}
candidate={candidate}
checked={selection.has(candidate.taskId)}
selected={candidate.taskId === focusedId}
onCheckedChange={(checked) =>
toggle(candidate.taskId, checked)
}
onSelect={() => setFocusedId(candidate.taskId)}
/>
))}
</BlockStack>
<CandidateList
candidates={candidates}
selection={selection}
focusedId={focusedId}
onToggle={toggle}
onFocus={setFocusedId}
/>
</div>
<VerticalResizeHandle side="right" minWidth={300} />
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,10 +87,26 @@ export function UpgradeCandidateRow({
blockAlign="center"
data-testid="upgrade-candidate-row-digest"
>
<Badge variant="secondary" size="sm">
{truncateDigest(candidate.currentDigest)}
</Badge>
<Icon name="ArrowRight" size="xs" className="text-muted-foreground" />
{candidate.isEditedOffMainline ? (
<Badge
variant="outline"
size="sm"
className="border-amber-400 text-amber-600"
>
Edited locally
</Badge>
) : (
<>
<Badge variant="secondary" size="sm">
{truncateDigest(candidate.currentDigest)}
</Badge>
<Icon
name="ArrowRight"
size="xs"
className="text-muted-foreground"
/>
</>
)}
<Badge variant="secondary" size="sm">
{truncateDigest(candidate.newComponentRef.digest ?? "")}
</Badge>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,16 +1,21 @@
import { useOutdatedComponents } from "@/components/shared/ManageComponent/hooks/useOutdatedComponents";
import type { ComponentReference } from "@/models/componentSpec";
import type { ComponentReference, ComponentSpec } from "@/models/componentSpec";
import type { UpgradeCandidate } from "@/routes/v2/pages/Editor/components/UpgradeComponents/types";
import { buildUpgradeCandidateFromResolved } from "@/routes/v2/pages/Editor/components/UpgradeComponents/utils/buildUpgradeCandidateFromResolved";
import {
collectUsedComponentReferencesFromV2Spec,
EMPTY_USED_COMPONENTS,
} from "@/routes/v2/pages/Editor/components/UpgradeComponents/utils/collectUsedComponentReferencesFromV2Spec";
import { useSpec } from "@/routes/v2/shared/providers/SpecContext";
import { LINEAGE_ORIGIN_ANNOTATION } from "@/utils/lineage";

/**
* Upgrade candidates derived from {@link useOutdatedComponents} for the
* current graph's used components, aligned with {@link replaceTask} previews.
* Recurses through subgraphs so nested tasks are included. Also surfaces
* "edited-off-mainline" tasks: instances whose digests were changed locally
* so they fell out of the catalog's supersession chain, but whose lineage
* still traces back to a component family that has an upgrade available.
*
* The window is only opened from the V2 editor, so {@link useSpec} should
* always return a non-null spec here; the null branch is defensive and
Expand All @@ -35,27 +40,78 @@ export function useUpgradeCandidatesFromOutdated(): UpgradeCandidate[] {
}

const candidates: UpgradeCandidate[] = [];
for (const task of spec.tasks) {
const digest = task.componentRef.digest;
if (!digest) {
continue;
}
const newComponentRef = digestToMrc.get(digest);
if (!newComponentRef) {
continue;
const seenTaskIds = new Set<string>();
// originId → newRef for the off-mainline secondary pass.
const originIdToNewRef = new Map<string, ComponentReference>();

// Primary walk: tasks whose digest is directly in the supersession chain.
// `s` is each task's immediately-owning spec so lost-binding checks are scoped
// to the bindings at that nesting level (not the root).
function walkPrimary(s: ComponentSpec) {
for (const task of s.tasks) {
const digest = task.componentRef.digest;
const newComponentRef = digest ? digestToMrc.get(digest) : undefined;
const originId = task.annotations.get(
LINEAGE_ORIGIN_ANNOTATION,
)?.originId;

if (newComponentRef && digest) {
candidates.push({
...buildUpgradeCandidateFromResolved(
task.$id,
task.name,
digest,
task.resolvedComponentSpec,
newComponentRef,
s,
),
originId,
});
seenTaskIds.add(task.$id);
if (originId) originIdToNewRef.set(originId, newComponentRef);
}

if (task.subgraphSpec) walkPrimary(task.subgraphSpec);
}
}

candidates.push(
buildUpgradeCandidateFromResolved(
task.$id,
task.name,
digest,
task.resolvedComponentSpec,
newComponentRef,
spec,
),
);
walkPrimary(spec);

// Secondary walk: tasks that were locally edited (digest off-chain) but share
// a lineage origin with a family that does have an upgrade available.
function walkOffMainline(s: ComponentSpec) {
for (const task of s.tasks) {
if (!seenTaskIds.has(task.$id)) {
const originId = task.annotations.get(
LINEAGE_ORIGIN_ANNOTATION,
)?.originId;
const newComponentRef = originId
? originIdToNewRef.get(originId)
: undefined;

if (originId && newComponentRef) {
const digest = task.componentRef.digest ?? "";
candidates.push({
...buildUpgradeCandidateFromResolved(
task.$id,
task.name,
digest,
task.resolvedComponentSpec,
newComponentRef,
s,
),
originId,
isEditedOffMainline: true,
});
seenTaskIds.add(task.$id);
}
}

if (task.subgraphSpec) walkOffMainline(task.subgraphSpec);
}
}

walkOffMainline(spec);

return candidates;
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,14 @@ export interface UpgradeCandidate {
outputDiff: EntityDiff<OutputSpec>;
lostBindings: LostBinding[];
predictedIssues: ValidationIssue[];
/** Lineage origin id shared by all tasks descended from the same component. */
originId?: string;
/**
* True when this task was locally edited so its digest fell outside the
* published supersession chain, but its lineage still traces back to a
* component family that has a catalog upgrade available.
*/
isEditedOffMainline?: boolean;
}

export function candidateHasIssues(candidate: UpgradeCandidate): boolean {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { describe, expect, it } from "vitest";

import { ComponentSpec } from "@/models/componentSpec/entities/componentSpec";
import { Task } from "@/models/componentSpec/entities/task";

import { collectUsedComponentReferencesFromV2Spec } from "./collectUsedComponentReferencesFromV2Spec";

const task = ($id: string, digest: string, subgraphSpec?: ComponentSpec) =>
new Task({ $id, name: $id, componentRef: { digest }, subgraphSpec });

describe("collectUsedComponentReferencesFromV2Spec", () => {
it("collects unique digests from root tasks", () => {
const spec = new ComponentSpec({
name: "Root",
tasks: [task("a", "d1"), task("b", "d2"), task("c", "d1")],
});

const refs = collectUsedComponentReferencesFromV2Spec(spec);
expect(refs.map((r) => r.digest).sort()).toEqual(["d1", "d2"]);
});

it("recurses into subgraphs and collects their digests too", () => {
const sub = new ComponentSpec({
name: "Sub",
tasks: [task("nested", "d-nested")],
});
const spec = new ComponentSpec({
name: "Root",
tasks: [task("root", "d-root"), task("group", "d-group", sub)],
});

const refs = collectUsedComponentReferencesFromV2Spec(spec);
expect(refs.map((r) => r.digest).sort()).toEqual([
"d-group",
"d-nested",
"d-root",
]);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,25 @@ export const EMPTY_USED_COMPONENTS: ComponentReference[] = [];

/**
* Unique component references used by tasks in the current V2 editor spec
* (deduped by digest). Mirrors {@link fetchUsedComponents} for graph tasks.
* (deduped by digest). Recurses through subgraphs so tasks nested inside
* a group task are also considered for upgrades.
* Mirrors {@link fetchUsedComponents} for graph tasks.
*/
export function collectUsedComponentReferencesFromV2Spec(
spec: ComponentSpec,
): ComponentReference[] {
const usedComponentsMap = new Map<string, ComponentReference>();

for (const task of spec.tasks) {
const key = task.componentRef.digest;
if (key && !usedComponentsMap.has(key)) {
usedComponentsMap.set(key, { ...task.componentRef });
function walk(s: ComponentSpec) {
for (const task of s.tasks) {
const key = task.componentRef.digest;
if (key && !usedComponentsMap.has(key)) {
usedComponentsMap.set(key, { ...task.componentRef });
}
if (task.subgraphSpec) walk(task.subgraphSpec);
}
}

walk(spec);
return Array.from(usedComponentsMap.values());
}
Loading
Loading