feat(FN-3090): add graph position persistence to dependency graph plugin

Merges graph position persistence into the dependency graph plugin (FN-3090) via a scoped storage layer, a dedicated position storage utility, and a React hook that preserves pan/zoom state across sessions. Also includes a responsive CSS fix for ScriptsModal and supporting test coverage in both the

Fusion-Task-Id: FN-3090
This commit is contained in:
Fusion
2026-05-07 05:56:44 -07:00
committed by gsxdsm
parent 957b8b4efe
commit 70007a1d24
11 changed files with 535 additions and 23 deletions

View File

@@ -12,6 +12,7 @@ Plugin-provided top-level **Graph** dashboard view for Fusion.
- **Interaction**: drag-to-pan canvas background, drag-to-reposition nodes, cursor-centered wheel zoom, pinch-to-zoom with stationary midpoint, keyboard shortcuts, zoom toolbar, reset, and fit-to-graph
- **Fit-to-graph**: computes node bounding box with layout node dimensions and applies zoom/pan so the graph fits in viewport with padding
- **Initial auto-fit**: when no saved scoped positions exist, the first non-empty render auto-fits once; subsequent updates preserve user navigation state
- **Position persistence**: dragged node positions are stored per project in browser localStorage and restored on reload
- **Animated transitions**: fit/reset operations animate `transform` (`var(--transition-normal)`), while continuous drag/wheel/pinch stays transition-free for responsiveness
- **Node rendering**: each graph node renders the real dashboard `TaskCard` via `GraphTaskNode` (no duplicated card markup)
- **In-progress behavior**: steps are visible by default and active-task glow (`agent-active`) is preserved because node cards reuse TaskCard directly
@@ -23,6 +24,14 @@ Plugin-provided top-level **Graph** dashboard view for Fusion.
- **Graph edge classes**: `.graph-edge--highlighted` and `.graph-edge--dimmed` are applied during dependency-chain emphasis states
- **Drag behavior**: graph nodes pass `disableDrag={true}` to `TaskCard` so card-level HTML5 drag does not conflict with canvas pan/zoom
## Position persistence
- Storage key format: `kb:${projectId}:dependency-graph-positions` (falls back to `dependency-graph-positions` when no project is selected)
- Read path: positions load on graph mount and whenever `projectId` changes, then merge with fresh auto-layout so new tasks still receive layout defaults
- Write path: positions persist on drag end only (not on every drag frame), filtered to currently visible tasks for stale cleanup
- Reset behavior: Fit to graph / Reset view clear persisted positions and re-apply auto-layout
- Implementation detail: the plugin ships local `scopedStorage` helpers duplicated from dashboard `projectStorage` to stay plugin-isolated while preserving the same `kb:${projectId}:${baseKey}` convention
## Dependency chain highlighting
- **Hover** a node to highlight the full transitive upstream + downstream chain for that task.

View File

@@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { Task } from "@fusion/core";
import { GraphTaskNode } from "./GraphTaskNode";
import { GraphToolbar } from "./GraphToolbar";
@@ -8,18 +8,12 @@ import { computeAutoLayout } from "./layout";
import { useGraphData } from "./useGraphData";
import { useGraphInteraction } from "./useGraphInteraction";
import { useDependencyChain } from "./hooks/useDependencyChain";
import { useGraphPositions } from "./hooks/useGraphPositions";
import { mergePositions, type NodePositions } from "./utils/graphPositionStorage";
import "./DependencyGraph.css";
const NODE_WIDTH = 280;
const NODE_HEIGHT = 100;
const POSITION_STORAGE_KEY = "fusion-plugin-dependency-graph:positions";
function getScopedPositionItem(projectId?: string): string | null {
if (typeof window === "undefined") return null;
if (typeof window.localStorage?.getItem !== "function") return null;
const key = projectId ? `kb:${projectId}:${POSITION_STORAGE_KEY}` : POSITION_STORAGE_KEY;
return window.localStorage.getItem(key);
}
export interface DependencyGraphProps {
tasks: Task[];
@@ -78,18 +72,20 @@ export function DependencyGraph({
() => computeAutoLayout(graphData, { nodeWidth: NODE_WIDTH, nodeHeight: NODE_HEIGHT, horizontalGap: 40, verticalGap: 80 }),
[graphData],
);
const visibleTaskIds = useMemo(() => new Set(filteredTasks.map((task) => task.id)), [filteredTasks]);
const { savedPositions, persistPositions, clearSavedPositions } = useGraphPositions({ projectId, visibleTaskIds });
const [positions, setPositions] = useState<Map<string, { x: number; y: number }>>(autoLayoutPositions);
const [isNodeDragging, setIsNodeDragging] = useState(false);
useEffect(() => {
setPositions((current) => {
const next = new Map<string, { x: number; y: number }>();
for (const [taskId, layoutPosition] of autoLayoutPositions.entries()) {
next.set(taskId, current.get(taskId) ?? layoutPosition);
}
return next;
});
}, [autoLayoutPositions]);
const autoLayoutRecord: NodePositions = {};
for (const [taskId, position] of autoLayoutPositions.entries()) {
autoLayoutRecord[taskId] = position;
}
const merged = savedPositions ? mergePositions(autoLayoutRecord, savedPositions, visibleTaskIds) : autoLayoutRecord;
setPositions(new Map(Object.entries(merged)));
}, [autoLayoutPositions, savedPositions, visibleTaskIds]);
const {
transform,
@@ -110,7 +106,7 @@ export function DependencyGraph({
if (initialFitDoneRef.current) return;
if (filteredTasks.length === 0) return;
const hasSavedPositions = Boolean(getScopedPositionItem(projectId));
const hasSavedPositions = Boolean(savedPositions && Object.keys(savedPositions).length > 0);
if (hasSavedPositions) {
initialFitDoneRef.current = true;
return;
@@ -121,7 +117,7 @@ export function DependencyGraph({
fitToGraph(positions, viewport.clientWidth, viewport.clientHeight, { nodeWidth: NODE_WIDTH, nodeHeight: NODE_HEIGHT });
initialFitDoneRef.current = true;
}, [filteredTasks.length, fitToGraph, positions, projectId]);
}, [filteredTasks.length, fitToGraph, positions, savedPositions]);
const bounds = useMemo(() => {
const values = Array.from(positions.values());
@@ -131,6 +127,20 @@ export function DependencyGraph({
return { width: maxX, height: maxY };
}, [positions]);
const handleResetLayout = useCallback(() => {
clearSavedPositions();
const freshLayout = computeAutoLayout(graphData, { nodeWidth: NODE_WIDTH, nodeHeight: NODE_HEIGHT, horizontalGap: 40, verticalGap: 80 });
setPositions(freshLayout);
}, [clearSavedPositions, graphData]);
const handleNodeDragEnd = useCallback(() => {
const positionRecord: NodePositions = {};
for (const [taskId, position] of positions.entries()) {
positionRecord[taskId] = position;
}
persistPositions(positionRecord);
}, [persistPositions, positions]);
return (
<section className="dependency-graph" data-testid="dependency-graph">
<div
@@ -230,6 +240,7 @@ export function DependencyGraph({
});
}}
onNodeDragStateChange={setIsNodeDragging}
onNodeDragEnd={handleNodeDragEnd}
isHighlighted={highlightedTaskIds.size > 0 && highlightedTaskIds.has(node.task.id)}
isDimmed={highlightedTaskIds.size > 0 && !highlightedTaskIds.has(node.task.id)}
onOpenDetail={onOpenDetail ?? ((task) => onOpenTaskDetail?.(task.id))}
@@ -274,11 +285,16 @@ export function DependencyGraph({
zoomOut(viewport.clientWidth, viewport.clientHeight);
}}
onFitToGraph={() => {
handleResetLayout();
const viewport = viewportRef.current;
if (!viewport) return;
fitToGraph(positions, viewport.clientWidth, viewport.clientHeight, { nodeWidth: NODE_WIDTH, nodeHeight: NODE_HEIGHT });
const freshLayout = computeAutoLayout(graphData, { nodeWidth: NODE_WIDTH, nodeHeight: NODE_HEIGHT, horizontalGap: 40, verticalGap: 80 });
fitToGraph(freshLayout, viewport.clientWidth, viewport.clientHeight, { nodeWidth: NODE_WIDTH, nodeHeight: NODE_HEIGHT });
}}
onResetView={() => {
handleResetLayout();
resetView();
}}
onResetView={resetView}
/>
</section>
);

View File

@@ -37,6 +37,7 @@ export interface GraphTaskNodeProps extends TaskCardBridgeProps, Pick<HTMLAttrib
isDimmed?: boolean;
onNodePositionChange: (taskId: string, position: GraphPosition) => void;
onNodeDragStateChange?: (isDragging: boolean) => void;
onNodeDragEnd?: () => void;
}
const ACTIVE_STATUSES = new Set(["planning", "researching", "executing", "finalizing", "merging", "merging-fix"]);
@@ -60,6 +61,7 @@ export function GraphTaskNode({
onClick,
onNodePositionChange,
onNodeDragStateChange,
onNodeDragEnd,
...taskCardProps
}: GraphTaskNodeProps) {
const { task, globalPaused, taskStuckTimeoutMs, lastFetchTimeMs } = taskCardProps;
@@ -88,6 +90,7 @@ export function GraphTaskNode({
scale,
onPositionChange: onNodePositionChange,
onDragStateChange: onNodeDragStateChange,
onDragEnd: onNodeDragEnd,
});
return (

View File

@@ -0,0 +1,115 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import type { Task } from "@fusion/core";
import { DependencyGraph } from "../DependencyGraph";
const fitToGraph = vi.fn();
vi.mock("@fusion/dashboard/app/components/TaskCard", () => ({
TaskCard: ({ task, onOpenDetail }: { task: Task; onOpenDetail: (task: Task) => void }) => (
<button data-testid={`task-${task.id}`} onClick={() => onOpenDetail(task)}>{task.id}</button>
),
}));
vi.mock("../useGraphInteraction", () => ({
useGraphInteraction: () => ({
transform: "translate(0px, 0px) scale(1)",
zoom: 1,
transitioning: false,
zoomIn: vi.fn(),
zoomOut: vi.fn(),
resetView: vi.fn(),
fitToGraph,
onPointerDown: vi.fn(),
onPointerMove: vi.fn(),
onPointerUp: vi.fn(),
onWheelZoom: vi.fn(),
handleKeyDown: vi.fn(),
}),
}));
vi.mock("../layout", () => ({
computeAutoLayout: ({ nodes }: { nodes: Array<{ task: { id: string } }> }) => {
const map = new Map<string, { x: number; y: number }>();
for (const node of nodes) {
if (node.task.id === "A") map.set("A", { x: 0, y: 0 });
if (node.task.id === "B") map.set("B", { x: 200, y: 0 });
}
return map;
},
}));
function createStorage() {
const store = new Map<string, string>();
return {
getItem: (key: string) => store.get(key) ?? null,
setItem: (key: string, value: string) => {
store.set(key, value);
},
removeItem: (key: string) => {
store.delete(key);
},
};
}
function createTask(id: string, column: Task["column"] = "todo"): Task {
return { id, description: id, column, dependencies: [], steps: [], currentStep: 0, log: [] } as Task;
}
describe("DependencyGraph persistence", () => {
afterEach(() => {
cleanup();
});
beforeEach(() => {
Object.defineProperty(window, "localStorage", { value: createStorage(), configurable: true });
fitToGraph.mockReset();
});
it("persists dragged node position across remount", () => {
const { unmount } = render(<DependencyGraph tasks={[createTask("A")]} projectId="p1" />);
const node = screen.getByTestId("graph-task-node-A");
fireEvent.pointerDown(node, { pointerId: 1, isPrimary: true, clientX: 10, clientY: 10 });
fireEvent.pointerMove(node, { pointerId: 1, isPrimary: true, clientX: 30, clientY: 40 });
fireEvent.pointerUp(node, { pointerId: 1, isPrimary: true, clientX: 30, clientY: 40 });
expect(window.localStorage.getItem("kb:p1:dependency-graph-positions")).toContain('"A":{"x":20,"y":30}');
unmount();
render(<DependencyGraph tasks={[createTask("A")]} projectId="p1" />);
expect(screen.getByTestId("graph-task-node-A").getAttribute("style")).toContain("left: 20px");
expect(screen.getByTestId("graph-task-node-A").getAttribute("style")).toContain("top: 30px");
});
it("merges saved positions with auto-layout for new tasks", () => {
window.localStorage.setItem("kb:p1:dependency-graph-positions", JSON.stringify({ A: { x: 25, y: 35 } }));
render(<DependencyGraph tasks={[createTask("A"), createTask("B")]} projectId="p1" />);
expect(screen.getByTestId("graph-task-node-A").getAttribute("style")).toContain("left: 25px");
expect(screen.getByTestId("graph-task-node-B").getAttribute("style")).toContain("left: 200px");
});
it("fit to graph clears saved positions and reapplies auto-layout", () => {
window.localStorage.setItem("kb:p1:dependency-graph-positions", JSON.stringify({ A: { x: 25, y: 35 } }));
render(<DependencyGraph tasks={[createTask("A")]} projectId="p1" />);
fireEvent.click(screen.getByRole("button", { name: "Fit to graph" }));
expect(window.localStorage.getItem("kb:p1:dependency-graph-positions")).toBeNull();
expect(screen.getByTestId("graph-task-node-A").getAttribute("style")).toContain("left: 0px");
});
it("switching projects loads project-scoped positions", () => {
window.localStorage.setItem("kb:p1:dependency-graph-positions", JSON.stringify({ A: { x: 11, y: 22 } }));
window.localStorage.setItem("kb:p2:dependency-graph-positions", JSON.stringify({ A: { x: 33, y: 44 } }));
const { rerender } = render(<DependencyGraph tasks={[createTask("A")]} projectId="p1" />);
expect(screen.getByTestId("graph-task-node-A").getAttribute("style")).toContain("left: 11px");
rerender(<DependencyGraph tasks={[createTask("A")]} projectId="p2" />);
expect(screen.getByTestId("graph-task-node-A").getAttribute("style")).toContain("left: 33px");
});
});

View File

@@ -0,0 +1,84 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { clearPositions, loadPositions, mergePositions, savePositions } from "../utils/graphPositionStorage";
function createStorage() {
const store = new Map<string, string>();
return {
getItem: (key: string) => store.get(key) ?? null,
setItem: (key: string, value: string) => {
store.set(key, value);
},
removeItem: (key: string) => {
store.delete(key);
},
};
}
describe("graphPositionStorage", () => {
beforeEach(() => {
vi.unstubAllGlobals();
vi.stubGlobal("window", { localStorage: createStorage() });
});
it("loadPositions returns parsed positions from localStorage", () => {
window.localStorage.setItem("kb:p1:dependency-graph-positions", JSON.stringify({ a: { x: 1, y: 2 } }));
expect(loadPositions("p1")).toEqual({ a: { x: 1, y: 2 } });
});
it("loadPositions returns empty object when localStorage is empty", () => {
expect(loadPositions("p1")).toEqual({});
});
it("loadPositions returns empty object for invalid json", () => {
window.localStorage.setItem("kb:p1:dependency-graph-positions", "{oops");
expect(loadPositions("p1")).toEqual({});
});
it("loadPositions skips entries with invalid position shape", () => {
window.localStorage.setItem(
"kb:p1:dependency-graph-positions",
JSON.stringify({
good: { x: 1, y: 2 },
badX: { x: "1", y: 2 },
badY: { x: 1, y: null },
}),
);
expect(loadPositions("p1")).toEqual({ good: { x: 1, y: 2 } });
});
it("savePositions writes filtered positions json to scoped localStorage key", () => {
savePositions({ a: { x: 1, y: 2 }, b: { x: 3, y: 4 } }, new Set(["a"]), "p1");
expect(window.localStorage.getItem("kb:p1:dependency-graph-positions")).toBe(JSON.stringify({ a: { x: 1, y: 2 } }));
});
it("clearPositions removes scoped localStorage key", () => {
window.localStorage.setItem("kb:p1:dependency-graph-positions", JSON.stringify({ a: { x: 1, y: 2 } }));
clearPositions("p1");
expect(window.localStorage.getItem("kb:p1:dependency-graph-positions")).toBeNull();
});
it("mergePositions prefers saved for overlap and keeps auto-layout for new tasks", () => {
expect(
mergePositions(
{ a: { x: 1, y: 1 }, b: { x: 2, y: 2 } },
{ a: { x: 10, y: 10 } },
new Set(["a", "b"]),
),
).toEqual({ a: { x: 10, y: 10 }, b: { x: 2, y: 2 } });
});
it("mergePositions omits non-visible ids", () => {
expect(
mergePositions(
{ a: { x: 1, y: 1 }, hidden: { x: 9, y: 9 } },
{ hidden: { x: 10, y: 10 } },
new Set(["a"]),
),
).toEqual({ a: { x: 1, y: 1 } });
});
it("mergePositions returns auto-layout unchanged when saved is empty", () => {
expect(mergePositions({ a: { x: 1, y: 2 } }, {}, new Set(["a"]))).toEqual({ a: { x: 1, y: 2 } });
});
});

View File

@@ -0,0 +1,58 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { getScopedItem, removeScopedItem, scopedKey, setScopedItem } from "../utils/scopedStorage";
function createStorage() {
const store = new Map<string, string>();
return {
getItem: (key: string) => store.get(key) ?? null,
setItem: (key: string, value: string) => {
store.set(key, value);
},
removeItem: (key: string) => {
store.delete(key);
},
};
}
describe("scopedStorage", () => {
beforeEach(() => {
vi.unstubAllGlobals();
vi.stubGlobal("window", { localStorage: createStorage() });
});
it("scopedKey uses kb project prefix when project id is provided", () => {
expect(scopedKey("baseKey", "project-1")).toBe("kb:project-1:baseKey");
});
it("scopedKey falls back to unscoped key for undefined/null/empty project id", () => {
expect(scopedKey("baseKey", undefined)).toBe("baseKey");
expect(scopedKey("baseKey", null)).toBe("baseKey");
expect(scopedKey("baseKey", "")).toBe("baseKey");
});
it("getScopedItem reads from localStorage using scoped key", () => {
window.localStorage.setItem("kb:project-1:baseKey", "value");
expect(getScopedItem("baseKey", "project-1")).toBe("value");
});
it("getScopedItem returns null when window is undefined", () => {
vi.stubGlobal("window", undefined);
expect(getScopedItem("baseKey", "project-1")).toBeNull();
});
it("setScopedItem writes to localStorage using scoped key", () => {
setScopedItem("baseKey", "value", "project-1");
expect(window.localStorage.getItem("kb:project-1:baseKey")).toBe("value");
});
it("setScopedItem is a no-op when window is undefined", () => {
vi.stubGlobal("window", undefined);
expect(() => setScopedItem("baseKey", "value", "project-1")).not.toThrow();
});
it("removeScopedItem removes from localStorage using scoped key", () => {
window.localStorage.setItem("kb:project-1:baseKey", "value");
removeScopedItem("baseKey", "project-1");
expect(window.localStorage.getItem("kb:project-1:baseKey")).toBeNull();
});
});

View File

@@ -0,0 +1,66 @@
import { act, renderHook } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { useGraphPositions } from "../hooks/useGraphPositions";
import * as storage from "../utils/graphPositionStorage";
vi.mock("../utils/graphPositionStorage", () => ({
loadPositions: vi.fn(),
savePositions: vi.fn(),
clearPositions: vi.fn(),
}));
describe("useGraphPositions", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("loads saved positions on mount with project scope", () => {
vi.mocked(storage.loadPositions).mockReturnValue({ a: { x: 1, y: 2 } });
const { result } = renderHook(() => useGraphPositions({ projectId: "p1", visibleTaskIds: new Set(["a"]) }));
expect(storage.loadPositions).toHaveBeenCalledWith("p1");
expect(result.current.savedPositions).toEqual({ a: { x: 1, y: 2 } });
});
it("reloads positions when project id changes", () => {
vi.mocked(storage.loadPositions).mockReturnValueOnce({ a: { x: 1, y: 1 } }).mockReturnValueOnce({ b: { x: 2, y: 2 } });
const { result, rerender } = renderHook(
({ projectId }) => useGraphPositions({ projectId, visibleTaskIds: new Set(["a", "b"]) }),
{ initialProps: { projectId: "p1" } },
);
rerender({ projectId: "p2" });
expect(storage.loadPositions).toHaveBeenNthCalledWith(1, "p1");
expect(storage.loadPositions).toHaveBeenNthCalledWith(2, "p2");
expect(result.current.savedPositions).toEqual({ b: { x: 2, y: 2 } });
});
it("persistPositions writes scoped and filters non-visible ids", () => {
vi.mocked(storage.loadPositions).mockReturnValue({});
const { result } = renderHook(() => useGraphPositions({ projectId: "p1", visibleTaskIds: new Set(["a"]) }));
act(() => {
result.current.persistPositions({ a: { x: 1, y: 2 }, hidden: { x: 9, y: 9 } });
});
expect(storage.savePositions).toHaveBeenCalledWith({ a: { x: 1, y: 2 }, hidden: { x: 9, y: 9 } }, new Set(["a"]), "p1");
expect(result.current.savedPositions).toEqual({ a: { x: 1, y: 2 } });
});
it("clearSavedPositions clears storage and resets state", () => {
vi.mocked(storage.loadPositions).mockReturnValue({ a: { x: 1, y: 2 } });
const { result } = renderHook(() => useGraphPositions({ projectId: "p1", visibleTaskIds: new Set(["a"]) }));
act(() => {
result.current.clearSavedPositions();
});
expect(storage.clearPositions).toHaveBeenCalledWith("p1");
expect(result.current.savedPositions).toBeNull();
});
});

View File

@@ -0,0 +1,45 @@
import { useCallback, useEffect, useState } from "react";
import { clearPositions, loadPositions, savePositions, type NodePositions } from "../utils/graphPositionStorage";
function filterVisiblePositions(positions: NodePositions, visibleTaskIds: Set<string>): NodePositions {
const filtered: NodePositions = {};
for (const [taskId, position] of Object.entries(positions)) {
if (visibleTaskIds.has(taskId)) {
filtered[taskId] = position;
}
}
return filtered;
}
export function useGraphPositions({
projectId,
visibleTaskIds,
}: {
projectId: string | undefined;
visibleTaskIds: Set<string>;
}): {
savedPositions: NodePositions | null;
persistPositions: (positions: NodePositions) => void;
clearSavedPositions: () => void;
} {
const [savedPositions, setSavedPositions] = useState<NodePositions | null>(null);
useEffect(() => {
setSavedPositions(loadPositions(projectId));
}, [projectId]);
const persistPositions = useCallback(
(positions: NodePositions) => {
savePositions(positions, visibleTaskIds, projectId);
setSavedPositions(filterVisiblePositions(positions, visibleTaskIds));
},
[projectId, visibleTaskIds],
);
const clearSavedPositions = useCallback(() => {
clearPositions(projectId);
setSavedPositions(null);
}, [projectId]);
return { savedPositions, persistPositions, clearSavedPositions };
}

View File

@@ -10,6 +10,7 @@ interface UseNodeDragOptions {
scale: number;
onPositionChange: (taskId: string, position: GraphPosition) => void;
onDragStateChange?: (isDragging: boolean) => void;
onDragEnd?: () => void;
}
interface PendingState {
@@ -18,7 +19,7 @@ interface PendingState {
startPosition: GraphPosition;
}
export function useNodeDrag({ taskId, position, scale, onPositionChange, onDragStateChange }: UseNodeDragOptions) {
export function useNodeDrag({ taskId, position, scale, onPositionChange, onDragStateChange, onDragEnd }: UseNodeDragOptions) {
const [isDragging, setIsDragging] = useState(false);
const pendingRef = useRef<PendingState | null>(null);
const positionRef = useRef(position);
@@ -31,9 +32,10 @@ export function useNodeDrag({ taskId, position, scale, onPositionChange, onDragS
setIsDragging(false);
if (dragging) {
onDragStateChange?.(false);
onDragEnd?.();
suppressClickRef.current = true;
}
}, [onDragStateChange]);
}, [onDragEnd, onDragStateChange]);
const onPointerDown = useCallback((event: ReactPointerEvent<HTMLElement>) => {
if (!event.isPrimary) return;

View File

@@ -0,0 +1,65 @@
import { getScopedItem, removeScopedItem, setScopedItem } from "./scopedStorage";
export type NodePositions = Record<string, { x: number; y: number }>;
const STORAGE_KEY = "dependency-graph-positions";
function isPosition(value: unknown): value is { x: number; y: number } {
if (!value || typeof value !== "object") return false;
const candidate = value as { x?: unknown; y?: unknown };
return typeof candidate.x === "number" && Number.isFinite(candidate.x) && typeof candidate.y === "number" && Number.isFinite(candidate.y);
}
export function loadPositions(projectId?: string): NodePositions {
const raw = getScopedItem(STORAGE_KEY, projectId);
if (!raw) return {};
try {
const parsed = JSON.parse(raw) as Record<string, unknown>;
if (!parsed || typeof parsed !== "object") return {};
const result: NodePositions = {};
for (const [taskId, value] of Object.entries(parsed)) {
if (isPosition(value)) {
result[taskId] = value;
}
}
return result;
} catch {
return {};
}
}
export function savePositions(positions: NodePositions, visibleTaskIds: Set<string>, projectId?: string): void {
const filtered: NodePositions = {};
for (const [taskId, position] of Object.entries(positions)) {
if (visibleTaskIds.has(taskId) && isPosition(position)) {
filtered[taskId] = position;
}
}
setScopedItem(STORAGE_KEY, JSON.stringify(filtered), projectId);
}
export function clearPositions(projectId?: string): void {
removeScopedItem(STORAGE_KEY, projectId);
}
export function mergePositions(autoLayoutPositions: NodePositions, savedPositions: NodePositions, visibleTaskIds: Set<string>): NodePositions {
const merged: NodePositions = {};
for (const [taskId, position] of Object.entries(autoLayoutPositions)) {
if (visibleTaskIds.has(taskId) && isPosition(position)) {
merged[taskId] = position;
}
}
for (const [taskId, position] of Object.entries(savedPositions)) {
if (visibleTaskIds.has(taskId) && isPosition(position)) {
merged[taskId] = position;
}
}
return merged;
}

View File

@@ -0,0 +1,49 @@
// Duplicated from packages/dashboard/app/utils/projectStorage.ts for plugin isolation.
// Keeps the same project-scoped key convention: kb:${projectId}:${baseKey}.
export function scopedKey(baseKey: string, projectId?: string | null): string {
if (typeof projectId !== "string" || projectId.length === 0) {
return baseKey;
}
return `kb:${projectId}:${baseKey}`;
}
export function getScopedItem(baseKey: string, projectId?: string | null): string | null {
if (typeof window === "undefined") {
return null;
}
const getItem = window.localStorage?.getItem;
if (typeof getItem !== "function") {
return null;
}
return getItem.call(window.localStorage, scopedKey(baseKey, projectId));
}
export function setScopedItem(baseKey: string, value: string, projectId?: string | null): void {
if (typeof window === "undefined") {
return;
}
const setItem = window.localStorage?.setItem;
if (typeof setItem !== "function") {
return;
}
setItem.call(window.localStorage, scopedKey(baseKey, projectId), value);
}
export function removeScopedItem(baseKey: string, projectId?: string | null): void {
if (typeof window === "undefined") {
return;
}
const removeItem = window.localStorage?.removeItem;
if (typeof removeItem !== "function") {
return;
}
removeItem.call(window.localStorage, scopedKey(baseKey, projectId));
}