Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions app/core/src/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
// a file. Diagrams are capped at 200 KB and the engine refuses ambiguity instead of searching, so a check is fast
// (0.1–0.4 s for large diagrams).

import { compile, parse, resolve, THEMES } from "../../../engine/src/index.js"
import { compile, parse, resolve, SourceError, THEMES } from "../../../engine/src/index.js"

export { parse, resolve }

Expand All @@ -13,8 +13,8 @@ export function check(source: string): string | null {
compile(source)
return null
} catch (e) {
const line = e && typeof (e as { line?: number }).line === "number" && (e as { line: number }).line > 0 ? `line ${(e as { line: number }).line}: ` : ""
return (line + String(e instanceof Error ? e.message : e)).slice(0, 2000)
// the engine's own "line N: …" (SourceError.format), as the app's editor shows it (ui/src/render.ts `refusal`)
return (e instanceof SourceError && e.line > 0 ? e.format() : String(e instanceof Error ? e.message : e)).slice(0, 2000)
}
}

Expand Down
16 changes: 16 additions & 0 deletions app/core/test/engine.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { describe, expect, it } from "vitest"
import { check } from "../src/engine.js"

// check() is what a refused save, the scheduled drafts and Arch Agent's check_diagram report; the app's editor shows the
// same words (ui/src/render.ts `refusal`), so a line number reads the same everywhere (#32)
describe("check", () => {
it("is null when the source renders", () => {
expect(check('node a "A"\nnode b "B" right of a')).toBeNull()
})
it("names the line of the mistake", () => {
expect(check('node a "A"\nnode b "B" rigth of a')).toBe('line 2: node "b": "rigth" is not a direction')
})
it("names the line at fault in a statement written over several lines", () => {
expect(check('node a "A"\nnode b "B"\n style: rounded\n rigth of a')).toBe('line 4: node "b": "rigth" is not a direction')
})
})
13 changes: 13 additions & 0 deletions app/e2e/app.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,19 @@ async function main() {
await page.keyboard.press("Control+End")
await page.keyboard.type('\nnode cache "Menu cache" below db style: store\n')
await page.waitForFunction(() => document.querySelector("[data-card=system]")?.textContent?.includes("Menu cache"))
// a mistake: the status line and the card name its line, the gutter marks that line, Save waits (#32)
await page.keyboard.type('node oops "Oops" rigth of db')
await page.waitForFunction(() => /^line \d+: node "oops": "rigth" is not a direction$/.test(document.querySelector("[data-testid=status]")?.textContent?.trim() ?? ""))
const said = (await page.locator("[data-testid=status]").textContent())!.trim().match(/^line (\d+):/)![1]
const typedOn = String((await ta.inputValue()).split("\n").length)
const marked = (await page.locator("[data-error-line]").count()) ? (await page.locator("[data-error-line]").textContent())?.trim() : "no line"
if (said !== typedOn || marked !== typedOn) throw new Error(`the mistake is on line ${typedOn}; the status says ${said}, the gutter marks ${marked}`)
if (!(await page.locator("[data-card=system]").textContent())?.includes(`line ${said}: node "oops"`)) throw new Error("the card does not name the line")
if (await page.isEnabled("[data-testid=save]")) throw new Error("Save is enabled on a source that does not render")
await page.keyboard.press("Shift+Home")
await page.keyboard.press("Backspace")
await page.waitForFunction(() => document.querySelector("[data-testid=status]")?.textContent?.startsWith("Renders"))
if (await page.locator("[data-error-line]").count()) throw new Error("the gutter still marks a line after the mistake is gone")
await page.keyboard.press("Control+s")
// no git identity: the app asks who commits, then the save goes through
await page.waitForSelector("[data-testid=identity-dialog]")
Expand Down
9 changes: 6 additions & 3 deletions app/ui/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -785,7 +785,7 @@ export default function App() {
✕
</button>
</div>
<Editor value={selSource} onChange={v => (setDrafts(d => ({ ...d, [sel]: v })), current?.sample && tourEvent("source.changed"))} />
<Editor value={selSource} errorLine={selCard?.r.line} onChange={v => (setDrafts(d => ({ ...d, [sel]: v })), current?.sample && tourEvent("source.changed"))} />
<div className={`border-t border-[var(--line)] px-3 py-2 text-xs ${selCard?.r.error ? "text-[var(--danger)]" : "text-[var(--muted)]"}`} data-testid="status">
{selCard?.r.error ?? (current?.source.kind === "github" ? "Renders. ⌘/Ctrl-S saves it as a change waiting for review; the card updates as you type." : "Renders. ⌘/Ctrl-S saves it; the card updates as you type.")}
</div>
Expand Down Expand Up @@ -1010,15 +1010,18 @@ function ProjectTitleDialog({ project, onClose, onSaved }: { project: Project; o
}


function Editor({ value, onChange }: { value: string; onChange: (v: string) => void }) {
/** The source editor: a textarea with line numbers; the line the engine refused (`errorLine`) is marked in the gutter. */
function Editor({ value, errorLine, onChange }: { value: string; errorLine?: number; onChange: (v: string) => void }) {
const ta = useRef<HTMLTextAreaElement>(null)
const gutter = useRef<HTMLDivElement>(null)
const lines = value.split("\n").length
return (
<div className="relative flex min-h-0 flex-1 font-mono text-[13px] leading-[1.6]">
<div ref={gutter} className="ad-gutter overflow-hidden py-3 pr-2 pl-3 text-right" aria-hidden>
{Array.from({ length: lines }, (_, i) => (
<div key={i}>{i + 1}</div>
<div key={i} className={i + 1 === errorLine ? "ad-gutter-error" : undefined} data-error-line={i + 1 === errorLine || undefined}>
{i + 1}
</div>
))}
</div>
<textarea
Expand Down
11 changes: 10 additions & 1 deletion app/ui/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -176,10 +176,19 @@ body {
}
.ad-gutter {
color: var(--muted);
opacity: 0.6;
user-select: none;
min-width: 3em;
}
.ad-gutter > div {
opacity: 0.6;
}
/* the line the engine refused: the status line below says why */
.ad-gutter > .ad-gutter-error {
opacity: 1;
color: var(--danger);
font-weight: 600;
box-shadow: inset -2px 0 0 var(--danger);
}

.ad-toast {
padding: 8px 14px;
Expand Down
33 changes: 32 additions & 1 deletion app/ui/src/render.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,37 @@
// @vitest-environment jsdom
import { describe, expect, it } from "vitest"
import { renderSource, sanitize } from "./render"
import { SourceError } from "@engine"
import { refusal, renderSource, sanitize } from "./render"

describe("renderSource errors", () => {
it("includes the source line when a diagram fails to compile", () => {
const result = renderSource('node a "A"\nnode b "B" rigth of a', false)
expect(result.error).toBe('line 2: node "b": "rigth" is not a direction')
expect(result.line).toBe(2)
expect(result.size).toEqual({ w: 560, h: 120 })
})
it("names the line at fault in a statement written over several lines, not the line it starts on", () => {
const result = renderSource('node a "A"\nnode b "B"\n style: rounded\n rigth of a', false)
expect(result.error).toBe('line 4: node "b": "rigth" is not a direction')
expect(result.line).toBe(4)
})
it("has no error and no line when the source renders", () => {
const result = renderSource('node a "A"', false)
expect(result.error).toBeUndefined()
expect(result.line).toBeUndefined()
})
})

describe("refusal", () => {
it("prints a source error the way the engine's command-line tool does", () => {
expect(refusal(new SourceError('two placements for "server"', 12))).toEqual({ error: 'line 12: two placements for "server"', line: 12 })
})
it("keeps errors without a usable line unchanged", () => {
expect(refusal(new SourceError("cannot compile diagram", 0))).toEqual({ error: "cannot compile diagram" })
expect(refusal(new Error("cannot compile diagram"))).toEqual({ error: "cannot compile diagram" })
expect(refusal("cannot compile diagram")).toEqual({ error: "cannot compile diagram" })
})
})

describe("sanitize", () => {
it("drops the three bypasses the review reproduced", () => {
Expand Down
13 changes: 10 additions & 3 deletions app/ui/src/render.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,26 @@
import DOMPurify from "dompurify"
import { THEMES, compile } from "@engine"
import { SourceError, THEMES, compile } from "@engine"
import { svgSize, type Size } from "./model"

export type Rendered = { svg: string; size: Size; error?: undefined } | { svg?: undefined; size: Size; error: string }
export type Rendered = { svg: string; size: Size; error?: undefined; line?: undefined } | { svg?: undefined; size: Size; error: string; line?: number }

/** Render a source with the engine in a theme. A refusal is returned, not thrown: the card shows it in place. */
export function renderSource(source: string, dark: boolean): Rendered {
try {
const svg = sanitize(compile(source, { theme: dark ? THEMES.dark : THEMES.light }))
return { svg, size: svgSize(svg) }
} catch (e) {
return { error: e instanceof Error ? e.message : String(e), size: { w: 560, h: 120 } }
return { ...refusal(e), size: { w: 560, h: 120 } }
}
}

/** The engine's refusal as the user reads it: "line 2: …" in the engine's own words (`SourceError.format`, as its
* command-line tool prints it), and the line on its own so the editor can mark it. No usable line, no prefix. */
export function refusal(e: unknown): { error: string; line?: number } {
if (e instanceof SourceError && e.line > 0) return { error: e.format(), line: e.line }
return { error: e instanceof Error ? e.message : String(e) }
}

const INTERNAL = /^#\/[a-z0-9][a-z0-9-]{0,62}(\/[a-z0-9][a-z0-9-]{0,62})?$/
const ANCHOR = /^#[A-Za-z][\w.-]*$/
const WEB = /^https?:\/\/[^\s"'<>]+$/i
Expand Down