From 2ec93246c2a3ea6c68f236bd90fbf97f32183fb2 Mon Sep 17 00:00:00 2001 From: wangliangbj01 Date: Thu, 14 May 2026 12:21:53 +0800 Subject: [PATCH 1/4] feat(react): hide CTE aliases in matrix view with transitive collapse The Tables sub-mode of MatrixView previously surfaced WITH-clause CTE aliases as first-class rows/columns alongside physical tables, diluting the cross-script blueprint signal (typical scripts have 30%+ CTE noise). Add a Layers toggle (default ON) that: - Filters CTE rows/columns out of the rendered matrix. - Reconstructs physical->physical dependencies that previously only reached via CTE chains, using a forward BFS over `write` cells with the visited-CTE path captured in `viaCtes`. - Renders rebuilt edges with a dashed half-opacity arrow and a tooltip showing the CTE hop chain, distinct from direct edges. - Hides itself in Scripts sub-mode (no overlap with CTE keys). Worker payload gains `cteItemKeys: string[]`; collapse + metric recomputation happen on the main thread for instant toggle response. Refactor: extract `MatrixMetrics` and `computeMatrixMetrics` into matrixUtils as the single source of truth, eliminating a 49-line duplicate that previously lived in both the worker and MatrixView. Docs: clarify in CLAUDE.md and ui-change-protocol.md that the Cursor browser MCP (Playwright-based) reliably operates Radix DropdownMenu buttons, while the external agent-browser CLI is still unreliable. Tests: 5 new cases cover single-hop, multi-hop, direct-edge-priority, empty CTE set, and chain-isolation scenarios. 201/201 passing. Co-authored-by: Cursor --- .../frontend/ui-change-protocol.md | 9 +- .../tasks/05-14-matrix-hide-cte/check.jsonl | 1 + .../05-14-matrix-hide-cte/implement.jsonl | 1 + .trellis/tasks/05-14-matrix-hide-cte/prd.md | 61 +++++ .../tasks/05-14-matrix-hide-cte/task.json | 26 ++ CLAUDE.md | 2 +- packages/react/src/components/MatrixView.tsx | 145 +++++++++-- packages/react/src/utils/matrixUtils.ts | 246 ++++++++++++++++++ .../react/src/utils/matrixWorkerService.ts | 11 +- packages/react/src/workers/matrix.worker.ts | 69 ++--- packages/react/tests/matrixView.test.ts | 127 +++++++++ 11 files changed, 624 insertions(+), 74 deletions(-) create mode 100644 .trellis/tasks/05-14-matrix-hide-cte/check.jsonl create mode 100644 .trellis/tasks/05-14-matrix-hide-cte/implement.jsonl create mode 100644 .trellis/tasks/05-14-matrix-hide-cte/prd.md create mode 100644 .trellis/tasks/05-14-matrix-hide-cte/task.json diff --git a/.trellis/spec/flowscope-app/frontend/ui-change-protocol.md b/.trellis/spec/flowscope-app/frontend/ui-change-protocol.md index 55812831..a1976736 100644 --- a/.trellis/spec/flowscope-app/frontend/ui-change-protocol.md +++ b/.trellis/spec/flowscope-app/frontend/ui-change-protocol.md @@ -100,4 +100,11 @@ cargo build -p flowscope-cli --features serve # 2. embed 进 binary pkill -f 'flowscope.*--serve'; ./target/debug/flowscope --serve ... # 3. 重启 ``` -**agent-browser 的限制**:Radix UI DropdownMenu 内部的 button 无法被 `click @ref` 可靠点击(DOM 会被提前删除)。需要通过手动 Chrome 操作或 JS 注入验证。 +**浏览器自动化分两档**: + +| 工具 | Radix DropdownMenu / Popover 内的 button | 备注 | +|------|------------------------------------------|------| +| Cursor IDE 浏览器 MCP(`cursor-ide-browser`,基于 Playwright) | ✅ 可靠 | snapshot 拿 ref → click,DOM detach 前 Playwright 会等待事件冒泡完成 | +| 外部 `agent-browser` CLI | ❌ 不可靠 | DOM 会在 click 落地前被 Radix 卸载,需手动 Chrome 或 JS 注入 | + +优先用 Cursor 浏览器 MCP 做端到端验证;仅在没有 Cursor 上下文(独立 CI、远程 SSH)时退化到 agent-browser CLI + 手动验证。 diff --git a/.trellis/tasks/05-14-matrix-hide-cte/check.jsonl b/.trellis/tasks/05-14-matrix-hide-cte/check.jsonl new file mode 100644 index 00000000..9dd3234a --- /dev/null +++ b/.trellis/tasks/05-14-matrix-hide-cte/check.jsonl @@ -0,0 +1 @@ +{"_example": "Fill with {\"file\": \"\", \"reason\": \"\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."} diff --git a/.trellis/tasks/05-14-matrix-hide-cte/implement.jsonl b/.trellis/tasks/05-14-matrix-hide-cte/implement.jsonl new file mode 100644 index 00000000..9dd3234a --- /dev/null +++ b/.trellis/tasks/05-14-matrix-hide-cte/implement.jsonl @@ -0,0 +1 @@ +{"_example": "Fill with {\"file\": \"\", \"reason\": \"\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."} diff --git a/.trellis/tasks/05-14-matrix-hide-cte/prd.md b/.trellis/tasks/05-14-matrix-hide-cte/prd.md new file mode 100644 index 00000000..5fcca859 --- /dev/null +++ b/.trellis/tasks/05-14-matrix-hide-cte/prd.md @@ -0,0 +1,61 @@ +# Matrix 隐藏 CTE 开关与传递性依赖补全 + +## Goal + +在 Matrix 视图的 Tables 子模式下,**默认隐藏 CTE 节点**,并提供工具栏开关供用户按需展开。隐藏时通过 BFS 传递性补全,避免「仅通过 CTE 链相连」的物理表对依赖在矩阵中消失。 + +## Background + +- Matrix 当前用 `isTableLikeType()` 收集表节点,把 `'table' | 'view' | 'cte'` 一锅端到矩阵里(`packages/react/src/utils/matrixUtils.ts:76`)。 +- 截图实测:典型多 CTE 脚本里 CTE 节点能占矩阵 ≥30% 的行/列,把真物理表标签挤变形、稀释信号。 +- FlowScope 数据模型已经承认 CTE 不是物理对象(`audit-api-spec.md` 中 `table_count` 只计 `Table | View`)。 +- CTE 在跨脚本对比、聚类、heatmap 这些 Matrix 核心场景里没有语义价值。 + +## Requirements + +### 数据层(matrixUtils) + +- `MatrixData` 维持现接口,不破坏 Worker 协议。 +- Worker payload 新增 `cteItemSet: string[]`(序列化用 array,主线程转 Set),用于主线程识别哪些 item 是 CTE。 +- `extractTableDependenciesWithDetails` 已经会经由 column-level 路径建立 `cte→cte`、`cte→table`、`table→cte` 三种依赖;不修改这部分逻辑。 +- 新增主线程工具函数 `collapseCteFromMatrix(matrix, cteSet) → MatrixData`: + - 过滤掉 CTE item 行/列; + - 对每对剩余的物理表 `(A, B)`,若原 cells 中 `A→...→B` 仅经由 CTE 链可达(且原本无直接 write/read),则在新矩阵中补一条 `write` 边,details 用 transitive 链上首尾任一段的 details(标记 `indirect: true` 字段供后续 tooltip 区分)。 + - 已存在的直接边保留原样。 + +### 状态管理 + +- `MatrixViewControlledState` 新增 `hideCte: boolean`,默认 `true`。 +- 走 `useImmediateControlledMatrixState` pipeline,可被外部 controlled。 + +### UI + +- Tables 工具栏(`subMode === 'tables'`)新增一个开关按钮(lucide `Layers` 图标),位置紧挨 Heatmap / Cluster / Complexity 这一组。 +- Scripts 子模式下不显示该按钮。 +- 按下时切换 `hideCte`,开启状态视觉与其他 toggle 一致(`bg-cyan-100 text-cyan-600 ring-1 ring-cyan-500`)。 +- Tooltip 文案中文 + 英文: + - 标题:`Hide CTE Aliases` + - 说明:`Collapse CTE rows/columns and connect physical tables transitively.` + +### 渲染层 + +- `MatrixView` 在 `fullMatrixData` 之后新增一个 useMemo 衍生的 `displayMatrixData`,根据 `hideCte && subMode === 'tables'` 决定是否调用 `collapseCteFromMatrix`。 +- 后续所有 `sortedItems` / 过滤 / 渲染均基于 `displayMatrixData`。 +- legend 区在 `hideCte` 开启时显示一个 `CTE Hidden` 状态标识。 + +## Acceptance Criteria + +- [ ] 默认进入 Matrix-Tables 视图,CTE 节点(`a / before / t1` 这类)不出现在行列里。 +- [ ] 工具栏 `Layers` 按钮可以一键切换显示/隐藏 CTE。 +- [ ] 隐藏 CTE 时,原本通过 CTE 链相连的物理表之间能在矩阵里看到 `write` 箭头。 +- [ ] Scripts 子模式工具栏不显示该按钮。 +- [ ] 单测覆盖:`collapseCteFromMatrix` 至少覆盖 3 种场景: + - 物理表 → CTE → 物理表(间接,应补全); + - 物理表 → CTE → CTE → 物理表(多跳间接); + - 物理表 → 物理表 同时也途径 CTE(直接边优先,不重复补)。 +- [ ] `yarn workspace @pondpilot/flowscope-react lint && typecheck && test` 全部通过。 + +## Notes + +- 不动 Rust 引擎、不动 worker 内 build 逻辑,纯前端聚合 + 渲染层改造。 +- `details.indirect` 字段为可选扩展,TS 类型在 `TableDependencyWithDetails` 上标记为 `indirect?: true`。 diff --git a/.trellis/tasks/05-14-matrix-hide-cte/task.json b/.trellis/tasks/05-14-matrix-hide-cte/task.json new file mode 100644 index 00000000..620028fb --- /dev/null +++ b/.trellis/tasks/05-14-matrix-hide-cte/task.json @@ -0,0 +1,26 @@ +{ + "id": "matrix-hide-cte", + "name": "matrix-hide-cte", + "title": "Matrix \\u9690\\u85cf CTE \\u5f00\\u5173\\u4e0e\\u4f20\\u9012\\u6027\\u4f9d\\u8d56\\u8865\\u5168", + "description": "", + "status": "planning", + "dev_type": null, + "scope": null, + "package": null, + "priority": "P2", + "creator": "wangliang", + "assignee": "wangliang", + "createdAt": "2026-05-14", + "completedAt": null, + "branch": null, + "base_branch": "master", + "worktree_path": null, + "commit": null, + "pr_url": null, + "subtasks": [], + "children": [], + "parent": null, + "relatedFiles": [], + "notes": "", + "meta": {} +} \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index f735209b..1ee982ac 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -35,7 +35,7 @@ This file is intentionally short. `AGENTS.md` is the canonical source of build, 1. **组件文件定位**:精确到文件路径 + 行号(不是"某个地方")。 2. **API 数据先验证**:血缘/数据逻辑问题先用 curl 验证 API 返回;API 不对 → **先修 Rust 引擎**;API 对、图不对 → 才改前端渲染。 -3. **验证方式**:`agent-browser` 无法可靠点击 Radix UI DropdownMenu 内的 button,需手动 Chrome 验证或 JS 注入。 +3. **验证方式**:Cursor IDE 浏览器 MCP(基于 Playwright)可以可靠点击 Radix UI DropdownMenu / Popover 内的 button,无需手动 Chrome;外部 `agent-browser` CLI 在该场景仍不可靠,需走手动 Chrome 或 JS 注入。 完整规范、curl 模板、验证 SOP 见 `.trellis/spec/flowscope-app/frontend/ui-change-protocol.md`。 diff --git a/packages/react/src/components/MatrixView.tsx b/packages/react/src/components/MatrixView.tsx index 7ab7b1f9..b7d639aa 100644 --- a/packages/react/src/components/MatrixView.tsx +++ b/packages/react/src/components/MatrixView.tsx @@ -30,6 +30,7 @@ import { BarChart2, ScanLine, Loader2, + Layers, } from 'lucide-react'; import { useLineage } from '../store'; import type { MatrixSubMode } from '../types'; @@ -49,6 +50,9 @@ import { type ScriptDependency, type MatrixCellData, type MatrixData, + type MatrixMetrics, + collapseCteFromMatrix, + computeMatrixMetrics, } from '../utils/matrixUtils'; import { buildMatrixInWorker, cancelPendingMatrixBuilds } from '../utils/matrixWorkerService'; @@ -62,14 +66,7 @@ interface MatrixWorkerPayload { tableItemsRendered: number; scriptItemCount: number; scriptItemsRendered: number; -} - -interface MatrixMetrics { - rowCounts: Map; - colCounts: Map; - maxRow: number; - maxCol: number; - maxIntensity: number; + cteItemKeys: string[]; } const EMPTY_MATRIX: MatrixData = { items: [], cells: new Map() }; @@ -248,6 +245,10 @@ const MatrixCell = memo( return { backgroundColor: color }; }, [heatmapMode, hasDependency, intensity, cellData.type]); + const isIndirect = + (cellData.type === 'write' || cellData.type === 'read') && + (cellData.details as TableDependencyWithDetails | undefined)?.indirect === true; + const content = useMemo(() => { switch (cellData.type) { case 'self': @@ -255,19 +256,30 @@ const MatrixCell = memo( case 'write': return ( ); case 'read': return ( - + ); case 'none': default: return null; } - }, [cellData.type]); + }, [cellData.type, isIndirect]); const tooltipContent = useMemo(() => { const displayRowName = getShortName(rowName); @@ -351,6 +363,8 @@ const MatrixCell = memo( // Standard Tooltip if (subMode === 'tables') { const details = cellData.details as TableDependencyWithDetails | undefined; + const indirect = details?.indirect === true; + const viaCtes = details?.viaCtes ?? []; return (
@@ -358,7 +372,21 @@ const MatrixCell = memo( {isWrite ? displayColName : displayRowName}
- {details && details.columnCount > 0 && ( + {indirect && ( +
+ Indirect + {viaCtes.length > 0 && ( + + {' '} + via {viaCtes.length} CTE hop{viaCtes.length > 1 ? 's' : ''}:{' '} + + {viaCtes.map(getShortName).join(' → ')} + + + )} +
+ )} + {details && details.columnCount > 0 && !indirect && (
{details.columnCount} column {details.columnCount > 1 ? 's' : ''} mapped @@ -449,6 +477,13 @@ export interface MatrixViewControlledState { focusedNode: string | null; firstColumnWidth: number; headerHeight: number; + /** + * Hide CTE rows/columns in the Tables sub-mode and reconstruct physical-to-physical + * dependencies via transitive closure. Defaults to true because CTE aliases are + * scope-local and dilute the matrix signal for cross-script comparison. + * No effect in Scripts sub-mode. + */ + hideCte: boolean; } interface MatrixViewProps { @@ -652,6 +687,12 @@ export function MatrixView({ onStateChange, DEFAULT_HEADER_HEIGHT ); + const [hideCte, setHideCte] = useImmediateControlledMatrixState( + 'hideCte', + controlledState, + onStateChange, + true + ); const debouncedFilterText = useDebounce(filterText, SEARCH_DEBOUNCE_DELAY); const [hoveredCell, setHoveredCell] = useState<{ row: string; col: string } | null>(null); @@ -769,11 +810,39 @@ export function MatrixView({ }; }, [result]); - const fullMatrixData = useMemo(() => { + const rawMatrixData = useMemo(() => { if (!matrixPayload) return EMPTY_MATRIX; return matrixSubMode === 'tables' ? matrixPayload.tableMatrix : matrixPayload.scriptMatrix; }, [matrixSubMode, matrixPayload]); + const cteItemSet = useMemo(() => { + if (!matrixPayload) return null; + if (matrixPayload.cteItemKeys.length === 0) return null; + return new Set(matrixPayload.cteItemKeys); + }, [matrixPayload]); + + // Apply CTE collapse only in Tables sub-mode; Scripts items are file paths and + // never overlap with CTE keys, so the toggle has no effect there. + const fullMatrixData = useMemo(() => { + if (matrixSubMode !== 'tables' || !hideCte || !cteItemSet) { + return rawMatrixData; + } + const start = MATRIX_DEBUG ? performance.now() : 0; + const collapsed = collapseCteFromMatrix(rawMatrixData, cteItemSet); + if (MATRIX_DEBUG) { + const duration = performance.now() - start; + if (duration > 8) { + console.log(`[MatrixView] collapseCteFromMatrix: ${duration.toFixed(1)}ms`); + } + } + return collapsed; + }, [rawMatrixData, cteItemSet, hideCte, matrixSubMode]); + + const hiddenCteCount = useMemo(() => { + if (matrixSubMode !== 'tables' || !hideCte || !cteItemSet) return 0; + return rawMatrixData.items.filter((item) => cteItemSet.has(item)).length; + }, [rawMatrixData, cteItemSet, hideCte, matrixSubMode]); + const allColumnNames = matrixPayload?.allColumnNames ?? []; const limitInfo = useMemo(() => { @@ -816,8 +885,13 @@ export function MatrixView({ maxIntensity: 1, }; } + // When CTE collapse changed the matrix structure, the worker-computed metrics + // refer to a different item set; recompute on the (already small) collapsed view. + if (matrixSubMode === 'tables' && fullMatrixData !== rawMatrixData) { + return computeMatrixMetrics(fullMatrixData, 'tables'); + } return matrixSubMode === 'tables' ? matrixPayload.tableMetrics : matrixPayload.scriptMetrics; - }, [matrixPayload, matrixSubMode]); + }, [matrixPayload, matrixSubMode, fullMatrixData, rawMatrixData]); // Clustering Logic const sortedItems = useMemo(() => { @@ -1321,6 +1395,44 @@ export function MatrixView({ + {/* Hide CTE Toggle (Tables sub-mode only) */} + {matrixSubMode === 'tables' && ( + + + + + + +
Hide CTE Aliases
+
+ Collapse CTE rows/columns and connect physical tables transitively. +
+ {hideCte && hiddenCteCount > 0 && ( +
+ {hiddenCteCount} CTE{hiddenCteCount > 1 ? 's' : ''} hidden, dotted arrows = + indirect dependency. +
+ )} +
+
+
+ )} + {/* Legend Toggle */} {!showLegend && ( @@ -1758,6 +1870,9 @@ export function MatrixView({ {heatmapMode && Heatmap Active} {clusterMode && Sorted by Clusters} {complexityMode && Complexity Margins} + {matrixSubMode === 'tables' && hideCte && hiddenCteCount > 0 && ( + CTE Hidden ({hiddenCteCount}) + )}