Files
next-ai-draw-io/lib/diagram-diff.ts
T
Dayuan Jiang 60105ddb51 refactor: remove dead code and over-engineering found by two audits (#986)
## Problem

Two audits of the repository (a workflow of finder, dedup and per-file verifier agents, run on 2026-10-11 against 1f4141e and again against 07f3e3c after a first round of refactoring) found dead code and features that were more machinery than the app needs: an Electron configuration-preset system with its own settings window, three copies of the about page, a hand-written `.env` parser, an "extended" system prompt nobody selected, two data migrations for formats from 2025-12 and 2026-01, a server-side example cache, type-to-confirm provider deletion, 81 unused dictionary keys, wrapper components around single call sites, and a long tail of unused exports, props, options and tests.

## Change

Every item below was either approved one by one or covered by "fix all of the others".

- **Electron:** the configuration-preset system is gone (settings window, preload, IPC, menu entry, dev watcher); `config-manager.ts` keeps only `userLocale`; `.env` files are parsed with `dotenv`. Entitlements file and a duplicate CI workflow removed; `test.yml` gets a type check and concurrency.
- **Server:** no extended prompt, no example cache, no `ENABLE_HISTORY_XML_REPLACE`, no message metadata or `hasConfig`; the connection test is one shared helper; EdgeOne loses its mock branch and model aliases; `DEBUG_LLM_PAYLOAD` logs message structure instead of payloads; Langfuse scoring helper; `apiKeyEnv` arrays kept and documented in `env.example` and the provider docs.
- **Pages:** one about page per language from a single component (old `/cn` and `/ja` URLs redirect with 308); layout uses `hasLocale`; the GitHub icon is a local component instead of `react-icons`; Cloudflare deploys without an R2 cache by default.
- **Chat and canvas:** the legacy `diagramHistory` conversion, the localStorage chat migration and the templates storage are gone; `chat-engine` reads the diagram through the context's refs; the diagram context has one tagged export path (`requestTaggedExport` / `fetchDiagramXml`); the screenshot check uses fetch + JSON and `generateText` with `Output.object`; `load-diagram` accepts `<svg:svg>` and strips the XML prolog.
- **Settings UI:** `model-selector` inlines its Dialog, Command and Tooltip; `ai-elements/model-selector`, `button-with-tooltip`, `error-toast` removed; no type-to-confirm, no footer, no `bedrockApiKey`; `use-model-config` loses the old-format migration; one reasoning component; the shimmer is CSS (no `motion`, no `@radix-ui/react-use-controllable-state`); 81 dictionary keys removed in all four languages.
- **MCP server:** `registerTool` / `ok` / `fail` replace per-tool try/catch; `syncFromBrowser`, `commitDocument`, `describePages`; the hand-written XML syntax check, unused result fields, `pickPageSelector`, `expandStyles` and the `host` attribute handling are gone; tests get a shared `setup.ts` and drive the server through the SDK client.
- **Prompt:** the web app's style rules come from the MCP drawing guide (`styleRules`), byte-identical to before.
- **Tests:** shared e2e helpers (`openApp`, `sse`, `toolCallEvents`, `chunkedReplies`); placeholder specs dropped; `settings-review-fixes` split by topic; unit tests use `vi.stubEnv`.
- **Dependencies removed:** `@xmldom/xmldom`, `remark-gfm`, `@anthropic-ai/tokenizer`, `@testing-library/user-event`, `concurrently`, `wait-on`, `eslint`, `eslint-config-next`, `@vitest/coverage-v8`, `shx`, `motion`, `@radix-ui/react-use-controllable-state`, `react-icons`.

Source lines: 74,983 before, 67,418 after (excluding lockfiles).

The last commit merges #984 (the canvas shell). Where the shell, the classic page or their tests depend on a wire format or signature this branch had changed, main's version wins (export fields of the poll response, `addHistory` signature); where main only extended code this branch restructured, main's change is carried into the new structure (`start_session`, `get_selection`, `prepareNewDiagram`, the diagram context's `requestExport`).

## Testing

- `tsc --noEmit` for the web app, `packages/mcp-server`, its shell and `electron`: clean
- `biome ci`: 0 errors (37 pre-existing warnings)
- Root `vitest run`: 719 passed; `packages/mcp-server` `vitest run`: 372 passed
- `next build`: clean
- Playwright, whole web suite on the dev server (4 workers): 280 tests, 279 passed; `language.spec.ts` "a stale offer to switch without saving does nothing once a message is being sent" failed under parallel load and passed alone, as it does on main
2026-10-12 12:35:55 +09:00

324 lines
11 KiB
TypeScript

import {
fileVars,
modelOfPage,
pageElement,
parseXml,
} from "@/lib/diagram-pages"
/** What changed on one page between two versions of a diagram */
export interface ChangeSummary {
shapesAdded: number
shapesRemoved: number
shapesChanged: number
edgesAdded: number
edgesRemoved: number
edgesChanged: number
}
export interface DiagramDiff {
summary: ChangeSummary
/** Ids of cells that were added or changed (for highlighting) */
touchedIds: string[]
/** The diagram before had no shapes or connectors */
fromScratch: boolean
}
export const EMPTY_SUMMARY: ChangeSummary = {
shapesAdded: 0,
shapesRemoved: 0,
shapesChanged: 0,
edgesAdded: 0,
edgesRemoved: 0,
edgesChanged: 0,
}
interface CellInfo {
isEdge: boolean
signature: string
}
/**
* A page's mxGraphModel element, whatever wrapper the XML has: the page
* with this id, else the first
*/
function pageModel(xml: string, pageId?: string | null): Element | null {
const doc = parseXml(xml)
if (!doc) return null
const diagram = pageElement(doc, pageId)
if (diagram) return modelOfPage(diagram)
return doc.querySelector("mxGraphModel") ?? doc.documentElement
}
// Values draw.io leaves out when it saves: the model may write them
const DEFAULT_VALUES: Record<string, Record<string, string>> = {
mxCell: {
vertex: "0",
edge: "0",
connectable: "1",
visible: "1",
collapsed: "0",
style: "",
},
mxGeometry: { x: "0", y: "0", width: "0", height: "0", relative: "0" },
mxPoint: { x: "0", y: "0" },
}
// Numbers draw.io writes back in their shortest form ("40.0" is "40"), on
// the elements that hold a position or size (a wrapper's "x" is the
// user's own data, shown as written)
const NUMBERS = new Set(["x", "y", "width", "height"])
const GEOMETRY = new Set(["mxGeometry", "mxPoint", "mxRectangle"])
/**
* A style as draw.io reads it (mxStylesheet.getCellStyle): entries apply in
* order and empty ones do nothing; a leading ";" leaves the default out
*/
function normalStyle(style: string): string {
const entries = style.split(";").filter((entry) => entry.trim() !== "")
return (style.startsWith(";") ? ";" : "") + entries.join(";")
}
/**
* What a cell is, independent of how the XML was written: the same cell
* saved by draw.io and written by the model can order attributes
* differently, and draw.io drops values that are the default. Style
* entries keep their order, which counts.
*/
function signatureOf(node: Element): string {
const defaults = DEFAULT_VALUES[node.tagName] ?? {}
const attrs = Array.from(node.attributes)
.flatMap((a) => {
let value = a.value
// A cell's style (a wrapper's "style" is the user's own data)
if (a.name === "style" && node.tagName === "mxCell") {
value = normalStyle(value)
} else if (
GEOMETRY.has(node.tagName) &&
NUMBERS.has(a.name) &&
value.trim() !== ""
) {
const n = Number(value)
if (Number.isFinite(n)) value = String(n)
}
if (defaults[a.name] === value) return []
return [[a.name, value]]
})
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
// As JSON: a value can hold any text, also "name=value" pairs
return JSON.stringify([
node.tagName,
attrs,
Array.from(node.children).map(signatureOf),
])
}
/** The cells of a page, layers included, with the element that holds each */
function cellsIn(model: Element | null) {
if (!model) return []
return Array.from(model.getElementsByTagName("mxCell")).flatMap((cell) => {
// Cells with links or custom data are wrapped; the wrapper holds the id
const wrapper = cell.parentElement
const isWrapped =
wrapper?.tagName === "object" || wrapper?.tagName === "UserObject"
const id = cell.getAttribute("id") || wrapper?.getAttribute("id")
if (!id) return []
return [{ id, cell, node: isWrapped && wrapper ? wrapper : cell }]
})
}
/** Shapes and connectors of one page (not the root and its layers) */
function collectCells(
xml: string,
pageId?: string | null,
): Map<string, CellInfo> {
const cells = new Map<string, CellInfo>()
for (const { id, cell, node } of cellsIn(pageModel(xml, pageId))) {
if (id === "0" || cell.getAttribute("parent") === "0") continue
cells.set(id, {
isEdge: cell.getAttribute("edge") === "1",
signature: signatureOf(node),
})
}
return cells
}
/** What changed on one page (the first when no id is given) */
export function diffDiagrams(
beforeXml: string,
afterXml: string,
pageId?: string | null,
): DiagramDiff {
const before = collectCells(beforeXml, pageId)
const after = collectCells(afterXml, pageId)
const summary: ChangeSummary = { ...EMPTY_SUMMARY }
const touchedIds: string[] = []
for (const [id, cell] of after) {
const old = before.get(id)
if (!old) {
touchedIds.push(id)
if (cell.isEdge) summary.edgesAdded++
else summary.shapesAdded++
} else if (old.signature !== cell.signature) {
touchedIds.push(id)
if (cell.isEdge) summary.edgesChanged++
else summary.shapesChanged++
}
}
for (const [id, cell] of before) {
if (after.has(id)) continue
if (cell.isEdge) summary.edgesRemoved++
else summary.shapesRemoved++
}
return { summary, touchedIds, fromScratch: before.size === 0 }
}
// Where the view was scrolled to, saved with the page; not a change
const VIEW_ATTRIBUTES = new Set(["dx", "dy"])
// A page setting written on one side only is draw.io's default being
// filled in when the other side has that default; an empty list: any value
// is a change. Settings not listed are not compared then, like the paper
// size: a page without it keeps the canvas's (draw.io's readGraphState).
const PAGE_DEFAULTS: Record<string, string[]> = {
background: ["none"],
backgroundImage: [],
extFonts: [],
math: ["0"],
shadow: ["0"],
page: ["1"],
pageScale: ["1"],
grid: ["1"],
gridSize: ["10"],
guides: ["1"],
tooltips: ["1"],
connect: ["1"],
arrows: ["1"],
fold: ["1"],
}
// Settings draw.io reads as numbers (Editor.readGraphState): "10.0" is 10
const PAGE_NUMBERS = new Set([
"gridSize",
"pageScale",
"pageWidth",
"pageHeight",
])
function samePageSettings(a: Element, b: Element): boolean {
const names = new Set(
[...Array.from(a.attributes), ...Array.from(b.attributes)].map(
(attr) => attr.name,
),
)
const read = (page: Element, name: string) => {
const value = page.getAttribute(name)
if (value === null || !PAGE_NUMBERS.has(name) || !value.trim()) {
return value
}
const n = Number(value)
return Number.isFinite(n) ? String(n) : value
}
for (const name of names) {
if (VIEW_ATTRIBUTES.has(name)) continue
const va = read(a, name)
const vb = read(b, name)
if (va !== null && vb !== null) {
if (va !== vb) return false
continue
}
const defaults = PAGE_DEFAULTS[name]
if (defaults && !defaults.includes((va ?? vb) as string)) return false
}
return true
}
/**
* Every cell of a page, layers included, and the order of each parent's
* children (which shape is in front)
*/
function contentOf(model: Element | null) {
const cells = new Map<string, string>()
const children = new Map<string, string[]>()
for (const { id, cell, node } of cellsIn(model)) {
cells.set(id, signatureOf(node))
const parent = cell.getAttribute("parent") ?? ""
children.set(parent, [...(children.get(parent) ?? []), id])
}
return { cells, children }
}
function sameContent(a: Element | null, b: Element | null): boolean {
const ca = contentOf(a)
const cb = contentOf(b)
if (ca.cells.size !== cb.cells.size) return false
for (const [id, signature] of ca.cells) {
if (cb.cells.get(id) !== signature) return false
}
for (const [parent, ids] of ca.children) {
if (cb.children.get(parent)?.join("\n") !== ids.join("\n")) {
return false
}
}
return true
}
/**
* File variables (%name% placeholders) as draw.io reads them, from JSON:
* the same names and values in any order; none is the same as "{}"
*/
export function sameFileVars(a: string | null, b: string | null): boolean {
if (a === b) return true
try {
// By name: names are unique, and a pair's text can look like another's
const sorted = (vars: string | null) =>
JSON.stringify(
Object.entries(JSON.parse(vars ?? "{}")).sort(([a], [b]) =>
a < b ? -1 : a > b ? 1 : 0,
),
)
return sorted(a) === sorted(b)
} catch {
return false
}
}
/**
* Whether two documents hold the same pages (names, cells, their order),
* page settings and file variables. This is the web app's one rule for "is
* this version on the canvas" (versions, compare, one-step commits). The
* MCP server's edit gate has its own (contentFingerprint): it compares the
* cells as written, and leaves page settings and file variables out. The
* two disagree both ways (a background change is a difference only here;
* a geometry written "40.0" and "40" is one only there), so neither is a
* subset of the other.
*/
export function isSameDocument(a: string, b: string): boolean {
const pagesOf = (doc: Document) => {
const diagrams = Array.from(doc.getElementsByTagName("diagram"))
if (diagrams.length === 0) {
return [{ name: null, model: doc.querySelector("mxGraphModel") }]
}
return diagrams.map((d) => ({
name: d.getAttribute("name"),
model: modelOfPage(d),
}))
}
const docA = parseXml(a)
const docB = parseXml(b)
if (!docA || !docB) return a === b
if (!sameFileVars(fileVars(docA), fileVars(docB))) return false
const pagesA = pagesOf(docA)
const pagesB = pagesOf(docB)
if (pagesA.length !== pagesB.length) return false
return pagesA.every((pageA, i) => {
const pageB = pagesB[i]
if (pageA.name !== null && pageB.name !== null) {
if (pageA.name !== pageB.name) return false
}
if (pageA.model && pageB.model) {
if (!samePageSettings(pageA.model, pageB.model)) return false
}
return sameContent(pageA.model, pageB.model)
})
}