将资源依赖图计算迁移到Rust
新增只读资源依赖图模型,完成过滤、去重、环检测和任务流聚合 前端SVG仅负责几何渲染、搜索联动、选中高亮和拖动预览 补充资源图、覆盖层和工作台回归测试 同步更新工作台PRD、技术方案与项目记忆
This commit is contained in:
@@ -204,6 +204,17 @@ pub(crate) fn read_local_project_resource_canvas_layout(
|
||||
read_project_resource_canvas_layout_at(Path::new(project_path.trim()), mode)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn read_local_project_resource_graph(
|
||||
project_path: String,
|
||||
expected_project_id: String,
|
||||
resources: Vec<ProjectResourceGraphNodeInput>,
|
||||
) -> Result<ProjectResourceGraphReadModel, String> {
|
||||
let root = validated_local_project_directory_path(project_path.trim())?;
|
||||
enforce_project_auto_permission_policy(&root, "asset.list")?;
|
||||
read_project_resource_graph_at(&root, expected_project_id.trim(), resources)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn update_local_project_resource_canvas_layout(
|
||||
project_path: String,
|
||||
|
||||
@@ -1969,6 +1969,7 @@ fn handle_game_creator_gui_run_event(event: &tauri::RunEvent) {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(test))]
|
||||
fn main() {
|
||||
let mut args = std::env::args().skip(1).collect::<Vec<_>>();
|
||||
#[cfg(target_os = "linux")]
|
||||
@@ -2322,6 +2323,7 @@ fn main() {
|
||||
stop_local_game_preview_if_matches,
|
||||
get_local_game_preview_status,
|
||||
read_local_project_resource_canvas_layout,
|
||||
read_local_project_resource_graph,
|
||||
update_local_project_resource_canvas_layout,
|
||||
get_local_game_project_revision,
|
||||
get_local_game_manifest
|
||||
|
||||
@@ -10,6 +10,7 @@ mod export;
|
||||
mod filesystem;
|
||||
mod manifest;
|
||||
mod memory;
|
||||
mod resource_dependency_graph;
|
||||
mod resource_layout;
|
||||
mod verification;
|
||||
|
||||
@@ -20,5 +21,6 @@ pub(crate) use export::*;
|
||||
pub(crate) use filesystem::*;
|
||||
pub(crate) use manifest::*;
|
||||
pub(crate) use memory::*;
|
||||
pub(crate) use resource_dependency_graph::*;
|
||||
pub(crate) use resource_layout::*;
|
||||
pub(crate) use verification::*;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+224
-76
@@ -27,9 +27,14 @@ type Rect = Point & {
|
||||
|
||||
type SectionOrigins = Partial<Record<ProjectResourceCanvasSection, Point>>;
|
||||
|
||||
type RectLookup = {
|
||||
get(resourceId: string): Rect | undefined;
|
||||
};
|
||||
|
||||
export type ResourceDependencyOverlayProps = {
|
||||
graph: ProjectResourceGraph;
|
||||
positions: readonly ProjectResourceCanvasPosition[];
|
||||
dragPreview: (Point & { resourceId: string }) | null;
|
||||
visibleResourceIds: ReadonlySet<string>;
|
||||
selectedResourceId: string | null;
|
||||
};
|
||||
@@ -117,7 +122,7 @@ function rectAnchor(rect: Rect, direction: 1 | -1): Point {
|
||||
|
||||
function referenceGeometry(
|
||||
edge: ProjectResourceReferenceEdge,
|
||||
rectByResourceId: ReadonlyMap<string, Rect>,
|
||||
rectByResourceId: RectLookup,
|
||||
) {
|
||||
const sourceRect = rectByResourceId.get(edge.sourceResourceId);
|
||||
const targetRect = rectByResourceId.get(edge.targetResourceId);
|
||||
@@ -148,7 +153,7 @@ function referenceGeometry(
|
||||
|
||||
function taskFlowGeometry(
|
||||
flow: ProjectResourceTaskFlow,
|
||||
rectByResourceId: ReadonlyMap<string, Rect>,
|
||||
rectByResourceId: RectLookup,
|
||||
) {
|
||||
const sourceRects = flow.sourceResourceIds.flatMap((resourceId) => {
|
||||
const rect = rectByResourceId.get(resourceId);
|
||||
@@ -198,11 +203,15 @@ function taskFlowGeometry(
|
||||
export function ResourceDependencyOverlay({
|
||||
graph,
|
||||
positions,
|
||||
dragPreview,
|
||||
visibleResourceIds,
|
||||
selectedResourceId,
|
||||
}: ResourceDependencyOverlayProps) {
|
||||
const markerPrefix = useId().replace(/[^a-zA-Z0-9_-]/gu, '');
|
||||
const overlayRef = useRef<SVGSVGElement>(null);
|
||||
const referencePathRefs = useRef(new Map<string, SVGPathElement>());
|
||||
const taskFlowGroupRefs = useRef(new Map<string, SVGGElement>());
|
||||
const activeDragResourceIdRef = useRef<string | null>(null);
|
||||
const [sectionOrigins, setSectionOrigins] = useState<SectionOrigins>({});
|
||||
|
||||
useLayoutEffect(() => {
|
||||
@@ -257,7 +266,7 @@ export function ResourceDependencyOverlay({
|
||||
observer?.disconnect();
|
||||
window.removeEventListener('resize', scheduleMeasure);
|
||||
};
|
||||
}, [graph, positions, visibleResourceIds]);
|
||||
}, []);
|
||||
|
||||
const rectByResourceId = useMemo(() => {
|
||||
const result = new Map<string, Rect>();
|
||||
@@ -290,6 +299,216 @@ export function ResourceDependencyOverlay({
|
||||
selectedResourceId && graph.resourceIds.has(selectedResourceId),
|
||||
);
|
||||
|
||||
const renderTaskFlows = useMemo(
|
||||
() =>
|
||||
graph.taskFlows.map((flow) => {
|
||||
const geometry = taskFlowGeometry(flow, rectByResourceId);
|
||||
if (!geometry) {
|
||||
return null;
|
||||
}
|
||||
const highlighted = neighbors.connectedEdgeIds.has(flow.id);
|
||||
const className = `game-resource-dependency-edge game-resource-dependency-edge--task${
|
||||
highlighted ? ' is-highlighted' : selected ? ' is-dimmed' : ''
|
||||
}${flow.cyclic ? ' is-cyclic' : ''}`;
|
||||
return (
|
||||
<g
|
||||
ref={(node) => {
|
||||
if (node) {
|
||||
taskFlowGroupRefs.current.set(flow.id, node);
|
||||
} else {
|
||||
taskFlowGroupRefs.current.delete(flow.id);
|
||||
}
|
||||
}}
|
||||
key={flow.id}
|
||||
className={className}
|
||||
data-edge-kind="task-flow"
|
||||
data-edge-id={flow.id}
|
||||
data-source-task-id={flow.sourceTaskId}
|
||||
data-target-task-id={flow.targetTaskId}
|
||||
data-cyclic={flow.cyclic || undefined}
|
||||
>
|
||||
<title>{`任务流转:${flow.sourceTaskId} → ${flow.targetTaskId}${
|
||||
flow.cyclic ? '(检测到依赖环)' : ''
|
||||
}`}</title>
|
||||
{geometry.sourceAnchors.map(({ resourceId, point }) => (
|
||||
<path
|
||||
key={`source:${resourceId}`}
|
||||
className="game-resource-dependency-branch"
|
||||
data-branch-side="source"
|
||||
data-resource-id={resourceId}
|
||||
d={taskFlowBranchPath(point, geometry.sourceHub)}
|
||||
/>
|
||||
))}
|
||||
<path
|
||||
className="game-resource-dependency-trunk"
|
||||
d={connectionPath(geometry.sourceHub, geometry.targetHub)}
|
||||
/>
|
||||
{geometry.targetAnchors.map(({ resourceId, point }) => (
|
||||
<path
|
||||
key={`target:${resourceId}`}
|
||||
className="game-resource-dependency-branch"
|
||||
data-branch-side="target"
|
||||
data-resource-id={resourceId}
|
||||
d={taskFlowBranchPath(geometry.targetHub, point)}
|
||||
markerEnd={`url(#${markerPrefix}-task-flow-arrow)`}
|
||||
/>
|
||||
))}
|
||||
</g>
|
||||
);
|
||||
}),
|
||||
[
|
||||
graph.taskFlows,
|
||||
markerPrefix,
|
||||
neighbors.connectedEdgeIds,
|
||||
rectByResourceId,
|
||||
selected,
|
||||
],
|
||||
);
|
||||
|
||||
const renderReferenceEdges = useMemo(
|
||||
() =>
|
||||
graph.referenceEdges.map((edge) => {
|
||||
const geometry = referenceGeometry(edge, rectByResourceId);
|
||||
if (!geometry) {
|
||||
return null;
|
||||
}
|
||||
const highlighted = neighbors.connectedEdgeIds.has(edge.id);
|
||||
const className = `game-resource-dependency-edge game-resource-dependency-edge--reference${
|
||||
highlighted ? ' is-highlighted' : selected ? ' is-dimmed' : ''
|
||||
}${edge.cyclic ? ' is-cyclic' : ''}`;
|
||||
return (
|
||||
<path
|
||||
ref={(node) => {
|
||||
if (node) {
|
||||
referencePathRefs.current.set(edge.id, node);
|
||||
} else {
|
||||
referencePathRefs.current.delete(edge.id);
|
||||
}
|
||||
}}
|
||||
key={edge.id}
|
||||
className={className}
|
||||
data-edge-kind="asset-reference"
|
||||
data-edge-id={edge.id}
|
||||
data-source-resource-id={edge.sourceResourceId}
|
||||
data-target-resource-id={edge.targetResourceId}
|
||||
data-cyclic={edge.cyclic || undefined}
|
||||
data-self-loop={geometry.selfLoop || undefined}
|
||||
d={geometry.path}
|
||||
markerEnd={`url(#${markerPrefix}-asset-reference-arrow)`}
|
||||
>
|
||||
<title>{`资源引用${edge.cyclic ? '(检测到依赖环)' : ''}`}</title>
|
||||
</path>
|
||||
);
|
||||
}),
|
||||
[
|
||||
graph.referenceEdges,
|
||||
markerPrefix,
|
||||
neighbors.connectedEdgeIds,
|
||||
rectByResourceId,
|
||||
selected,
|
||||
],
|
||||
);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const affectedResourceIds = new Set<string>();
|
||||
if (activeDragResourceIdRef.current) {
|
||||
affectedResourceIds.add(activeDragResourceIdRef.current);
|
||||
}
|
||||
if (dragPreview) {
|
||||
affectedResourceIds.add(dragPreview.resourceId);
|
||||
}
|
||||
activeDragResourceIdRef.current = dragPreview?.resourceId ?? null;
|
||||
if (affectedResourceIds.size === 0) {
|
||||
return;
|
||||
}
|
||||
const dragBasePosition = dragPreview
|
||||
? positions.find(
|
||||
(position) => position.resourceId === dragPreview.resourceId,
|
||||
)
|
||||
: undefined;
|
||||
const rectLookup = {
|
||||
get(resourceId: string) {
|
||||
const rect = rectByResourceId.get(resourceId);
|
||||
if (!rect) {
|
||||
return undefined;
|
||||
}
|
||||
return dragPreview?.resourceId === resourceId
|
||||
? {
|
||||
...rect,
|
||||
x:
|
||||
rect.x - (dragBasePosition?.x ?? 0) + dragPreview.x,
|
||||
y:
|
||||
rect.y - (dragBasePosition?.y ?? 0) + dragPreview.y,
|
||||
}
|
||||
: rect;
|
||||
},
|
||||
};
|
||||
const affectedEdgeIds = new Set<string>();
|
||||
for (const resourceId of affectedResourceIds) {
|
||||
const index = graph.connectionIndex.get(resourceId);
|
||||
index?.referenceEdgeIds.forEach((edgeId) =>
|
||||
affectedEdgeIds.add(edgeId),
|
||||
);
|
||||
index?.taskFlowIds.forEach((flowId) => affectedEdgeIds.add(flowId));
|
||||
}
|
||||
for (const edgeId of affectedEdgeIds) {
|
||||
const referenceEdge = graph.referenceEdgeById.get(edgeId);
|
||||
if (referenceEdge) {
|
||||
const geometry = referenceGeometry(referenceEdge, rectLookup);
|
||||
const path = referencePathRefs.current.get(edgeId);
|
||||
if (geometry && path) {
|
||||
path.setAttribute('d', geometry.path);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const flow = graph.taskFlowById.get(edgeId);
|
||||
const group = taskFlowGroupRefs.current.get(edgeId);
|
||||
if (!flow || !group) {
|
||||
continue;
|
||||
}
|
||||
const geometry = taskFlowGeometry(flow, rectLookup);
|
||||
if (!geometry) {
|
||||
continue;
|
||||
}
|
||||
const sourcePathByResourceId = new Map<string, SVGPathElement>();
|
||||
const targetPathByResourceId = new Map<string, SVGPathElement>();
|
||||
group
|
||||
.querySelectorAll<SVGPathElement>('.game-resource-dependency-branch')
|
||||
.forEach((path) => {
|
||||
const resourceId = path.dataset.resourceId;
|
||||
if (!resourceId) {
|
||||
return;
|
||||
}
|
||||
(path.dataset.branchSide === 'source'
|
||||
? sourcePathByResourceId
|
||||
: targetPathByResourceId
|
||||
).set(resourceId, path);
|
||||
});
|
||||
geometry.sourceAnchors.forEach(({ resourceId, point }) => {
|
||||
sourcePathByResourceId
|
||||
.get(resourceId)
|
||||
?.setAttribute(
|
||||
'd',
|
||||
taskFlowBranchPath(point, geometry.sourceHub),
|
||||
);
|
||||
});
|
||||
group
|
||||
.querySelector<SVGPathElement>('.game-resource-dependency-trunk')
|
||||
?.setAttribute(
|
||||
'd',
|
||||
connectionPath(geometry.sourceHub, geometry.targetHub),
|
||||
);
|
||||
geometry.targetAnchors.forEach(({ resourceId, point }) => {
|
||||
targetPathByResourceId
|
||||
.get(resourceId)
|
||||
?.setAttribute(
|
||||
'd',
|
||||
taskFlowBranchPath(geometry.targetHub, point),
|
||||
);
|
||||
});
|
||||
}
|
||||
}, [dragPreview, graph, positions, rectByResourceId]);
|
||||
|
||||
return (
|
||||
<svg
|
||||
ref={overlayRef}
|
||||
@@ -327,79 +546,8 @@ export function ResourceDependencyOverlay({
|
||||
</marker>
|
||||
</defs>
|
||||
|
||||
{graph.taskFlows.map((flow) => {
|
||||
const geometry = taskFlowGeometry(flow, rectByResourceId);
|
||||
if (!geometry) {
|
||||
return null;
|
||||
}
|
||||
const highlighted = neighbors.connectedEdgeIds.has(flow.id);
|
||||
const className = `game-resource-dependency-edge game-resource-dependency-edge--task${
|
||||
highlighted ? ' is-highlighted' : selected ? ' is-dimmed' : ''
|
||||
}${flow.cyclic ? ' is-cyclic' : ''}`;
|
||||
return (
|
||||
<g
|
||||
key={flow.id}
|
||||
className={className}
|
||||
data-edge-kind="task-flow"
|
||||
data-edge-id={flow.id}
|
||||
data-source-task-id={flow.sourceTaskId}
|
||||
data-target-task-id={flow.targetTaskId}
|
||||
data-cyclic={flow.cyclic || undefined}
|
||||
>
|
||||
<title>{`任务流转:${flow.sourceTaskId} → ${flow.targetTaskId}${
|
||||
flow.cyclic ? '(检测到依赖环)' : ''
|
||||
}`}</title>
|
||||
{geometry.sourceAnchors.map(({ resourceId, point }) => (
|
||||
<path
|
||||
key={`source:${resourceId}`}
|
||||
className="game-resource-dependency-branch"
|
||||
data-resource-id={resourceId}
|
||||
d={taskFlowBranchPath(point, geometry.sourceHub)}
|
||||
/>
|
||||
))}
|
||||
<path
|
||||
className="game-resource-dependency-trunk"
|
||||
d={connectionPath(geometry.sourceHub, geometry.targetHub)}
|
||||
/>
|
||||
{geometry.targetAnchors.map(({ resourceId, point }) => (
|
||||
<path
|
||||
key={`target:${resourceId}`}
|
||||
className="game-resource-dependency-branch"
|
||||
data-resource-id={resourceId}
|
||||
d={taskFlowBranchPath(geometry.targetHub, point)}
|
||||
markerEnd={`url(#${markerPrefix}-task-flow-arrow)`}
|
||||
/>
|
||||
))}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{graph.referenceEdges.map((edge) => {
|
||||
const geometry = referenceGeometry(edge, rectByResourceId);
|
||||
if (!geometry) {
|
||||
return null;
|
||||
}
|
||||
const highlighted = neighbors.connectedEdgeIds.has(edge.id);
|
||||
const className = `game-resource-dependency-edge game-resource-dependency-edge--reference${
|
||||
highlighted ? ' is-highlighted' : selected ? ' is-dimmed' : ''
|
||||
}${edge.cyclic ? ' is-cyclic' : ''}`;
|
||||
return (
|
||||
<path
|
||||
key={edge.id}
|
||||
className={className}
|
||||
data-edge-kind="asset-reference"
|
||||
data-edge-id={edge.id}
|
||||
data-source-resource-id={edge.sourceResourceId}
|
||||
data-target-resource-id={edge.targetResourceId}
|
||||
data-cyclic={edge.cyclic || undefined}
|
||||
data-self-loop={geometry.selfLoop || undefined}
|
||||
d={geometry.path}
|
||||
markerEnd={`url(#${markerPrefix}-asset-reference-arrow)`}
|
||||
>
|
||||
<title>{`资源引用${edge.cyclic ? '(检测到依赖环)' : ''}`}</title>
|
||||
</path>
|
||||
);
|
||||
})}
|
||||
{renderTaskFlows}
|
||||
{renderReferenceEdges}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -43,12 +43,20 @@ import {
|
||||
resolveEmbeddedPreviewUrl,
|
||||
} from '../../features/project-workspace/LocalGamePreviewFrame';
|
||||
import {
|
||||
RESOURCE_CANVAS_CARD_HEIGHT,
|
||||
RESOURCE_CANVAS_CARD_WIDTH,
|
||||
RESOURCE_CANVAS_COLUMN_GAP,
|
||||
RESOURCE_CANVAS_DRAG_THRESHOLD,
|
||||
RESOURCE_CANVAS_ROW_GAP,
|
||||
resourceCanvasSectionExtent,
|
||||
} from './resourceCanvasLayoutModel';
|
||||
import {
|
||||
createProjectResourceGraph,
|
||||
EMPTY_PROJECT_RESOURCE_GRAPH,
|
||||
normalizeProjectResourceGraph,
|
||||
type ProjectResourceGraph,
|
||||
projectResourceGraphNeighbors,
|
||||
type ProjectResourceGraphNodeInput,
|
||||
type ProjectResourceGraphReadModel,
|
||||
} from './resourceDependencyGraphModel';
|
||||
import { ResourceDependencyOverlay } from './ResourceDependencyOverlay';
|
||||
import { useProjectResourceCanvasLayout } from './useProjectResourceCanvasLayout';
|
||||
@@ -91,6 +99,7 @@ type ProjectResource = {
|
||||
mediaType: string;
|
||||
sourceLabel: string;
|
||||
taskTitle: string | null;
|
||||
manifestAssetId: string | null;
|
||||
producerTaskId: string | null;
|
||||
externalResourceId: string | null;
|
||||
referenceResourceIds: string[];
|
||||
@@ -139,6 +148,7 @@ export type ProjectAgentRuntimeSummary = {
|
||||
|
||||
const emptyProjectAgentRuntimeSummaries: ProjectAgentRuntimeSummary[] = [];
|
||||
const emptyProjectAgentResults: ProjectAgentResultSummary[] = [];
|
||||
const RESOURCE_DEPENDENCY_VISUAL_GUTTER = 64;
|
||||
|
||||
type AgentSummary = ProjectAgentRuntimeSummary;
|
||||
|
||||
@@ -318,6 +328,7 @@ function resourcesFromProject(
|
||||
mediaType: category === 'document' ? '项目文档' : '项目产物',
|
||||
sourceLabel: '任务产物',
|
||||
taskTitle: task.title,
|
||||
manifestAssetId: null,
|
||||
producerTaskId: task.id,
|
||||
externalResourceId: null,
|
||||
referenceResourceIds: [],
|
||||
@@ -328,9 +339,6 @@ function resourcesFromProject(
|
||||
}
|
||||
|
||||
for (const asset of manifest.assets) {
|
||||
const task = asset.source.taskId
|
||||
? taskById.get(asset.source.taskId)
|
||||
: undefined;
|
||||
const isPendingUiPrototype =
|
||||
asset.kind === 'ui-prototype' &&
|
||||
taskById.get('design-foundation')?.status !== 'completed';
|
||||
@@ -351,12 +359,13 @@ function resourcesFromProject(
|
||||
: asset.source.kind === 'generated'
|
||||
? 'Agent 生成'
|
||||
: '用户上传',
|
||||
taskTitle: task?.title ?? null,
|
||||
producerTaskId: task?.id ?? null,
|
||||
taskTitle: null,
|
||||
manifestAssetId: asset.id,
|
||||
producerTaskId: null,
|
||||
externalResourceId: asset.source.resourceId ?? null,
|
||||
referenceResourceIds: asset.source.referenceResourceIds ?? [],
|
||||
dependencies: task?.dependencies ?? [],
|
||||
dependencyDepth: task ? taskDependencyDepth(task, taskById) : 0,
|
||||
dependencies: [],
|
||||
dependencyDepth: 0,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -376,6 +385,7 @@ function resourcesFromProject(
|
||||
mediaType: attachment.mediaType || '未知媒体类型',
|
||||
sourceLabel: '用户上传',
|
||||
taskTitle: null,
|
||||
manifestAssetId: null,
|
||||
producerTaskId: null,
|
||||
externalResourceId: null,
|
||||
referenceResourceIds: [],
|
||||
@@ -394,6 +404,7 @@ function resourcesFromProject(
|
||||
mediaType: 'Agent 历史文本回执',
|
||||
sourceLabel: `历史成果 · ${result.label}`,
|
||||
taskTitle: null,
|
||||
manifestAssetId: null,
|
||||
producerTaskId: taskById.has(result.agentId) ? result.agentId : null,
|
||||
externalResourceId: null,
|
||||
referenceResourceIds: [],
|
||||
@@ -552,6 +563,10 @@ export default function ProjectDevelopmentView({
|
||||
const dockRef = useRef<HTMLElement>(null);
|
||||
const resourceDialogRef = useRef<HTMLElement>(null);
|
||||
const resourceCardDragRef = useRef<ResourceCardDrag | null>(null);
|
||||
const pendingResourceDragPreviewRef = useRef<
|
||||
(Point & { resourceId: string }) | null
|
||||
>(null);
|
||||
const resourceDragFrameRef = useRef<number | null>(null);
|
||||
const suppressResourceClickRef = useRef<string | null>(null);
|
||||
const resourceDialogDragRef = useRef<{
|
||||
pointerId: number;
|
||||
@@ -566,10 +581,121 @@ export default function ProjectDevelopmentView({
|
||||
manifest.tasks.some(
|
||||
(task) => task.id === 'code-prototype' && task.status === 'completed',
|
||||
);
|
||||
const resources = useMemo(
|
||||
const projectedResources = useMemo(
|
||||
() => resourcesFromProject(manifest, attachments, agentResults),
|
||||
[agentResults, attachments, manifest],
|
||||
);
|
||||
const resourceGraphInputs = useMemo<ProjectResourceGraphNodeInput[]>(
|
||||
() =>
|
||||
projectedResources.map((resource) => ({
|
||||
resourceId: resource.id,
|
||||
manifestAssetId: resource.manifestAssetId,
|
||||
producerTaskId: resource.producerTaskId,
|
||||
})),
|
||||
[projectedResources],
|
||||
);
|
||||
const resourceGraphScopeKey = useMemo(
|
||||
() =>
|
||||
JSON.stringify([
|
||||
projectPath,
|
||||
manifest.projectId,
|
||||
resourceGraphInputs,
|
||||
]),
|
||||
[manifest.projectId, projectPath, resourceGraphInputs],
|
||||
);
|
||||
const [resourceGraphState, setResourceGraphState] = useState<{
|
||||
scopeKey: string;
|
||||
graph: ProjectResourceGraph;
|
||||
}>({ scopeKey: '', graph: EMPTY_PROJECT_RESOURCE_GRAPH });
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
if (sortMode !== 'dependency') {
|
||||
setResourceGraphState({
|
||||
scopeKey: '',
|
||||
graph: EMPTY_PROJECT_RESOURCE_GRAPH,
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}
|
||||
const invoke = window.__TAURI__?.core?.invoke;
|
||||
if (!invoke) {
|
||||
setResourceGraphState({
|
||||
scopeKey: resourceGraphScopeKey,
|
||||
graph: EMPTY_PROJECT_RESOURCE_GRAPH,
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}
|
||||
void invoke<ProjectResourceGraphReadModel>(
|
||||
'read_local_project_resource_graph',
|
||||
{
|
||||
projectPath,
|
||||
expectedProjectId: manifest.projectId,
|
||||
resources: resourceGraphInputs,
|
||||
},
|
||||
)
|
||||
.then((readModel) => {
|
||||
if (!cancelled) {
|
||||
setResourceGraphState({
|
||||
scopeKey: resourceGraphScopeKey,
|
||||
graph: normalizeProjectResourceGraph(readModel),
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setResourceGraphState({
|
||||
scopeKey: resourceGraphScopeKey,
|
||||
graph: EMPTY_PROJECT_RESOURCE_GRAPH,
|
||||
});
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [
|
||||
manifest.projectId,
|
||||
projectPath,
|
||||
resourceGraphInputs,
|
||||
resourceGraphScopeKey,
|
||||
sortMode,
|
||||
]);
|
||||
|
||||
const resourceGraph =
|
||||
resourceGraphState.scopeKey === resourceGraphScopeKey
|
||||
? resourceGraphState.graph
|
||||
: EMPTY_PROJECT_RESOURCE_GRAPH;
|
||||
const manifestTaskById = useMemo(
|
||||
() => new Map(manifest.tasks.map((task) => [task.id, task])),
|
||||
[manifest.tasks],
|
||||
);
|
||||
const resources = useMemo(
|
||||
() =>
|
||||
projectedResources.map((resource) => {
|
||||
const producerTaskId =
|
||||
resourceGraph.producerTaskIdByResourceId.get(resource.id) ??
|
||||
resource.producerTaskId;
|
||||
const producerTask = producerTaskId
|
||||
? manifestTaskById.get(producerTaskId)
|
||||
: undefined;
|
||||
return producerTask && producerTaskId !== resource.producerTaskId
|
||||
? {
|
||||
...resource,
|
||||
taskTitle: producerTask.title,
|
||||
producerTaskId,
|
||||
dependencies: producerTask.dependencies,
|
||||
dependencyDepth: taskDependencyDepth(
|
||||
producerTask,
|
||||
manifestTaskById,
|
||||
),
|
||||
}
|
||||
: resource;
|
||||
}),
|
||||
[manifestTaskById, projectedResources, resourceGraph],
|
||||
);
|
||||
const {
|
||||
layout: resourceLayout,
|
||||
notice: resourceLayoutNotice,
|
||||
@@ -581,12 +707,15 @@ export default function ProjectDevelopmentView({
|
||||
mode: sortMode,
|
||||
resources,
|
||||
});
|
||||
const resourcePositionById = new Map(
|
||||
resourceLayout.positions.map((position) => [position.resourceId, position]),
|
||||
);
|
||||
const resourceGraph = useMemo(
|
||||
() => createProjectResourceGraph(resources, manifest.tasks),
|
||||
[manifest.tasks, resources],
|
||||
const resourcePositionById = useMemo(
|
||||
() =>
|
||||
new Map(
|
||||
resourceLayout.positions.map((position) => [
|
||||
position.resourceId,
|
||||
position,
|
||||
]),
|
||||
),
|
||||
[resourceLayout.positions],
|
||||
);
|
||||
const selectedResourceNeighbors = useMemo(
|
||||
() => projectResourceGraphNeighbors(resourceGraph, selectedResourceId),
|
||||
@@ -611,18 +740,41 @@ export default function ProjectDevelopmentView({
|
||||
() => new Set(visibleResources.map((resource) => resource.id)),
|
||||
[visibleResources],
|
||||
);
|
||||
const resourceDependencyPositions = useMemo(
|
||||
const visibleResourcesByCategory = useMemo(
|
||||
() =>
|
||||
resourceLayout.positions.map((position) =>
|
||||
resourceDragPreview?.resourceId === position.resourceId
|
||||
? {
|
||||
...position,
|
||||
x: resourceDragPreview.x,
|
||||
y: resourceDragPreview.y,
|
||||
}
|
||||
: position,
|
||||
new Map(
|
||||
categoryOrder.map((category) => [
|
||||
category,
|
||||
visibleResources.filter(
|
||||
(resource) => resource.category === category,
|
||||
),
|
||||
]),
|
||||
),
|
||||
[resourceDragPreview, resourceLayout.positions],
|
||||
[visibleResources],
|
||||
);
|
||||
const resourcePositionsByCategory = useMemo(
|
||||
() =>
|
||||
new Map(
|
||||
categoryOrder.map((category) => [
|
||||
category,
|
||||
resourceLayout.positions.filter(
|
||||
(position) => position.section === category,
|
||||
),
|
||||
]),
|
||||
),
|
||||
[resourceLayout.positions],
|
||||
);
|
||||
const resourceBaseExtentByCategory = useMemo(
|
||||
() =>
|
||||
new Map(
|
||||
categoryOrder.map((category) => [
|
||||
category,
|
||||
resourceCanvasSectionExtent(
|
||||
resourcePositionsByCategory.get(category) ?? [],
|
||||
),
|
||||
]),
|
||||
),
|
||||
[resourcePositionsByCategory],
|
||||
);
|
||||
const selectedResource =
|
||||
resources.find((resource) => resource.id === selectedResourceId) ?? null;
|
||||
@@ -665,6 +817,39 @@ export default function ProjectDevelopmentView({
|
||||
approvalOptions.find((option) => option.id === approvalMode)?.label ??
|
||||
'严格审批';
|
||||
|
||||
const scheduleResourceDragPreview = useCallback(
|
||||
(preview: Point & { resourceId: string }) => {
|
||||
pendingResourceDragPreviewRef.current = preview;
|
||||
if (resourceDragFrameRef.current !== null) {
|
||||
return;
|
||||
}
|
||||
resourceDragFrameRef.current = window.requestAnimationFrame(() => {
|
||||
resourceDragFrameRef.current = null;
|
||||
const pending = pendingResourceDragPreviewRef.current;
|
||||
pendingResourceDragPreviewRef.current = null;
|
||||
if (pending) {
|
||||
setResourceDragPreview(pending);
|
||||
}
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const clearScheduledResourceDragPreview = useCallback(() => {
|
||||
pendingResourceDragPreviewRef.current = null;
|
||||
if (resourceDragFrameRef.current !== null) {
|
||||
window.cancelAnimationFrame(resourceDragFrameRef.current);
|
||||
resourceDragFrameRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
clearScheduledResourceDragPreview();
|
||||
},
|
||||
[clearScheduledResourceDragPreview],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (embeddedPreviewUrl) {
|
||||
setMode('run');
|
||||
@@ -674,9 +859,10 @@ export default function ProjectDevelopmentView({
|
||||
useEffect(() => {
|
||||
setSelectedResourceId(null);
|
||||
resourceCardDragRef.current = null;
|
||||
clearScheduledResourceDragPreview();
|
||||
setDraggedResourceId(null);
|
||||
setResourceDragPreview(null);
|
||||
}, [projectPath]);
|
||||
}, [clearScheduledResourceDragPreview, projectPath]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedResourceId) {
|
||||
@@ -812,6 +998,7 @@ export default function ProjectDevelopmentView({
|
||||
if (!position || position.section !== resource.category) {
|
||||
return;
|
||||
}
|
||||
clearScheduledResourceDragPreview();
|
||||
resourceCardDragRef.current = {
|
||||
pointerId: event.pointerId,
|
||||
resourceId: resource.id,
|
||||
@@ -842,7 +1029,7 @@ export default function ProjectDevelopmentView({
|
||||
}
|
||||
drag.moved = true;
|
||||
setDraggedResourceId(drag.resourceId);
|
||||
setResourceDragPreview({
|
||||
scheduleResourceDragPreview({
|
||||
resourceId: drag.resourceId,
|
||||
x: Math.max(0, drag.startX + deltaX),
|
||||
y: Math.max(0, drag.startY + deltaY),
|
||||
@@ -858,6 +1045,7 @@ export default function ProjectDevelopmentView({
|
||||
if (!drag || drag.pointerId !== event.pointerId) {
|
||||
return;
|
||||
}
|
||||
clearScheduledResourceDragPreview();
|
||||
resourceCardDragRef.current = null;
|
||||
if (event.currentTarget.hasPointerCapture?.(event.pointerId)) {
|
||||
event.currentTarget.releasePointerCapture?.(event.pointerId);
|
||||
@@ -1050,29 +1238,49 @@ export default function ProjectDevelopmentView({
|
||||
<ResourceDependencyOverlay
|
||||
key={`${projectPath}:${manifest.projectId}`}
|
||||
graph={resourceGraph}
|
||||
positions={resourceDependencyPositions}
|
||||
positions={resourceLayout.positions}
|
||||
dragPreview={resourceDragPreview}
|
||||
visibleResourceIds={visibleResourceIds}
|
||||
selectedResourceId={selectedResourceId}
|
||||
/>
|
||||
) : null}
|
||||
{categoryOrder.map((category) => {
|
||||
const categoryResources = visibleResources.filter(
|
||||
(resource) => resource.category === category,
|
||||
);
|
||||
const categoryPositions = resourceLayout.positions.filter(
|
||||
(position) => position.section === category,
|
||||
);
|
||||
const extent = resourceCanvasSectionExtent(
|
||||
categoryPositions.map((position) =>
|
||||
resourceDragPreview?.resourceId === position.resourceId
|
||||
? {
|
||||
...position,
|
||||
x: resourceDragPreview.x,
|
||||
y: resourceDragPreview.y,
|
||||
}
|
||||
: position,
|
||||
const categoryResources =
|
||||
visibleResourcesByCategory.get(category) ?? [];
|
||||
const baseExtent = resourceBaseExtentByCategory.get(
|
||||
category,
|
||||
) ?? { width: 0, height: 0 };
|
||||
const dragPosition = resourceDragPreview
|
||||
? resourcePositionById.get(
|
||||
resourceDragPreview.resourceId,
|
||||
)
|
||||
: undefined;
|
||||
const previewInCategory =
|
||||
dragPosition?.section === category
|
||||
? resourceDragPreview
|
||||
: null;
|
||||
const extent = {
|
||||
width:
|
||||
Math.max(
|
||||
baseExtent.width,
|
||||
previewInCategory
|
||||
? previewInCategory.x +
|
||||
RESOURCE_CANVAS_CARD_WIDTH +
|
||||
RESOURCE_CANVAS_COLUMN_GAP
|
||||
: 0,
|
||||
) +
|
||||
(sortMode === 'dependency'
|
||||
? RESOURCE_DEPENDENCY_VISUAL_GUTTER
|
||||
: 0),
|
||||
height: Math.max(
|
||||
baseExtent.height,
|
||||
previewInCategory
|
||||
? previewInCategory.y +
|
||||
RESOURCE_CANVAS_CARD_HEIGHT +
|
||||
RESOURCE_CANVAS_ROW_GAP
|
||||
: 0,
|
||||
),
|
||||
);
|
||||
};
|
||||
const Icon = categoryIcons[category];
|
||||
return (
|
||||
<section
|
||||
|
||||
+152
-253
@@ -1,13 +1,7 @@
|
||||
export type ProjectResourceGraphNodeInput = {
|
||||
id: string;
|
||||
resourceId: string;
|
||||
manifestAssetId: string | null;
|
||||
producerTaskId: string | null;
|
||||
externalResourceId: string | null;
|
||||
referenceResourceIds: readonly string[];
|
||||
};
|
||||
|
||||
export type ProjectResourceGraphTaskInput = {
|
||||
id: string;
|
||||
dependencies: readonly string[];
|
||||
};
|
||||
|
||||
export type ProjectResourceReferenceEdge = {
|
||||
@@ -28,13 +22,50 @@ export type ProjectResourceTaskFlow = {
|
||||
cyclic: boolean;
|
||||
};
|
||||
|
||||
export type ProjectResourceConnectionIndexDto = {
|
||||
resourceId: string;
|
||||
upstreamReferenceResourceIds: string[];
|
||||
downstreamReferenceResourceIds: string[];
|
||||
referenceEdgeIds: string[];
|
||||
taskFlowIds: string[];
|
||||
};
|
||||
|
||||
export type ProjectResourceProducerAssignment = {
|
||||
resourceId: string;
|
||||
taskId: string;
|
||||
};
|
||||
|
||||
export type ProjectResourceGraphReadModel = {
|
||||
resourceIds: string[];
|
||||
referenceEdges: ProjectResourceReferenceEdge[];
|
||||
taskFlows: ProjectResourceTaskFlow[];
|
||||
connectionIndex: ProjectResourceConnectionIndexDto[];
|
||||
producerAssignments: ProjectResourceProducerAssignment[];
|
||||
unresolvedReferenceResourceIds: string[];
|
||||
cyclicResourceIds: string[];
|
||||
cyclicTaskIds: string[];
|
||||
producerMappingTruncated: boolean;
|
||||
};
|
||||
|
||||
type ProjectResourceConnectionIndex = {
|
||||
upstreamReferenceResourceIds: ReadonlySet<string>;
|
||||
downstreamReferenceResourceIds: ReadonlySet<string>;
|
||||
referenceEdgeIds: ReadonlySet<string>;
|
||||
taskFlowIds: ReadonlySet<string>;
|
||||
};
|
||||
|
||||
export type ProjectResourceGraph = {
|
||||
resourceIds: ReadonlySet<string>;
|
||||
referenceEdges: ProjectResourceReferenceEdge[];
|
||||
referenceEdgeById: ReadonlyMap<string, ProjectResourceReferenceEdge>;
|
||||
taskFlows: ProjectResourceTaskFlow[];
|
||||
taskFlowById: ReadonlyMap<string, ProjectResourceTaskFlow>;
|
||||
connectionIndex: ReadonlyMap<string, ProjectResourceConnectionIndex>;
|
||||
producerTaskIdByResourceId: ReadonlyMap<string, string>;
|
||||
unresolvedReferenceResourceIds: string[];
|
||||
cyclicResourceIds: ReadonlySet<string>;
|
||||
cyclicTaskIds: ReadonlySet<string>;
|
||||
producerMappingTruncated: boolean;
|
||||
};
|
||||
|
||||
export type ProjectResourceGraphNeighbors = {
|
||||
@@ -43,18 +74,18 @@ export type ProjectResourceGraphNeighbors = {
|
||||
connectedEdgeIds: ReadonlySet<string>;
|
||||
};
|
||||
|
||||
type DirectedEdge = {
|
||||
id: string;
|
||||
sourceId: string;
|
||||
targetId: string;
|
||||
};
|
||||
|
||||
type CycleAnalysis = {
|
||||
cyclicNodeIds: Set<string>;
|
||||
cyclicEdgeIds: Set<string>;
|
||||
};
|
||||
|
||||
const emptyStringSet: ReadonlySet<string> = new Set<string>();
|
||||
const emptyStringMap: ReadonlyMap<string, string> = new Map<string, string>();
|
||||
const emptyConnectionMap: ReadonlyMap<string, ProjectResourceConnectionIndex> =
|
||||
new Map<string, ProjectResourceConnectionIndex>();
|
||||
const emptyTaskFlowMap: ReadonlyMap<string, ProjectResourceTaskFlow> = new Map<
|
||||
string,
|
||||
ProjectResourceTaskFlow
|
||||
>();
|
||||
const emptyReferenceEdgeMap: ReadonlyMap<
|
||||
string,
|
||||
ProjectResourceReferenceEdge
|
||||
> = new Map<string, ProjectResourceReferenceEdge>();
|
||||
|
||||
export const EMPTY_PROJECT_RESOURCE_GRAPH_NEIGHBORS: ProjectResourceGraphNeighbors =
|
||||
{
|
||||
@@ -63,13 +94,19 @@ export const EMPTY_PROJECT_RESOURCE_GRAPH_NEIGHBORS: ProjectResourceGraphNeighbo
|
||||
connectedEdgeIds: emptyStringSet,
|
||||
};
|
||||
|
||||
function stableEdgeId(
|
||||
kind: ProjectResourceReferenceEdge['kind'] | ProjectResourceTaskFlow['kind'],
|
||||
sourceId: string,
|
||||
targetId: string,
|
||||
) {
|
||||
return `${kind}:${JSON.stringify([sourceId, targetId])}`;
|
||||
}
|
||||
export const EMPTY_PROJECT_RESOURCE_GRAPH: ProjectResourceGraph = {
|
||||
resourceIds: emptyStringSet,
|
||||
referenceEdges: [],
|
||||
referenceEdgeById: emptyReferenceEdgeMap,
|
||||
taskFlows: [],
|
||||
taskFlowById: emptyTaskFlowMap,
|
||||
connectionIndex: emptyConnectionMap,
|
||||
producerTaskIdByResourceId: emptyStringMap,
|
||||
unresolvedReferenceResourceIds: [],
|
||||
cyclicResourceIds: emptyStringSet,
|
||||
cyclicTaskIds: emptyStringSet,
|
||||
producerMappingTruncated: false,
|
||||
};
|
||||
|
||||
function uniqueSorted(values: Iterable<string>) {
|
||||
return Array.from(new Set(values)).sort((left, right) =>
|
||||
@@ -77,231 +114,92 @@ function uniqueSorted(values: Iterable<string>) {
|
||||
);
|
||||
}
|
||||
|
||||
function analyzeDirectedCycles(
|
||||
nodeIds: Iterable<string>,
|
||||
edges: readonly DirectedEdge[],
|
||||
): CycleAnalysis {
|
||||
const nodes = new Set(nodeIds);
|
||||
for (const edge of edges) {
|
||||
nodes.add(edge.sourceId);
|
||||
nodes.add(edge.targetId);
|
||||
}
|
||||
const adjacency = new Map<string, string[]>();
|
||||
const reverseAdjacency = new Map<string, string[]>();
|
||||
for (const nodeId of nodes) {
|
||||
adjacency.set(nodeId, []);
|
||||
reverseAdjacency.set(nodeId, []);
|
||||
}
|
||||
for (const edge of edges) {
|
||||
adjacency.get(edge.sourceId)?.push(edge.targetId);
|
||||
reverseAdjacency.get(edge.targetId)?.push(edge.sourceId);
|
||||
}
|
||||
|
||||
const visited = new Set<string>();
|
||||
const finishOrder: string[] = [];
|
||||
for (const root of nodes) {
|
||||
if (visited.has(root)) {
|
||||
continue;
|
||||
}
|
||||
visited.add(root);
|
||||
const stack: Array<{ nodeId: string; nextIndex: number }> = [
|
||||
{ nodeId: root, nextIndex: 0 },
|
||||
];
|
||||
while (stack.length > 0) {
|
||||
const frame = stack[stack.length - 1];
|
||||
if (!frame) {
|
||||
break;
|
||||
}
|
||||
const neighbors = adjacency.get(frame.nodeId) ?? [];
|
||||
const next = neighbors[frame.nextIndex];
|
||||
if (next === undefined) {
|
||||
finishOrder.push(frame.nodeId);
|
||||
stack.pop();
|
||||
continue;
|
||||
}
|
||||
frame.nextIndex += 1;
|
||||
if (!visited.has(next)) {
|
||||
visited.add(next);
|
||||
stack.push({ nodeId: next, nextIndex: 0 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const componentByNode = new Map<string, number>();
|
||||
const componentSizes: number[] = [];
|
||||
for (let index = finishOrder.length - 1; index >= 0; index -= 1) {
|
||||
const root = finishOrder[index];
|
||||
if (root === undefined || componentByNode.has(root)) {
|
||||
continue;
|
||||
}
|
||||
const componentId = componentSizes.length;
|
||||
let size = 0;
|
||||
const stack = [root];
|
||||
componentByNode.set(root, componentId);
|
||||
while (stack.length > 0) {
|
||||
const nodeId = stack.pop();
|
||||
if (nodeId === undefined) {
|
||||
continue;
|
||||
}
|
||||
size += 1;
|
||||
for (const neighbor of reverseAdjacency.get(nodeId) ?? []) {
|
||||
if (!componentByNode.has(neighbor)) {
|
||||
componentByNode.set(neighbor, componentId);
|
||||
stack.push(neighbor);
|
||||
}
|
||||
}
|
||||
}
|
||||
componentSizes.push(size);
|
||||
}
|
||||
|
||||
const cyclicNodeIds = new Set<string>();
|
||||
const cyclicEdgeIds = new Set<string>();
|
||||
for (const edge of edges) {
|
||||
const sourceComponent = componentByNode.get(edge.sourceId);
|
||||
const targetComponent = componentByNode.get(edge.targetId);
|
||||
if (
|
||||
sourceComponent !== undefined &&
|
||||
sourceComponent === targetComponent &&
|
||||
((componentSizes[sourceComponent] ?? 0) > 1 ||
|
||||
edge.sourceId === edge.targetId)
|
||||
) {
|
||||
cyclicNodeIds.add(edge.sourceId);
|
||||
cyclicNodeIds.add(edge.targetId);
|
||||
cyclicEdgeIds.add(edge.id);
|
||||
}
|
||||
}
|
||||
return { cyclicNodeIds, cyclicEdgeIds };
|
||||
}
|
||||
|
||||
export function createProjectResourceGraph(
|
||||
resources: readonly ProjectResourceGraphNodeInput[],
|
||||
tasks: readonly ProjectResourceGraphTaskInput[],
|
||||
export function normalizeProjectResourceGraph(
|
||||
readModel: ProjectResourceGraphReadModel,
|
||||
): ProjectResourceGraph {
|
||||
const resourceById = new Map(
|
||||
resources.map((resource) => [resource.id, resource]),
|
||||
);
|
||||
const resourcesByTask = new Map<string, string[]>();
|
||||
const resourcesByExternalId = new Map<string, string[]>();
|
||||
for (const resource of resources) {
|
||||
if (resource.producerTaskId) {
|
||||
const taskResources = resourcesByTask.get(resource.producerTaskId) ?? [];
|
||||
taskResources.push(resource.id);
|
||||
resourcesByTask.set(resource.producerTaskId, taskResources);
|
||||
}
|
||||
if (resource.externalResourceId) {
|
||||
const externalResources =
|
||||
resourcesByExternalId.get(resource.externalResourceId) ?? [];
|
||||
externalResources.push(resource.id);
|
||||
resourcesByExternalId.set(resource.externalResourceId, externalResources);
|
||||
}
|
||||
}
|
||||
for (const [taskId, resourceIds] of resourcesByTask) {
|
||||
resourcesByTask.set(taskId, uniqueSorted(resourceIds));
|
||||
}
|
||||
|
||||
const unresolvedReferenceResourceIds = new Set<string>();
|
||||
const referenceEdgeById = new Map<string, ProjectResourceReferenceEdge>();
|
||||
for (const target of resources) {
|
||||
for (const externalReferenceId of new Set(target.referenceResourceIds)) {
|
||||
const sourceCandidates =
|
||||
resourcesByExternalId.get(externalReferenceId) ?? [];
|
||||
if (sourceCandidates.length !== 1) {
|
||||
unresolvedReferenceResourceIds.add(externalReferenceId);
|
||||
continue;
|
||||
const resourceIds = new Set(uniqueSorted(readModel.resourceIds));
|
||||
const referenceEdges = readModel.referenceEdges
|
||||
.filter(
|
||||
(edge) =>
|
||||
edge.kind === 'asset-reference' &&
|
||||
resourceIds.has(edge.sourceResourceId) &&
|
||||
resourceIds.has(edge.targetResourceId),
|
||||
)
|
||||
.sort((left, right) => left.id.localeCompare(right.id));
|
||||
const referenceEdgeIds = new Set(referenceEdges.map((edge) => edge.id));
|
||||
const taskFlows = readModel.taskFlows
|
||||
.flatMap((flow) => {
|
||||
if (flow.kind !== 'task-flow') {
|
||||
return [];
|
||||
}
|
||||
const sourceResourceId = sourceCandidates[0];
|
||||
if (sourceResourceId === undefined) {
|
||||
unresolvedReferenceResourceIds.add(externalReferenceId);
|
||||
continue;
|
||||
}
|
||||
if (!resourceById.has(sourceResourceId) || !resourceById.has(target.id)) {
|
||||
continue;
|
||||
}
|
||||
const id = stableEdgeId('asset-reference', sourceResourceId, target.id);
|
||||
referenceEdgeById.set(id, {
|
||||
id,
|
||||
kind: 'asset-reference',
|
||||
sourceResourceId,
|
||||
targetResourceId: target.id,
|
||||
cyclic: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
const referenceEdges = Array.from(referenceEdgeById.values()).sort(
|
||||
(left, right) => left.id.localeCompare(right.id),
|
||||
);
|
||||
const referenceCycles = analyzeDirectedCycles(
|
||||
resourceById.keys(),
|
||||
referenceEdges.map((edge) => ({
|
||||
id: edge.id,
|
||||
sourceId: edge.sourceResourceId,
|
||||
targetId: edge.targetResourceId,
|
||||
})),
|
||||
);
|
||||
for (const edge of referenceEdges) {
|
||||
edge.cyclic = referenceCycles.cyclicEdgeIds.has(edge.id);
|
||||
}
|
||||
|
||||
const taskById = new Map(tasks.map((task) => [task.id, task]));
|
||||
const taskDependencyEdges: DirectedEdge[] = [];
|
||||
for (const targetTask of tasks) {
|
||||
for (const sourceTaskId of new Set(targetTask.dependencies)) {
|
||||
if (!taskById.has(sourceTaskId)) {
|
||||
continue;
|
||||
}
|
||||
taskDependencyEdges.push({
|
||||
id: stableEdgeId('task-flow', sourceTaskId, targetTask.id),
|
||||
sourceId: sourceTaskId,
|
||||
targetId: targetTask.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
const taskCycles = analyzeDirectedCycles(
|
||||
taskById.keys(),
|
||||
taskDependencyEdges,
|
||||
);
|
||||
const taskFlowById = new Map<string, ProjectResourceTaskFlow>();
|
||||
for (const targetTask of tasks) {
|
||||
const targetResourceIds = resourcesByTask.get(targetTask.id) ?? [];
|
||||
if (targetResourceIds.length === 0) {
|
||||
const sourceResourceIds = uniqueSorted(
|
||||
flow.sourceResourceIds.filter((resourceId) =>
|
||||
resourceIds.has(resourceId),
|
||||
),
|
||||
);
|
||||
const targetResourceIds = uniqueSorted(
|
||||
flow.targetResourceIds.filter((resourceId) =>
|
||||
resourceIds.has(resourceId),
|
||||
),
|
||||
);
|
||||
return sourceResourceIds.length > 0 && targetResourceIds.length > 0
|
||||
? [{ ...flow, sourceResourceIds, targetResourceIds }]
|
||||
: [];
|
||||
})
|
||||
.sort((left, right) => left.id.localeCompare(right.id));
|
||||
const taskFlowIds = new Set(taskFlows.map((flow) => flow.id));
|
||||
const connectionIndex = new Map<string, ProjectResourceConnectionIndex>();
|
||||
for (const index of readModel.connectionIndex) {
|
||||
if (!resourceIds.has(index.resourceId)) {
|
||||
continue;
|
||||
}
|
||||
for (const sourceTaskId of new Set(targetTask.dependencies)) {
|
||||
if (!taskById.has(sourceTaskId)) {
|
||||
continue;
|
||||
}
|
||||
const sourceResourceIds = resourcesByTask.get(sourceTaskId) ?? [];
|
||||
if (sourceResourceIds.length === 0) {
|
||||
continue;
|
||||
}
|
||||
const id = stableEdgeId('task-flow', sourceTaskId, targetTask.id);
|
||||
taskFlowById.set(id, {
|
||||
id,
|
||||
kind: 'task-flow',
|
||||
sourceTaskId,
|
||||
targetTaskId: targetTask.id,
|
||||
sourceResourceIds,
|
||||
targetResourceIds,
|
||||
cyclic: false,
|
||||
});
|
||||
}
|
||||
connectionIndex.set(index.resourceId, {
|
||||
upstreamReferenceResourceIds: new Set(
|
||||
index.upstreamReferenceResourceIds.filter((resourceId) =>
|
||||
resourceIds.has(resourceId),
|
||||
),
|
||||
),
|
||||
downstreamReferenceResourceIds: new Set(
|
||||
index.downstreamReferenceResourceIds.filter((resourceId) =>
|
||||
resourceIds.has(resourceId),
|
||||
),
|
||||
),
|
||||
referenceEdgeIds: new Set(
|
||||
index.referenceEdgeIds.filter((edgeId) =>
|
||||
referenceEdgeIds.has(edgeId),
|
||||
),
|
||||
),
|
||||
taskFlowIds: new Set(
|
||||
index.taskFlowIds.filter((flowId) => taskFlowIds.has(flowId)),
|
||||
),
|
||||
});
|
||||
}
|
||||
const taskFlows = Array.from(taskFlowById.values()).sort((left, right) =>
|
||||
left.id.localeCompare(right.id),
|
||||
const producerTaskIdByResourceId = new Map(
|
||||
readModel.producerAssignments.flatMap((assignment) =>
|
||||
resourceIds.has(assignment.resourceId) && assignment.taskId
|
||||
? [[assignment.resourceId, assignment.taskId] as const]
|
||||
: [],
|
||||
),
|
||||
);
|
||||
for (const flow of taskFlows) {
|
||||
flow.cyclic = taskCycles.cyclicEdgeIds.has(flow.id);
|
||||
}
|
||||
|
||||
return {
|
||||
resourceIds: new Set(resourceById.keys()),
|
||||
resourceIds,
|
||||
referenceEdges,
|
||||
referenceEdgeById: new Map(referenceEdges.map((edge) => [edge.id, edge])),
|
||||
taskFlows,
|
||||
taskFlowById: new Map(taskFlows.map((flow) => [flow.id, flow])),
|
||||
connectionIndex,
|
||||
producerTaskIdByResourceId,
|
||||
unresolvedReferenceResourceIds: uniqueSorted(
|
||||
unresolvedReferenceResourceIds,
|
||||
readModel.unresolvedReferenceResourceIds,
|
||||
),
|
||||
cyclicResourceIds: referenceCycles.cyclicNodeIds,
|
||||
cyclicTaskIds: taskCycles.cyclicNodeIds,
|
||||
cyclicResourceIds: new Set(
|
||||
readModel.cyclicResourceIds.filter((resourceId) =>
|
||||
resourceIds.has(resourceId),
|
||||
),
|
||||
),
|
||||
cyclicTaskIds: new Set(readModel.cyclicTaskIds),
|
||||
producerMappingTruncated: Boolean(readModel.producerMappingTruncated),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -312,21 +210,22 @@ export function projectResourceGraphNeighbors(
|
||||
if (!resourceId || !graph.resourceIds.has(resourceId)) {
|
||||
return EMPTY_PROJECT_RESOURCE_GRAPH_NEIGHBORS;
|
||||
}
|
||||
const upstreamResourceIds = new Set<string>();
|
||||
const downstreamResourceIds = new Set<string>();
|
||||
const connectedEdgeIds = new Set<string>();
|
||||
|
||||
for (const edge of graph.referenceEdges) {
|
||||
if (edge.targetResourceId === resourceId) {
|
||||
upstreamResourceIds.add(edge.sourceResourceId);
|
||||
connectedEdgeIds.add(edge.id);
|
||||
}
|
||||
if (edge.sourceResourceId === resourceId) {
|
||||
downstreamResourceIds.add(edge.targetResourceId);
|
||||
connectedEdgeIds.add(edge.id);
|
||||
}
|
||||
const index = graph.connectionIndex.get(resourceId);
|
||||
if (!index) {
|
||||
return EMPTY_PROJECT_RESOURCE_GRAPH_NEIGHBORS;
|
||||
}
|
||||
for (const flow of graph.taskFlows) {
|
||||
const upstreamResourceIds = new Set(
|
||||
index.upstreamReferenceResourceIds,
|
||||
);
|
||||
const downstreamResourceIds = new Set(
|
||||
index.downstreamReferenceResourceIds,
|
||||
);
|
||||
const connectedEdgeIds = new Set(index.referenceEdgeIds);
|
||||
for (const flowId of index.taskFlowIds) {
|
||||
const flow = graph.taskFlowById.get(flowId);
|
||||
if (!flow) {
|
||||
continue;
|
||||
}
|
||||
if (flow.targetResourceIds.includes(resourceId)) {
|
||||
flow.sourceResourceIds.forEach((id) => upstreamResourceIds.add(id));
|
||||
connectedEdgeIds.add(flow.id);
|
||||
|
||||
@@ -5,8 +5,9 @@ import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { ProjectResourceCanvasPosition } from '../../../packages/shared/src/contracts/gameCreationApp';
|
||||
import {
|
||||
createProjectResourceGraph,
|
||||
normalizeProjectResourceGraph,
|
||||
type ProjectResourceGraph,
|
||||
type ProjectResourceGraphReadModel,
|
||||
} from '../src/view/project-development/resourceDependencyGraphModel';
|
||||
import { ResourceDependencyOverlay } from '../src/view/project-development/ResourceDependencyOverlay';
|
||||
|
||||
@@ -25,45 +26,76 @@ function position(
|
||||
}
|
||||
|
||||
function graphFixture() {
|
||||
return createProjectResourceGraph(
|
||||
[
|
||||
const referenceId = 'asset-reference:["source:one","target:one"]';
|
||||
const selfReferenceId = 'asset-reference:["unrelated","unrelated"]';
|
||||
const flowId = 'task-flow:["source-task","target-task"]';
|
||||
const resourceIds = [
|
||||
'source:one',
|
||||
'source:two',
|
||||
'source:three',
|
||||
'target:one',
|
||||
'target:two',
|
||||
'target:three',
|
||||
'unrelated',
|
||||
];
|
||||
const readModel: ProjectResourceGraphReadModel = {
|
||||
resourceIds,
|
||||
referenceEdges: [
|
||||
{
|
||||
id: 'source:one',
|
||||
producerTaskId: 'source-task',
|
||||
externalResourceId: 'external-source',
|
||||
referenceResourceIds: [],
|
||||
id: referenceId,
|
||||
kind: 'asset-reference',
|
||||
sourceResourceId: 'source:one',
|
||||
targetResourceId: 'target:one',
|
||||
cyclic: false,
|
||||
},
|
||||
...['source:two', 'source:three'].map((id) => ({
|
||||
id,
|
||||
producerTaskId: 'source-task',
|
||||
externalResourceId: null,
|
||||
referenceResourceIds: [],
|
||||
})),
|
||||
{
|
||||
id: 'target:one',
|
||||
producerTaskId: 'target-task',
|
||||
externalResourceId: null,
|
||||
referenceResourceIds: ['external-source'],
|
||||
},
|
||||
...['target:two', 'target:three'].map((id) => ({
|
||||
id,
|
||||
producerTaskId: 'target-task',
|
||||
externalResourceId: null,
|
||||
referenceResourceIds: [],
|
||||
})),
|
||||
{
|
||||
id: 'unrelated',
|
||||
producerTaskId: 'unrelated-task',
|
||||
externalResourceId: 'external-unrelated',
|
||||
referenceResourceIds: ['external-unrelated'],
|
||||
id: selfReferenceId,
|
||||
kind: 'asset-reference',
|
||||
sourceResourceId: 'unrelated',
|
||||
targetResourceId: 'unrelated',
|
||||
cyclic: true,
|
||||
},
|
||||
],
|
||||
[
|
||||
{ id: 'source-task', dependencies: [] },
|
||||
{ id: 'target-task', dependencies: ['source-task'] },
|
||||
{ id: 'unrelated-task', dependencies: [] },
|
||||
taskFlows: [
|
||||
{
|
||||
id: flowId,
|
||||
kind: 'task-flow',
|
||||
sourceTaskId: 'source-task',
|
||||
targetTaskId: 'target-task',
|
||||
sourceResourceIds: ['source:one', 'source:two', 'source:three'],
|
||||
targetResourceIds: ['target:one', 'target:two', 'target:three'],
|
||||
cyclic: false,
|
||||
},
|
||||
],
|
||||
);
|
||||
connectionIndex: resourceIds.map((resourceId) => ({
|
||||
resourceId,
|
||||
upstreamReferenceResourceIds:
|
||||
resourceId === 'target:one'
|
||||
? ['source:one']
|
||||
: resourceId === 'unrelated'
|
||||
? ['unrelated']
|
||||
: [],
|
||||
downstreamReferenceResourceIds:
|
||||
resourceId === 'source:one'
|
||||
? ['target:one']
|
||||
: resourceId === 'unrelated'
|
||||
? ['unrelated']
|
||||
: [],
|
||||
referenceEdgeIds:
|
||||
resourceId === 'source:one' || resourceId === 'target:one'
|
||||
? [referenceId]
|
||||
: resourceId === 'unrelated'
|
||||
? [selfReferenceId]
|
||||
: [],
|
||||
taskFlowIds: resourceId === 'unrelated' ? [] : [flowId],
|
||||
})),
|
||||
producerAssignments: [],
|
||||
unresolvedReferenceResourceIds: [],
|
||||
cyclicResourceIds: ['unrelated'],
|
||||
cyclicTaskIds: [],
|
||||
producerMappingTruncated: false,
|
||||
};
|
||||
return normalizeProjectResourceGraph(readModel);
|
||||
}
|
||||
|
||||
function OverlayHarness({
|
||||
@@ -71,11 +103,13 @@ function OverlayHarness({
|
||||
positions,
|
||||
visibleResourceIds,
|
||||
selectedResourceId = null,
|
||||
dragPreview = null,
|
||||
}: {
|
||||
graph: ProjectResourceGraph;
|
||||
positions: ProjectResourceCanvasPosition[];
|
||||
visibleResourceIds: ReadonlySet<string>;
|
||||
selectedResourceId?: string | null;
|
||||
dragPreview?: { resourceId: string; x: number; y: number } | null;
|
||||
}) {
|
||||
return React.createElement(
|
||||
'div',
|
||||
@@ -84,6 +118,7 @@ function OverlayHarness({
|
||||
React.createElement(ResourceDependencyOverlay, {
|
||||
graph,
|
||||
positions,
|
||||
dragPreview,
|
||||
visibleResourceIds,
|
||||
selectedResourceId,
|
||||
}),
|
||||
@@ -95,12 +130,14 @@ function overlayView(
|
||||
positions: ProjectResourceCanvasPosition[],
|
||||
visibleResourceIds: ReadonlySet<string>,
|
||||
selectedResourceId: string | null = null,
|
||||
dragPreview: { resourceId: string; x: number; y: number } | null = null,
|
||||
) {
|
||||
return React.createElement(OverlayHarness, {
|
||||
graph,
|
||||
positions,
|
||||
visibleResourceIds,
|
||||
selectedResourceId,
|
||||
dragPreview,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -159,8 +196,10 @@ describe('ResourceDependencyOverlay', () => {
|
||||
view.rerender(
|
||||
overlayView(
|
||||
graph,
|
||||
[position('source:one', 80, 40), position('target:one', 240, 0)],
|
||||
positions,
|
||||
new Set(['source:one', 'target:one']),
|
||||
null,
|
||||
{ resourceId: 'source:one', x: 80, y: 40 },
|
||||
),
|
||||
);
|
||||
await waitFor(() =>
|
||||
@@ -179,6 +218,56 @@ describe('ResourceDependencyOverlay', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the section origin when updating a dragged path', async () => {
|
||||
const getBoundingClientRect = vi
|
||||
.spyOn(HTMLElement.prototype, 'getBoundingClientRect')
|
||||
.mockImplementation(function () {
|
||||
const isPlane = this.hasAttribute('data-resource-section-plane');
|
||||
return {
|
||||
x: isPlane ? 160 : 20,
|
||||
y: isPlane ? 90 : 10,
|
||||
left: isPlane ? 160 : 20,
|
||||
top: isPlane ? 90 : 10,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
width: 0,
|
||||
height: 0,
|
||||
toJSON: () => ({}),
|
||||
};
|
||||
});
|
||||
try {
|
||||
const graph = graphFixture();
|
||||
const positions = [
|
||||
position('source:one', 0, 0),
|
||||
position('target:one', 240, 0),
|
||||
];
|
||||
const visible = new Set(['source:one', 'target:one']);
|
||||
const view = render(overlayView(graph, positions, visible));
|
||||
const overlay = await screen.findByTestId('resource-dependency-overlay');
|
||||
const selector = '[data-edge-kind="asset-reference"]';
|
||||
await waitFor(() =>
|
||||
expect(overlay.querySelector(selector)?.getAttribute('d')).toContain(
|
||||
'M 320 126',
|
||||
),
|
||||
);
|
||||
|
||||
view.rerender(
|
||||
overlayView(graph, positions, visible, null, {
|
||||
resourceId: 'source:one',
|
||||
x: 80,
|
||||
y: 40,
|
||||
}),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(overlay.querySelector(selector)?.getAttribute('d')).toContain(
|
||||
'M 400 166',
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
getBoundingClientRect.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('routes a cyclic self-reference outside the resource card and moves it with the card', async () => {
|
||||
const graph = graphFixture();
|
||||
const view = render(
|
||||
@@ -244,6 +333,76 @@ describe('ResourceDependencyOverlay', () => {
|
||||
expect(unrelated?.classList.contains('is-dimmed')).toBe(true);
|
||||
});
|
||||
|
||||
it('updates only adjacent paths while dragging inside a 4096-resource topology', async () => {
|
||||
const resourceIds = Array.from(
|
||||
{ length: 4096 },
|
||||
(_, index) => `resource:${index}`,
|
||||
);
|
||||
const taskFlows = resourceIds.slice(1).map((resourceId, index) => ({
|
||||
id: `flow:${index}:${index + 1}`,
|
||||
kind: 'task-flow' as const,
|
||||
sourceTaskId: `task:${index}`,
|
||||
targetTaskId: `task:${index + 1}`,
|
||||
sourceResourceIds: [`resource:${index}`],
|
||||
targetResourceIds: [resourceId],
|
||||
cyclic: false,
|
||||
}));
|
||||
const graph = normalizeProjectResourceGraph({
|
||||
resourceIds,
|
||||
referenceEdges: [],
|
||||
taskFlows,
|
||||
connectionIndex: resourceIds.map((resourceId, index) => ({
|
||||
resourceId,
|
||||
upstreamReferenceResourceIds: [],
|
||||
downstreamReferenceResourceIds: [],
|
||||
referenceEdgeIds: [],
|
||||
taskFlowIds: [
|
||||
...(index > 0 ? [`flow:${index - 1}:${index}`] : []),
|
||||
...(index < resourceIds.length - 1
|
||||
? [`flow:${index}:${index + 1}`]
|
||||
: []),
|
||||
],
|
||||
})),
|
||||
producerAssignments: [],
|
||||
unresolvedReferenceResourceIds: [],
|
||||
cyclicResourceIds: [],
|
||||
cyclicTaskIds: [],
|
||||
producerMappingTruncated: false,
|
||||
});
|
||||
const positions = [
|
||||
position('resource:2047', 0, 0),
|
||||
position('resource:2048', 220, 0),
|
||||
position('resource:2049', 440, 0),
|
||||
];
|
||||
const visible = new Set(positions.map(({ resourceId }) => resourceId));
|
||||
const view = render(overlayView(graph, positions, visible));
|
||||
const overlay = await screen.findByTestId('resource-dependency-overlay');
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
overlay.querySelectorAll('[data-edge-kind="task-flow"]'),
|
||||
).toHaveLength(2),
|
||||
);
|
||||
const setAttribute = vi.spyOn(
|
||||
SVGElement.prototype,
|
||||
'setAttribute',
|
||||
);
|
||||
try {
|
||||
view.rerender(
|
||||
overlayView(graph, positions, visible, null, {
|
||||
resourceId: 'resource:2048',
|
||||
x: 260,
|
||||
y: 32,
|
||||
}),
|
||||
);
|
||||
const geometryUpdates = setAttribute.mock.calls.filter(
|
||||
([name]) => name === 'd',
|
||||
);
|
||||
expect(geometryUpdates).toHaveLength(6);
|
||||
} finally {
|
||||
setAttribute.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('disconnects layout observers when the SVG layer is destroyed', () => {
|
||||
const observe = vi.fn();
|
||||
const disconnect = vi.fn();
|
||||
@@ -257,16 +416,27 @@ describe('ResourceDependencyOverlay', () => {
|
||||
vi.stubGlobal('ResizeObserver', TestResizeObserver);
|
||||
try {
|
||||
const graph = graphFixture();
|
||||
const positions = Array.from(graph.resourceIds).map(
|
||||
(resourceId, index) => position(resourceId, index * 200, 0),
|
||||
);
|
||||
const view = render(
|
||||
overlayView(
|
||||
graph,
|
||||
Array.from(graph.resourceIds).map((resourceId, index) =>
|
||||
position(resourceId, index * 200, 0),
|
||||
),
|
||||
positions,
|
||||
new Set(graph.resourceIds),
|
||||
),
|
||||
);
|
||||
|
||||
expect(observe).toHaveBeenCalledTimes(2);
|
||||
view.rerender(
|
||||
overlayView(
|
||||
graph,
|
||||
positions,
|
||||
new Set(graph.resourceIds),
|
||||
null,
|
||||
{ resourceId: 'source:one', x: 32, y: 24 },
|
||||
),
|
||||
);
|
||||
expect(observe).toHaveBeenCalledTimes(2);
|
||||
view.unmount();
|
||||
expect(disconnect).toHaveBeenCalledTimes(1);
|
||||
|
||||
@@ -732,7 +732,7 @@ export function registerProjectWorkbenchFoundationTests() {
|
||||
localPath: 'assets/spec-source.json',
|
||||
source: {
|
||||
kind: 'canvas',
|
||||
taskId: 'art-director',
|
||||
taskId: 'task-1',
|
||||
resourceId: 'canvas-spec-source',
|
||||
},
|
||||
},
|
||||
@@ -743,7 +743,7 @@ export function registerProjectWorkbenchFoundationTests() {
|
||||
localPath: 'assets/ui-dependency.json',
|
||||
source: {
|
||||
kind: 'canvas',
|
||||
taskId: 'design-foundation',
|
||||
taskId: 'task-2',
|
||||
resourceId: 'canvas-ui-target',
|
||||
referenceResourceIds: ['canvas-spec-source'],
|
||||
},
|
||||
@@ -761,6 +761,130 @@ export function registerProjectWorkbenchFoundationTests() {
|
||||
},
|
||||
);
|
||||
|
||||
const referenceId =
|
||||
'asset-reference:["asset:dependency-spec","asset:dependency-ui"]';
|
||||
const selfReferenceId =
|
||||
'asset-reference:["asset:unrelated-cycle","asset:unrelated-cycle"]';
|
||||
const flowId =
|
||||
'task-flow:["art-director","design-foundation"]';
|
||||
let layoutRevision = 0;
|
||||
const invoke = vi.fn(
|
||||
async (command: string, args?: Record<string, unknown>) => {
|
||||
if (command === 'read_local_project_resource_graph') {
|
||||
expect(args?.resources).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
resourceId: 'asset:dependency-spec',
|
||||
manifestAssetId: 'dependency-spec',
|
||||
producerTaskId: null,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
return {
|
||||
resourceIds: [
|
||||
'asset:dependency-spec',
|
||||
'asset:dependency-ui',
|
||||
'asset:unrelated-cycle',
|
||||
],
|
||||
referenceEdges: [
|
||||
{
|
||||
id: referenceId,
|
||||
kind: 'asset-reference',
|
||||
sourceResourceId: 'asset:dependency-spec',
|
||||
targetResourceId: 'asset:dependency-ui',
|
||||
cyclic: false,
|
||||
},
|
||||
{
|
||||
id: selfReferenceId,
|
||||
kind: 'asset-reference',
|
||||
sourceResourceId: 'asset:unrelated-cycle',
|
||||
targetResourceId: 'asset:unrelated-cycle',
|
||||
cyclic: true,
|
||||
},
|
||||
],
|
||||
taskFlows: [
|
||||
{
|
||||
id: flowId,
|
||||
kind: 'task-flow',
|
||||
sourceTaskId: 'art-director',
|
||||
targetTaskId: 'design-foundation',
|
||||
sourceResourceIds: ['asset:dependency-spec'],
|
||||
targetResourceIds: ['asset:dependency-ui'],
|
||||
cyclic: false,
|
||||
},
|
||||
],
|
||||
connectionIndex: [
|
||||
{
|
||||
resourceId: 'asset:dependency-spec',
|
||||
upstreamReferenceResourceIds: [],
|
||||
downstreamReferenceResourceIds: ['asset:dependency-ui'],
|
||||
referenceEdgeIds: [referenceId],
|
||||
taskFlowIds: [flowId],
|
||||
},
|
||||
{
|
||||
resourceId: 'asset:dependency-ui',
|
||||
upstreamReferenceResourceIds: ['asset:dependency-spec'],
|
||||
downstreamReferenceResourceIds: [],
|
||||
referenceEdgeIds: [referenceId],
|
||||
taskFlowIds: [flowId],
|
||||
},
|
||||
{
|
||||
resourceId: 'asset:unrelated-cycle',
|
||||
upstreamReferenceResourceIds: [
|
||||
'asset:unrelated-cycle',
|
||||
],
|
||||
downstreamReferenceResourceIds: [
|
||||
'asset:unrelated-cycle',
|
||||
],
|
||||
referenceEdgeIds: [selfReferenceId],
|
||||
taskFlowIds: [],
|
||||
},
|
||||
],
|
||||
producerAssignments: [
|
||||
{
|
||||
resourceId: 'asset:dependency-spec',
|
||||
taskId: 'art-director',
|
||||
},
|
||||
{
|
||||
resourceId: 'asset:dependency-ui',
|
||||
taskId: 'design-foundation',
|
||||
},
|
||||
],
|
||||
unresolvedReferenceResourceIds: [],
|
||||
cyclicResourceIds: ['asset:unrelated-cycle'],
|
||||
cyclicTaskIds: [],
|
||||
producerMappingTruncated: false,
|
||||
};
|
||||
}
|
||||
if (command === 'read_local_project_resource_canvas_layout') {
|
||||
return {
|
||||
schemaVersion: 'game-creator-resource-layout.v1',
|
||||
projectId: 'workbench-resource-graph',
|
||||
mode: args?.mode,
|
||||
revision: layoutRevision,
|
||||
positions: [],
|
||||
updatedAt: 0,
|
||||
};
|
||||
}
|
||||
if (command === 'update_local_project_resource_canvas_layout') {
|
||||
layoutRevision += 1;
|
||||
return {
|
||||
status: 'updated',
|
||||
layout: {
|
||||
schemaVersion: 'game-creator-resource-layout.v1',
|
||||
projectId: 'workbench-resource-graph',
|
||||
mode: args?.mode,
|
||||
revision: layoutRevision,
|
||||
positions: args?.positions,
|
||||
updatedAt: layoutRevision,
|
||||
},
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected invoke ${command}`);
|
||||
},
|
||||
);
|
||||
window.__TAURI__ = { core: { invoke } };
|
||||
|
||||
const view = render(
|
||||
React.createElement(ProjectDevelopmentView, {
|
||||
projectName: '资源依赖图测试',
|
||||
|
||||
@@ -1,157 +1,156 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
createProjectResourceGraph,
|
||||
normalizeProjectResourceGraph,
|
||||
projectResourceGraphNeighbors,
|
||||
type ProjectResourceGraphNodeInput,
|
||||
type ProjectResourceGraphReadModel,
|
||||
} from '../src/view/project-development/resourceDependencyGraphModel';
|
||||
|
||||
function resource(
|
||||
id: string,
|
||||
producerTaskId: string | null,
|
||||
options: Partial<ProjectResourceGraphNodeInput> = {},
|
||||
): ProjectResourceGraphNodeInput {
|
||||
function readModel(
|
||||
overrides: Partial<ProjectResourceGraphReadModel> = {},
|
||||
): ProjectResourceGraphReadModel {
|
||||
return {
|
||||
id,
|
||||
producerTaskId,
|
||||
externalResourceId: null,
|
||||
referenceResourceIds: [],
|
||||
...options,
|
||||
resourceIds: [],
|
||||
referenceEdges: [],
|
||||
taskFlows: [],
|
||||
connectionIndex: [],
|
||||
producerAssignments: [],
|
||||
unresolvedReferenceResourceIds: [],
|
||||
cyclicResourceIds: [],
|
||||
cyclicTaskIds: [],
|
||||
producerMappingTruncated: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('resource dependency graph model', () => {
|
||||
it('maps exact external asset references, deduplicates them, and filters missing resources', () => {
|
||||
const graph = createProjectResourceGraph(
|
||||
[
|
||||
resource('asset:spec', 'art-director', {
|
||||
externalResourceId: 'canvas-resource-spec',
|
||||
}),
|
||||
resource('asset:ui', 'design-foundation', {
|
||||
externalResourceId: 'canvas-resource-ui',
|
||||
referenceResourceIds: [
|
||||
'canvas-resource-spec',
|
||||
'canvas-resource-spec',
|
||||
'missing-resource',
|
||||
],
|
||||
}),
|
||||
],
|
||||
[
|
||||
{ id: 'art-director', dependencies: [] },
|
||||
{ id: 'design-foundation', dependencies: ['art-director'] },
|
||||
],
|
||||
it('normalizes the Rust read model and filters stale resource endpoints', () => {
|
||||
const graph = normalizeProjectResourceGraph(
|
||||
readModel({
|
||||
resourceIds: ['asset:source', 'asset:target'],
|
||||
referenceEdges: [
|
||||
{
|
||||
id: 'valid-reference',
|
||||
kind: 'asset-reference',
|
||||
sourceResourceId: 'asset:source',
|
||||
targetResourceId: 'asset:target',
|
||||
cyclic: false,
|
||||
},
|
||||
{
|
||||
id: 'ghost-reference',
|
||||
kind: 'asset-reference',
|
||||
sourceResourceId: 'asset:deleted',
|
||||
targetResourceId: 'asset:target',
|
||||
cyclic: false,
|
||||
},
|
||||
],
|
||||
taskFlows: [
|
||||
{
|
||||
id: 'valid-flow',
|
||||
kind: 'task-flow',
|
||||
sourceTaskId: 'source-task',
|
||||
targetTaskId: 'target-task',
|
||||
sourceResourceIds: ['asset:source', 'asset:deleted'],
|
||||
targetResourceIds: ['asset:target'],
|
||||
cyclic: false,
|
||||
},
|
||||
{
|
||||
id: 'ghost-flow',
|
||||
kind: 'task-flow',
|
||||
sourceTaskId: 'deleted-task',
|
||||
targetTaskId: 'target-task',
|
||||
sourceResourceIds: ['asset:deleted'],
|
||||
targetResourceIds: ['asset:target'],
|
||||
cyclic: false,
|
||||
},
|
||||
],
|
||||
cyclicResourceIds: ['asset:source', 'asset:deleted'],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(graph.referenceEdges).toEqual([
|
||||
expect(graph.referenceEdges.map((edge) => edge.id)).toEqual([
|
||||
'valid-reference',
|
||||
]);
|
||||
expect(graph.taskFlows).toEqual([
|
||||
expect.objectContaining({
|
||||
kind: 'asset-reference',
|
||||
sourceResourceId: 'asset:spec',
|
||||
targetResourceId: 'asset:ui',
|
||||
cyclic: false,
|
||||
id: 'valid-flow',
|
||||
sourceResourceIds: ['asset:source'],
|
||||
targetResourceIds: ['asset:target'],
|
||||
}),
|
||||
]);
|
||||
expect(graph.unresolvedReferenceResourceIds).toEqual(['missing-resource']);
|
||||
expect(graph.cyclicResourceIds).toEqual(new Set(['asset:source']));
|
||||
});
|
||||
|
||||
it('aggregates each task dependency into one flow instead of a resource cartesian product', () => {
|
||||
const graph = createProjectResourceGraph(
|
||||
[
|
||||
resource('upstream:a', 'task-a'),
|
||||
resource('upstream:b', 'task-a'),
|
||||
resource('downstream:a', 'task-b'),
|
||||
resource('downstream:b', 'task-b'),
|
||||
resource('downstream:c', 'task-b'),
|
||||
],
|
||||
[
|
||||
{ id: 'task-a', dependencies: [] },
|
||||
{ id: 'task-b', dependencies: ['task-a', 'task-a'] },
|
||||
],
|
||||
it('queries direct reference and aggregated task-flow neighbors from the bounded index', () => {
|
||||
const graph = normalizeProjectResourceGraph(
|
||||
readModel({
|
||||
resourceIds: ['source:a', 'source:b', 'target:a', 'target:b'],
|
||||
referenceEdges: [
|
||||
{
|
||||
id: 'reference:a',
|
||||
kind: 'asset-reference',
|
||||
sourceResourceId: 'source:a',
|
||||
targetResourceId: 'target:a',
|
||||
cyclic: false,
|
||||
},
|
||||
],
|
||||
taskFlows: [
|
||||
{
|
||||
id: 'flow:a-b',
|
||||
kind: 'task-flow',
|
||||
sourceTaskId: 'task:a',
|
||||
targetTaskId: 'task:b',
|
||||
sourceResourceIds: ['source:a', 'source:b'],
|
||||
targetResourceIds: ['target:a', 'target:b'],
|
||||
cyclic: false,
|
||||
},
|
||||
],
|
||||
connectionIndex: [
|
||||
{
|
||||
resourceId: 'target:a',
|
||||
upstreamReferenceResourceIds: ['source:a'],
|
||||
downstreamReferenceResourceIds: [],
|
||||
referenceEdgeIds: ['reference:a'],
|
||||
taskFlowIds: ['flow:a-b'],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(graph.taskFlows).toHaveLength(1);
|
||||
expect(graph.taskFlows[0]).toMatchObject({
|
||||
sourceTaskId: 'task-a',
|
||||
targetTaskId: 'task-b',
|
||||
sourceResourceIds: ['upstream:a', 'upstream:b'],
|
||||
targetResourceIds: ['downstream:a', 'downstream:b', 'downstream:c'],
|
||||
});
|
||||
});
|
||||
|
||||
it('detects resource and task dependency cycles without recursive traversal', () => {
|
||||
const graph = createProjectResourceGraph(
|
||||
[
|
||||
resource('asset:a', 'task-a', {
|
||||
externalResourceId: 'external-a',
|
||||
referenceResourceIds: ['external-b'],
|
||||
}),
|
||||
resource('asset:b', 'task-b', {
|
||||
externalResourceId: 'external-b',
|
||||
referenceResourceIds: ['external-a'],
|
||||
}),
|
||||
],
|
||||
[
|
||||
{ id: 'task-a', dependencies: ['task-b'] },
|
||||
{ id: 'task-b', dependencies: ['task-a'] },
|
||||
],
|
||||
);
|
||||
|
||||
expect(graph.cyclicResourceIds).toEqual(new Set(['asset:a', 'asset:b']));
|
||||
expect(graph.referenceEdges.every((edge) => edge.cyclic)).toBe(true);
|
||||
expect(graph.cyclicTaskIds).toEqual(new Set(['task-a', 'task-b']));
|
||||
expect(graph.taskFlows.every((flow) => flow.cyclic)).toBe(true);
|
||||
});
|
||||
|
||||
it('detects task cycles even when a cyclic task has no resource card', () => {
|
||||
const graph = createProjectResourceGraph(
|
||||
[resource('asset:a', 'task-a')],
|
||||
[
|
||||
{ id: 'task-a', dependencies: ['task-b'] },
|
||||
{ id: 'task-b', dependencies: ['task-a'] },
|
||||
],
|
||||
);
|
||||
|
||||
expect(graph.taskFlows).toHaveLength(0);
|
||||
expect(graph.cyclicTaskIds).toEqual(new Set(['task-a', 'task-b']));
|
||||
});
|
||||
|
||||
it('returns direct upstream and downstream resources for exact and aggregated edges', () => {
|
||||
const graph = createProjectResourceGraph(
|
||||
[
|
||||
resource('task-a:one', 'task-a', {
|
||||
externalResourceId: 'external-a',
|
||||
}),
|
||||
resource('task-a:two', 'task-a'),
|
||||
resource('task-b:one', 'task-b', {
|
||||
referenceResourceIds: ['external-a'],
|
||||
}),
|
||||
],
|
||||
[
|
||||
{ id: 'task-a', dependencies: [] },
|
||||
{ id: 'task-b', dependencies: ['task-a'] },
|
||||
],
|
||||
);
|
||||
|
||||
const neighbors = projectResourceGraphNeighbors(graph, 'task-b:one');
|
||||
const neighbors = projectResourceGraphNeighbors(graph, 'target:a');
|
||||
expect(neighbors.upstreamResourceIds).toEqual(
|
||||
new Set(['task-a:one', 'task-a:two']),
|
||||
new Set(['source:a', 'source:b']),
|
||||
);
|
||||
expect(neighbors.downstreamResourceIds).toEqual(new Set());
|
||||
expect(neighbors.connectedEdgeIds.size).toBe(2);
|
||||
expect(neighbors.connectedEdgeIds).toEqual(
|
||||
new Set(['reference:a', 'flow:a-b']),
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps graph construction linear for the supported resource bound', () => {
|
||||
const resources = Array.from({ length: 4096 }, (_, index) =>
|
||||
resource(`resource:${index}`, `task:${index}`),
|
||||
it('keeps real producer assignments and audit truncation metadata', () => {
|
||||
const graph = normalizeProjectResourceGraph(
|
||||
readModel({
|
||||
resourceIds: ['asset:spec', 'asset:ui'],
|
||||
producerAssignments: [
|
||||
{ resourceId: 'asset:spec', taskId: 'art-director' },
|
||||
{ resourceId: 'asset:ui', taskId: 'design-foundation' },
|
||||
{ resourceId: 'asset:deleted', taskId: 'task-1' },
|
||||
],
|
||||
producerMappingTruncated: true,
|
||||
}),
|
||||
);
|
||||
const tasks = Array.from({ length: 4096 }, (_, index) => ({
|
||||
id: `task:${index}`,
|
||||
dependencies: index === 0 ? [] : [`task:${index - 1}`],
|
||||
}));
|
||||
const startedAt = performance.now();
|
||||
const graph = createProjectResourceGraph(resources, tasks);
|
||||
const elapsedMs = performance.now() - startedAt;
|
||||
|
||||
expect(graph.taskFlows).toHaveLength(4095);
|
||||
expect(elapsedMs).toBeLessThan(2000);
|
||||
expect(graph.producerTaskIdByResourceId).toEqual(
|
||||
new Map([
|
||||
['asset:spec', 'art-director'],
|
||||
['asset:ui', 'design-foundation'],
|
||||
]),
|
||||
);
|
||||
expect(graph.producerMappingTruncated).toBe(true);
|
||||
expect(projectResourceGraphNeighbors(graph, 'asset:deleted')).toEqual({
|
||||
upstreamResourceIds: new Set(),
|
||||
downstreamResourceIds: new Set(),
|
||||
connectedEdgeIds: new Set(),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -153,7 +153,7 @@ P0 中 `approvalMode` 只能有效写入 `strict`;其它值只能作为不可
|
||||
|
||||
### 5.2 资源画布布局(P1)
|
||||
|
||||
实现状态(2026-07-31):本节布局合同已在独立客户端落地,dependency / type 双模式通过项目内 CAS sidecar 独立持久化;dependency 模式的前端资源依赖关系图层也已落地,但不写入布局 sidecar。资源替换、缩放 / 平移等其余 P1 能力仍按本文非目标保持未实现。
|
||||
实现状态(2026-07-31):本节布局合同已在独立客户端落地,dependency / type 双模式通过项目内 CAS sidecar 独立持久化;dependency 模式由 Tauri Rust 只读构建关系拓扑、前端 SVG 派生几何,图结构和线段均不写入布局 sidecar。资源替换、缩放 / 平移等其余 P1 能力仍按本文非目标保持未实现。
|
||||
|
||||
```ts
|
||||
type ProjectResourceCanvasLayout = {
|
||||
@@ -250,14 +250,16 @@ type UpdateProjectResourceCanvasLayoutResult =
|
||||
#### 5.2.5 资源依赖关系图层
|
||||
|
||||
- 图层只在 dependency 模式挂载;type 模式不得渲染 SVG、连线或 marker。切换 mode、切换项目或卸载工作台时必须销毁旧图层,并清理尺寸观察和窗口事件监听。
|
||||
- 输入固定为当前资源投影的全部卡片坐标与前端 `ProjectResourceGraph`;输出使用原生 SVG 的 path 和箭头 marker。SVG 叠加在资源卡底层并设置 `pointer-events: none`,不得引入 D3、React Flow 等图表库,也不得阻断卡片点击和 Pointer Events 拖动。
|
||||
- 输入固定为当前资源投影的全部卡片身份 / 坐标与 Tauri Rust 返回的 `ProjectResourceGraph` 只读 DTO;Rust 负责资源过滤、去重、环检测、任务流聚合和一跳连接索引,前端只负责 DTO 防御归一化、浏览器几何与原生 SVG path / marker。SVG 叠加在资源卡底层并设置 `pointer-events: none`,不得引入 D3、React Flow 等图表库,也不得阻断卡片点击和 Pointer Events 拖动。
|
||||
- `asset-reference` 表示精确资源引用,使用橙色实线与连续贝塞尔曲线。`GameCreationAppAssetManifestEntry.source.referenceResourceIds` 中的外部资源 ID 必须先唯一匹配另一项资产的 `source.resourceId`,再映射为当前资源卡 ID;缺失、重复或已删除的目标均不得渲染幽灵连线。
|
||||
- `task-flow` 表示任务产物流转,使用灰色圆头虚线。任务依赖按 `sourceTaskId -> targetTaskId` 聚合为一条主线,两端资源仅绘制平滑曲线分支,不得出现直角折线;禁止对上下游资源生成笛卡尔积连线。任务主线与分支可以使用不同线宽和透明度表达聚合层级,但不能改变端点或方向语义。
|
||||
- 画布资产 producer 只能来自 `agent.runtime.canvas.asset_generate` 的 `assetId -> agentId` 审计且 `agentId` 必须存在于当前 manifest;External Editor `source.taskId` 属于平台生成任务命名空间,禁止当作 manifest task ID。证据缺失、冲突或有界审计读取未覆盖时不生成对应 task flow,不猜测归属。
|
||||
- 图模型必须对资源引用图和完整任务依赖图做迭代式环检测,不得用无界递归遍历;参与环的可见边保留渲染并标记 cyclic,环本身不能造成重复生成或死循环。
|
||||
- 资源自引用的起点与终点为同一张卡片时,必须绘制在卡片外侧的可见闭环并保留箭头,不得让路径穿过卡片后被底层 SVG 层级遮挡。
|
||||
- 搜索只允许为当前可见端点生成几何;任一精确引用端点隐藏时该线隐藏,聚合任务流只保留仍可见的两端分支,任一侧没有可见资源时整条任务流隐藏。
|
||||
- 选中资源后,高亮其直接上游、直接下游卡片和关联边,弱化其余边;不做跨多层递归高亮。选中 ID 已失效时按未选中处理。
|
||||
- 拖动预览坐标必须直接进入 SVG 几何计算,使连线随 pointer move 实时更新;拖动结束仍只保存卡片布局坐标,不持久化 path、marker、section 原点或任何图结构。
|
||||
- Pointer Move 必须按动画帧合并;基础 positions 不随每帧复制,静态 SVG 拓扑保持复用,每帧只更新当前资源局部索引关联的 path。`ResizeObserver` 在单个图层生命周期只允许构造一次。dependency section 额外提供至少 `64px` 右侧视觉 gutter,确保最右侧自环和箭头可完整滚动显示,但不得修改卡片坐标或布局 sidecar。
|
||||
|
||||
### 5.3 资源类型与替换兼容性(P1)
|
||||
|
||||
@@ -402,10 +404,11 @@ type ProjectAgentMudPointAttribution = {
|
||||
4. 资源引用环和无资源产物参与的任务环都可被有限遍历识别,界面不死循环。
|
||||
5. 搜索、选择和拖动分别触发端点过滤、直接上下游高亮和实时几何更新;原有点击、详情浮层与拖动保存行为不回归。
|
||||
6. 切换布局模式或项目后旧 SVG、ResizeObserver 与窗口监听全部清理;图层从不写入 layout sidecar、manifest 或其它持久化。
|
||||
7. 4096 资源链式 fixture 下,拖动一张卡片只更新它关联的线段;同一图层 100 次拖动期间 Observer 仍只构造一次。真实 Chromium 目标为拖动 p95 小于 `16.7ms`、不出现超过 `50ms` 的 long task,并完整显示最右侧自环与箭头。
|
||||
|
||||
## 8. 非目标
|
||||
|
||||
- 本切片仍不实现资源替换、不可变迭代版本、画板编辑状态、测试切片、数值参数或泥点归因;已实现的资源关系图只提供前端派生展示,不建立新的资源业务真相。
|
||||
- 本切片仍不实现资源替换、不可变迭代版本、画板编辑状态、测试切片、数值参数或泥点归因;已实现的资源关系图只提供 Rust 只读拓扑与前端派生展示,不建立新的资源业务真相。
|
||||
- 本切片不保存资源详情浮层位置、画布缩放 / 平移、搜索条件、筛选条件或当前 mode;这些状态如需持久化必须另行扩展合同,不能塞入 `game-creator-resource-layout.v1`。
|
||||
- 不修改 SpacetimeDB schema。
|
||||
- 不开放普通用户 Agent.md/Skill。
|
||||
|
||||
@@ -16,13 +16,15 @@
|
||||
|
||||
---
|
||||
|
||||
## 2026-07-31 AI 游戏创作资源依赖图采用纯前端派生 SVG
|
||||
## 2026-07-31 AI 游戏创作资源依赖图采用 Rust 只读拓扑与前端派生 SVG
|
||||
|
||||
- 背景:资源画布已有 dependency / type 双模式坐标与本地 CAS sidecar,但 dependency 模式尚未把当前 manifest 中可证明的资源引用和任务流转可视化;关系图不能反向污染布局持久化或建立第二套资源真相。
|
||||
- 决策:dependency 模式从当前资源卡投影与 manifest task 派生 `ProjectResourceGraph`,使用原生 SVG 底层图层渲染。资产 `source.referenceResourceIds` 只在唯一匹配另一资产 `source.resourceId` 后形成橙色实线;任务依赖按任务对聚合为灰色虚线主线与两端分支,禁止资源笛卡尔积。图模型以迭代式强连通分量分析识别资源环和完整任务 DAG 环;搜索、选择、拖动只改变当前图层几何和高亮。
|
||||
- 生命周期与边界:type 模式不挂载图层;切换 mode、项目或卸载工作台时销毁 SVG、ResizeObserver 和窗口监听。SVG 统一 `pointer-events: none`,不干扰卡片点击/拖动;path、marker、图结构和 section 原点从不持久化。本切片不修改 Rust、Tauri command、layout sidecar、`resourceCanvasLayoutModel.ts`、manifest、api-server 或 SpacetimeDB,也不引入第三方图表库。
|
||||
- 影响范围:`apps/ai-game-creator-shell` 项目开发资源投影、依赖图模型、SVG overlay、样式与前端测试,以及工作台 PRD 和客户端实施计划。
|
||||
- 验证方式:图模型与 SVG 定向测试覆盖去重、无效 ID、完整任务环、可见资源自引用闭环、任务流聚合、搜索过滤、选择高亮和拖动几何;AppSurface 覆盖两种边、type 模式卸载和项目切换销毁,并运行 shell typecheck、编码检查与 `git diff --check`。
|
||||
- 决策:dependency 模式由 Tauri Rust 只读命令从当前 manifest、资源卡身份和有界 `.agent/agent.db` 审计构建稳定 `ProjectResourceGraph` read model,前端只归一化 DTO、测量卡片坐标并用原生 SVG 渲染。资产 `source.referenceResourceIds` 只在唯一匹配另一资产 `source.resourceId` 后形成橙色实线;任务依赖按任务对聚合为灰色虚线主线与两端分支,禁止资源笛卡尔积。Rust 以迭代式强连通分量分析识别资源环和完整任务 DAG 环,并返回资源局部连接索引。
|
||||
- 任务身份:External Editor 响应中的 `source.taskId` 是平台生成任务 ID,不等于本地 manifest task ID,禁止据此分配 producer。画布资产只接受 `agent.runtime.canvas.asset_generate` 审计中经当前 manifest task 校验的 `assetId -> agentId`;证据缺失、冲突或已超出有界读取窗口时不生成对应 task flow。任务产物与 Agent 回执继续使用自身已有的 manifest task 身份。
|
||||
- 生命周期与边界:Pointer Move 先用 `requestAnimationFrame` 合帧;基础 positions 与拖动预览分离,SVG 静态拓扑保持复用,每帧只按局部索引更新拖动资源关联的 reference edge 和 task flow。`ResizeObserver` 在单个图层生命周期只创建一次。type 模式不挂载图层;切换 mode、项目或卸载工作台时销毁 SVG、Observer 和窗口监听。SVG 统一 `pointer-events: none`;path、marker、图结构和 section 原点从不持久化。
|
||||
- 数据边界:本切片只新增 Tauri Rust 只读 read model,不修改 layout sidecar、`resourceCanvasLayoutModel.ts`、manifest、api-server、SpacetimeDB schema 或生成绑定,也不引入第三方图表库。dependency section 只在显示层额外预留 `64px` 右侧视觉 gutter,卡片坐标和持久化布局不变。
|
||||
- 影响范围:`apps/ai-game-creator-shell` 的 Tauri project read model/command、项目开发资源投影、依赖图 DTO、SVG overlay、样式与前后端测试,以及工作台 PRD 和客户端实施计划。
|
||||
- 验证方式:Rust 定向测试覆盖真实 producer 映射、拒绝复用外部 `taskId`、证据缺失、去重、无效 ID、完整任务环、4096 任务链与聚合复杂度;前端模型和 SVG 测试覆盖 DTO 防御过滤、局部上下游、可见资源自引用闭环、搜索、高亮、单帧局部 path 更新和稳定 Observer;AppSurface 覆盖生产数据形状、两种边、type 模式卸载和项目切换销毁,并运行 shell typecheck、编码检查与 `git diff --check`。
|
||||
- 关联文档:`docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md`、`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`。
|
||||
|
||||
---
|
||||
|
||||
@@ -14,6 +14,14 @@
|
||||
- 关联:相关文件、文档、提交或 Issue
|
||||
```
|
||||
|
||||
## External Editor taskId 不能当作本地 manifest taskId
|
||||
|
||||
- 现象:画布资产之间已有橙色精确引用线,但依赖任务之间没有灰色 task flow;测试用 `design-foundation` 之类字符串时正常,真实生成返回 `task-1` 后失败。
|
||||
- 原因:`GameCreationAppAssetSource.taskId` 保存的是 External Editor 生成任务身份,命名空间与本地 `.agent/manifest.json` 的 Agent/task 身份不同;前端用 `taskById.get(source.taskId)` 会让真实画布资产全部失去 producer。
|
||||
- 处理:资源依赖图的 Tauri Rust read model 从有界 `.agent/agent.db` 读取 `agent.runtime.canvas.asset_generate`,以 `assetId -> agentId` 映射 producer,并要求 `agentId` 存在于当前 manifest。记录缺失、多个不同有效 Agent 冲突或读取已截断时失败关闭该资产的 task flow,不回退 `source.taskId`。精确 `asset-reference` 仍只依赖 manifest 中外部 resourceId 的唯一匹配。
|
||||
- 验证:Rust fixture 把 `source.taskId` 固定为 `task-1 / task-2`,只有审计提供 `art-director / design-foundation` 后才生成 task flow;移除审计后橙色引用保留、灰色任务流消失。AppSurface 使用相同生产数据形状回归。
|
||||
- 关联:`apps/ai-game-creator-shell/src-tauri/src/project/resource_dependency_graph.rs`、`apps/ai-game-creator-shell/src/view/project-development/resourceDependencyGraphModel.ts`、`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`。
|
||||
|
||||
## Jenkins 异步备份不能用 nohup 脱离作业
|
||||
|
||||
- 现象:Stdb Publish 成功,上传日志只留下“已获取进程锁 / 上传已有备份 / 目标对象”,没有成功或可捕获错误;本地 tar.gz 和 `uploadStatus=deferred` manifest 每次发布后继续增长。
|
||||
|
||||
@@ -364,18 +364,20 @@ game-project/
|
||||
|
||||
2026-07-30 Rust 并发与零副作用加固状态:资源布局锁已由 `create_new + mtime stale 删除` 改为持久锁文件上的 Unix `flock` / Windows 独占句柄,活锁即使 mtime 很旧也不能被另一个写入者回收,释放后仍复用同一文件实例。更新命令携带只用于校验的 `expectedProjectId`,在任何目录创建前先读取 manifest 并拒绝旧项目窗口,锁内再次核对 projectId;不存在根、非项目根、损坏 manifest 和路径重建后的旧窗口均不产生 `.agent/workbench`。revision 在共享 serde、Tauri 命令和前端 IPC 三层限制到 `Number.MAX_SAFE_INTEGER`,达到上限时保持原文件并失败关闭,不能让 Rust `u64` 值在 JavaScript 中失真后击穿 CAS。
|
||||
|
||||
### 资源依赖关系图层 V1
|
||||
### 资源依赖关系图层 V1.1
|
||||
|
||||
2026-07-31 起,项目工作台在不修改 Rust、layout sidecar 和既有布局模型的前提下增加纯前端资源依赖图层:
|
||||
2026-07-31 起,项目工作台使用“Tauri Rust 只读拓扑 + 前端原生 SVG 几何”的资源依赖图层;不修改 layout sidecar、既有布局模型、api-server 或 SpacetimeDB:
|
||||
|
||||
- `resourceDependencyGraphModel.ts` 负责从当前资源卡投影和 manifest task 构建 `ProjectResourceGraph`。精确引用把资产 `source.referenceResourceIds` 唯一匹配到另一资产的 `source.resourceId`,再转换为实际卡片 ID;无匹配、多匹配和已删除资源只记录为 unresolved,不生成边。引用边按 `sourceResourceId + targetResourceId` 稳定去重。
|
||||
- task flow 只读取存在于当前 manifest 的任务依赖。资源按 `producerTaskId` 分组,每个 `sourceTaskId -> targetTaskId` 只生成一个聚合 flow;SVG 侧绘制 source 分支、唯一主线和 target 分支,路径数量为 `O(S+T)`,禁止资源笛卡尔积。
|
||||
- 资源引用图和完整任务依赖图分别使用迭代式强连通分量分析。任务环检测不能依赖可视 task flow 是否有两端资源,否则无产物任务参与的环会漏报;循环边只带 cyclic 标记,不触发递归展开。
|
||||
- `read_local_project_resource_graph` 读取当前 manifest、前端资源卡身份列表和最多 `32 MiB` 的安全 Agent DB 尾部,通过 Rust 构建稳定 read model;读取使用既有 Agent DB 普通文件 / 链接 / 追加锁边界,不新增数据库或 sidecar。返回资源 ID、引用边、聚合任务流、producer assignment、循环集合、unresolved 外部 ID、局部连接索引和 `producerMappingTruncated`。
|
||||
- 精确引用把 manifest 资产 `source.referenceResourceIds` 唯一匹配到另一资产的 `source.resourceId`,再转换为本次资源卡 ID;无匹配、多匹配、重复卡片或已删除资源只记录为 unresolved / 忽略,不生成边。引用边按 `sourceResourceId + targetResourceId` 稳定去重;前端 `resourceDependencyGraphModel.ts` 再做一次 DTO 端点防御过滤,避免异步切项目时出现幽灵线。
|
||||
- task flow 只读取存在于当前 manifest 的任务依赖。画布资产 producer 仅接受 `agent.runtime.canvas.asset_generate` 中经 manifest 校验的 `assetId -> agentId`;External Editor 返回并保存在 `source.taskId` 的 `task-1` 等身份属于平台生成任务,禁止复用为 manifest task。多个有效 Agent 对同一资产形成冲突或证据缺失时,不生成该资产对应 task flow。任务产物 / Agent 回执继续使用资源投影中已有的 manifest task 身份。
|
||||
- 资源按可信 producer 分组,每个 `sourceTaskId -> targetTaskId` 只生成一个聚合 flow;SVG 侧绘制 source 分支、唯一主线和 target 分支,路径数量为 `O(S+T)`,禁止资源笛卡尔积。局部连接索引保存 resource 关联的 reference edge ID / task flow ID,不预先展开 `S×T` 邻接矩阵。
|
||||
- 资源引用图和完整任务依赖图在 Rust 分别使用迭代式强连通分量分析。任务环检测不能依赖可视 task flow 是否有两端资源,否则无产物任务参与的环会漏报;循环边只带 cyclic 标记,不触发递归展开。
|
||||
- `ResourceDependencyOverlay.tsx` 使用原生 SVG path/marker,绝对定位在 `.game-resource-canvas-content` 底层并统一 `pointer-events: none`。橙色实线表示 `asset-reference`,灰色圆头虚线表示 `task-flow`;两类连线统一使用连续贝塞尔曲线,任务主线略强于两端分支,箭头使用不随高亮线宽缩放的稳定用户空间尺寸,避免直角折线、突兀拐弯和箭头跳变。不引入 D3、React Flow 或其它图表依赖。
|
||||
- `asset-reference` 的 source / target 是同一资源时使用卡片右侧外绕贝塞尔闭环,两个锚点分开且 marker 保留在返回锚点;路径不穿过卡片,并与普通边一样直接由拖动预览坐标重算。
|
||||
- 图层用 SVG 自身节点定位所属画布容器,测量各 section plane 相对原点;`ResizeObserver` 与 window resize 只负责重新测量,并在卸载时清理。这样不依赖父组件 ref 在子组件 layout effect 中的绑定时序。type 模式不挂载图层;项目身份作为 key,切换 mode、项目或工作台卸载都会销毁旧 SVG。
|
||||
- 搜索可见 ID 在几何阶段过滤端点;选中资源只查询一跳上下游与关联边,关联卡片和边高亮,其余边弱化。拖动预览坐标直接替换当前卡片坐标传入图层,pointer move 实时更新 path;结束后仍只通过原布局 Hook 保存卡片坐标,SVG 几何从不持久化。
|
||||
- 前端模型、SVG 单测和工作台 AppSurface 回归覆盖精确引用、去重、无效 ID、完整任务环、聚合复杂度、搜索过滤、选择高亮、拖动更新、type 模式卸载与项目切换销毁。该切片不修改 `resourceCanvasLayoutModel.ts`、Tauri command、layout sidecar、`api-server`、Rust 或 SpacetimeDB。
|
||||
- `asset-reference` 的 source / target 是同一资源时使用卡片右侧外绕贝塞尔闭环,两个锚点分开且 marker 保留在返回锚点;路径不穿过卡片。dependency section 在现有 extent 外额外增加 `64px` 右侧视觉 gutter,确保最右卡片的闭环和箭头可滚动显示;不改卡片坐标、`resourceCanvasLayoutModel.ts` 或 sidecar。
|
||||
- 图层用 SVG 自身节点定位所属画布容器,测量各 section plane 相对原点;`ResizeObserver` 在图层挂载时只创建一次,与 window resize 一起负责重新测量并在卸载时清理。type 模式不挂载图层且释放 graph state;项目身份作为 key,切换 mode、项目或工作台卸载都会销毁旧 SVG。
|
||||
- 基础 positions 保持稳定,拖动预览单独传入。Pointer Move 通过 `requestAnimationFrame` 合帧;静态 task/reference React SVG 子树不依赖 drag preview,每帧只按局部连接索引重新计算当前资源关联的 edge / flow 并更新对应 path `d`。搜索、选择、项目切换、真实 positions 或 section origin 变化才允许重新协调静态图层;结束后仍只通过原布局 Hook 保存卡片坐标,SVG 几何从不持久化。
|
||||
- Rust、前端 DTO/SVG 和工作台 AppSurface 回归覆盖生产 `task-1` 数据形状、真实 producer、证据缺失、精确引用、去重、无效 ID、完整任务环、4096 链式拓扑、聚合复杂度、搜索过滤、选择高亮、局部拖动更新、稳定 Observer、type 模式卸载与项目切换销毁。真实 Chromium 性能目标为拖动 p95 `<16.7ms`、不出现 `>50ms` long task;若实测仍超过预算,再增加 viewport + overscan 边裁剪,不在首轮预先引入额外复杂度。
|
||||
|
||||
## 分阶段实施
|
||||
|
||||
|
||||
Reference in New Issue
Block a user