@cfbender/cesium 0.5.1 → 0.5.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +47 -3
- package/README.md +8 -8
- package/package.json +17 -17
- package/src/cli/commands/serve.ts +1 -2
- package/src/index.ts +4 -1
- package/src/prompt/field-reference.ts +2 -2
- package/src/prompt/system-fragment.md +46 -16
- package/src/render/blocks/catalog.ts +2 -0
- package/src/render/blocks/diff/myers.ts +221 -0
- package/src/render/blocks/diff/parse-unified.ts +101 -0
- package/src/render/blocks/highlight.ts +8 -11
- package/src/render/blocks/markdown.ts +28 -7
- package/src/render/blocks/render.ts +3 -0
- package/src/render/blocks/renderers/code.ts +1 -3
- package/src/render/blocks/renderers/compare-table.ts +3 -4
- package/src/render/blocks/renderers/diagram.ts +2 -5
- package/src/render/blocks/renderers/diff.ts +378 -0
- package/src/render/blocks/renderers/prose.ts +1 -2
- package/src/render/blocks/renderers/timeline.ts +2 -1
- package/src/render/blocks/themes/claret-dark.ts +1 -6
- package/src/render/blocks/themes/claret-light.ts +1 -6
- package/src/render/blocks/types.ts +13 -1
- package/src/render/blocks/validate-block.ts +19 -9
- package/src/render/theme.ts +131 -0
- package/src/render/validate.ts +53 -9
- package/src/server/lifecycle.ts +5 -1
- package/src/storage/index-gen.ts +2 -3
- package/src/tools/publish.ts +1 -3
- package/src/tools/styleguide.ts +3 -7
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,49 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## v0.5.2 — 2026-05-12
|
|
4
|
+
|
|
5
|
+
A 16th block type — `diff` — that renders a beautiful side-by-side
|
|
6
|
+
before/after code diff with curved SVG bezier connectors between the two
|
|
7
|
+
columns. JetBrains diff-viewer aesthetic. Plus tooling improvements for
|
|
8
|
+
hot-reload visual iteration during plugin development.
|
|
9
|
+
|
|
10
|
+
- **feat:** New `diff` block type. Two input arms (XOR):
|
|
11
|
+
- `patch`: literal unified diff string (e.g. from `git diff` output)
|
|
12
|
+
- `before`/`after`: paired text strings; server runs Myers O(ND) line
|
|
13
|
+
diff to compute the change set
|
|
14
|
+
- **feat:** Per-line shiki syntax highlighting is preserved through the
|
|
15
|
+
diff. The renderer recomposes before-side and after-side text, runs each
|
|
16
|
+
through `highlightCode`, then zips the styled line spans back into the
|
|
17
|
+
diff line list — so multi-line constructs (template strings, block
|
|
18
|
+
comments) tokenize correctly.
|
|
19
|
+
- **feat:** Visual rendering:
|
|
20
|
+
- Three-column grid (1fr | 60px | 1fr) with line numbers per side
|
|
21
|
+
- Subtle red/green line-tint backgrounds on remove/add lines
|
|
22
|
+
- 60px-wide SVG connector column draws semi-transparent cubic-bezier
|
|
23
|
+
ribbons connecting each remove region on the left to the corresponding
|
|
24
|
+
add region on the right; pure adds collapse to a teardrop pointing
|
|
25
|
+
into the left, pure removes mirror
|
|
26
|
+
- Optional file-header strip with filename + `+N -M` stats
|
|
27
|
+
- Optional caption strip below
|
|
28
|
+
- 720px breakpoint collapses to single-column with connector hidden
|
|
29
|
+
- **feat:** Theme tokens `--diff-add`, `--diff-remove`, `--diff-change`
|
|
30
|
+
added to all seven palette presets so colors fit each preset's character
|
|
31
|
+
(claret rose, warm clay, cool blue, etc.).
|
|
32
|
+
- **fix:** Diff connector SVG now matches the `padding: 8px 0` of the
|
|
33
|
+
side columns so the bezier paths line up exactly with their target
|
|
34
|
+
regions instead of sitting 8px high.
|
|
35
|
+
- **chore:** Project-local opencode config tracked: `.opencode/opencode.json`,
|
|
36
|
+
`.opencode/plugins/cesium.ts` (dev-loop shim that loads the working
|
|
37
|
+
tree's source instead of the published npm package), and
|
|
38
|
+
`.opencode/skills/cesium-preview/SKILL.md` (hot-reload visual iteration
|
|
39
|
+
workflow that bypasses the stale plugin host by importing render code
|
|
40
|
+
directly via bun and writing to /tmp).
|
|
41
|
+
- **chore:** `scripts/dogfood-diff.ts` reference preview script for the
|
|
42
|
+
diff block. Useful template for previewing other block work.
|
|
43
|
+
- **chore:** Apply oxfmt to 30 files that drifted out of format compliance
|
|
44
|
+
in v0.5.1 (no CI lint gate caught it). Pure cosmetic — line-wrapping,
|
|
45
|
+
italic style normalization, key ordering. No behavior changes.
|
|
46
|
+
|
|
3
47
|
## v0.5.1 — 2026-05-12
|
|
4
48
|
|
|
5
49
|
Server-side syntax highlighting for `code` blocks via shiki, custom claret
|
|
@@ -10,11 +54,11 @@ longer kill the plugin host process.
|
|
|
10
54
|
- **fix (critical):** Lazy-started cesium server now runs as a detached
|
|
11
55
|
subprocess. Previously `ensureRunning` (called from publish/ask plugin
|
|
12
56
|
paths) ran `Bun.serve()` in-process and wrote `pid: process.pid` to the PID
|
|
13
|
-
file — meaning that PID was the
|
|
57
|
+
file — meaning that PID was the _plugin host_ (e.g. opencode). Any
|
|
14
58
|
invocation of `cesium stop` (CLI, tool, or test) would signal the host
|
|
15
59
|
process and kill it. Now lazy-start spawns `bun run cli serve` as a
|
|
16
60
|
detached child; the PID file points at that child. Foreground `cesium
|
|
17
|
-
|
|
61
|
+
serve` still runs in-process (correct for its semantics).
|
|
18
62
|
- **api:** Split `ensureRunning` into `runServerForeground` (in-process, for
|
|
19
63
|
the foreground CLI) and `ensureServerRunning` (detached subprocess, for
|
|
20
64
|
plugins). `ensureRunning` is kept as a backward-compat alias for
|
|
@@ -82,7 +126,7 @@ tokens, more on heavily structured artifacts.
|
|
|
82
126
|
surfaced as a small badge on index cards.
|
|
83
127
|
- **feat:** Framework CSS extended with rules for every block-renderer
|
|
84
128
|
pattern: `dl.kv` (2-column grid), `.pill-row`, `.check-list`, `<hr
|
|
85
|
-
|
|
129
|
+
data-label>`, `figure.code`, timeline-item internals, `.lede`, plus
|
|
86
130
|
`.diagram svg text { fill: currentColor }` so SVGs inherit theme color.
|
|
87
131
|
- **feat:** `escapeHtml` and `escapeAttr` throw a clear error on non-string
|
|
88
132
|
input instead of crashing inside `.replace()`.
|
package/README.md
CHANGED
|
@@ -382,15 +382,15 @@ state directory, hostname, and theme settings flow through.
|
|
|
382
382
|
|
|
383
383
|
Optional `~/.config/opencode/cesium.json`:
|
|
384
384
|
|
|
385
|
-
| Key | Type | Default | Description
|
|
386
|
-
| --------------- | ------ | ----------------------- |
|
|
387
|
-
| `stateDir` | string | `~/.local/state/cesium` | Where artifacts and indexes live
|
|
388
|
-
| `port` | number | `3030` | First port to try for the local HTTP server
|
|
389
|
-
| `portMax` | number | `3050` | Upper bound when scanning for free ports
|
|
390
|
-
| `hostname` | string | `127.0.0.1` | Bind address. Use `0.0.0.0` to expose on the LAN
|
|
385
|
+
| Key | Type | Default | Description |
|
|
386
|
+
| --------------- | ------ | ----------------------- | ------------------------------------------------------------------------------------------- |
|
|
387
|
+
| `stateDir` | string | `~/.local/state/cesium` | Where artifacts and indexes live |
|
|
388
|
+
| `port` | number | `3030` | First port to try for the local HTTP server |
|
|
389
|
+
| `portMax` | number | `3050` | Upper bound when scanning for free ports |
|
|
390
|
+
| `hostname` | string | `127.0.0.1` | Bind address. Use `0.0.0.0` to expose on the LAN |
|
|
391
391
|
| `idleTimeoutMs` | number | `1800000` | Plugin server idle-shutdown threshold (30 min). Does not apply to foreground `cesium serve` |
|
|
392
|
-
| `themePreset` | string | `"claret-dark"` | Named color palette (`claret-dark`/`claret-light`/`claret`/`warm`/`cool`/`mono`/`paper`)
|
|
393
|
-
| `theme` | object | (claret-dark palette) | Per-token color overrides (stacked on preset)
|
|
392
|
+
| `themePreset` | string | `"claret-dark"` | Named color palette (`claret-dark`/`claret-light`/`claret`/`warm`/`cool`/`mono`/`paper`) |
|
|
393
|
+
| `theme` | object | (claret-dark palette) | Per-token color overrides (stacked on preset) |
|
|
394
394
|
|
|
395
395
|
Environment overrides: `CESIUM_PORT`, `CESIUM_STATE_DIR`, `CESIUM_HOSTNAME`, `CESIUM_THEME_PRESET`.
|
|
396
396
|
|
package/package.json
CHANGED
|
@@ -1,29 +1,27 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cfbender/cesium",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.2",
|
|
4
4
|
"description": "Beautiful self-contained HTML artifacts from your opencode agent.",
|
|
5
|
-
"
|
|
6
|
-
|
|
5
|
+
"keywords": [
|
|
6
|
+
"agent",
|
|
7
|
+
"artifact",
|
|
8
|
+
"claret",
|
|
9
|
+
"cli",
|
|
10
|
+
"html",
|
|
11
|
+
"opencode",
|
|
12
|
+
"plugin"
|
|
13
|
+
],
|
|
7
14
|
"homepage": "https://github.com/cfbender/cesium#readme",
|
|
8
15
|
"bugs": "https://github.com/cfbender/cesium/issues",
|
|
16
|
+
"license": "MIT",
|
|
17
|
+
"author": "Cody Bender",
|
|
9
18
|
"repository": {
|
|
10
19
|
"type": "git",
|
|
11
20
|
"url": "git+https://github.com/cfbender/cesium.git"
|
|
12
21
|
},
|
|
13
|
-
"keywords": [
|
|
14
|
-
"opencode",
|
|
15
|
-
"agent",
|
|
16
|
-
"html",
|
|
17
|
-
"artifact",
|
|
18
|
-
"plugin",
|
|
19
|
-
"cli",
|
|
20
|
-
"claret"
|
|
21
|
-
],
|
|
22
22
|
"bin": {
|
|
23
23
|
"cesium": "./src/cli/index.ts"
|
|
24
24
|
},
|
|
25
|
-
"type": "module",
|
|
26
|
-
"main": "src/index.ts",
|
|
27
25
|
"files": [
|
|
28
26
|
"src",
|
|
29
27
|
"assets/styleguide.html",
|
|
@@ -31,12 +29,11 @@
|
|
|
31
29
|
"ARCHITECTURE.md",
|
|
32
30
|
"CHANGELOG.md"
|
|
33
31
|
],
|
|
32
|
+
"type": "module",
|
|
33
|
+
"main": "src/index.ts",
|
|
34
34
|
"publishConfig": {
|
|
35
35
|
"access": "public"
|
|
36
36
|
},
|
|
37
|
-
"engines": {
|
|
38
|
-
"bun": ">=1.0.0"
|
|
39
|
-
},
|
|
40
37
|
"scripts": {
|
|
41
38
|
"test": "bun test",
|
|
42
39
|
"typecheck": "tsc --noEmit",
|
|
@@ -58,5 +55,8 @@
|
|
|
58
55
|
"oxfmt": "^0.48.0",
|
|
59
56
|
"oxlint": "^1.63.0",
|
|
60
57
|
"typescript": "^5.4.0"
|
|
58
|
+
},
|
|
59
|
+
"engines": {
|
|
60
|
+
"bun": ">=1.0.0"
|
|
61
61
|
}
|
|
62
62
|
}
|
|
@@ -41,8 +41,7 @@ function parseDuration(input: string): number | null {
|
|
|
41
41
|
const n = parseFloat(match[1] ?? "");
|
|
42
42
|
if (!isFinite(n) || n < 0) return null;
|
|
43
43
|
const unit = match[2] ?? "ms";
|
|
44
|
-
const mul =
|
|
45
|
-
unit === "ms" ? 1 : unit === "s" ? 1000 : unit === "m" ? 60_000 : /* h */ 3_600_000;
|
|
44
|
+
const mul = unit === "ms" ? 1 : unit === "s" ? 1000 : unit === "m" ? 60_000 : /* h */ 3_600_000;
|
|
46
45
|
return Math.floor(n * mul);
|
|
47
46
|
}
|
|
48
47
|
|
package/src/index.ts
CHANGED
|
@@ -17,7 +17,10 @@ const rawFragment = await readFile(
|
|
|
17
17
|
"utf8",
|
|
18
18
|
);
|
|
19
19
|
|
|
20
|
-
const PROMPT_FRAGMENT = rawFragment.replace(
|
|
20
|
+
const PROMPT_FRAGMENT = rawFragment.replace(
|
|
21
|
+
"{{BLOCK_FIELD_REFERENCE}}",
|
|
22
|
+
generateBlockFieldReference(),
|
|
23
|
+
);
|
|
21
24
|
|
|
22
25
|
export const CesiumPlugin: Plugin = async (ctx): Promise<Hooks> => {
|
|
23
26
|
return {
|
|
@@ -86,8 +86,8 @@ export function generateBlockFieldReference(): string {
|
|
|
86
86
|
lines.push("");
|
|
87
87
|
lines.push(
|
|
88
88
|
"All `markdown` fields support `**bold**`, `*italic*`, `` `code` ``, lists, blockquotes, " +
|
|
89
|
-
|
|
90
|
-
|
|
89
|
+
'and the safelisted inline tags `<kbd>`, `<span class="pill">`, `<span class="tag">`. ' +
|
|
90
|
+
"External URLs in links render as plain text.",
|
|
91
91
|
);
|
|
92
92
|
|
|
93
93
|
return lines.join("\n");
|
|
@@ -20,22 +20,52 @@ You have access to six tools:
|
|
|
20
20
|
### Example
|
|
21
21
|
|
|
22
22
|
```json
|
|
23
|
-
{
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
{ "
|
|
37
|
-
|
|
38
|
-
|
|
23
|
+
{
|
|
24
|
+
"title": "Migration Guide",
|
|
25
|
+
"kind": "plan",
|
|
26
|
+
"blocks": [
|
|
27
|
+
{
|
|
28
|
+
"type": "hero",
|
|
29
|
+
"eyebrow": "v2",
|
|
30
|
+
"title": "Migration Guide",
|
|
31
|
+
"meta": [
|
|
32
|
+
{ "k": "Status", "v": "Draft" },
|
|
33
|
+
{ "k": "Owner", "v": "platform" }
|
|
34
|
+
]
|
|
35
|
+
},
|
|
36
|
+
{ "type": "tldr", "markdown": "**Summary:** Update one import path and bump the SDK." },
|
|
37
|
+
{
|
|
38
|
+
"type": "section",
|
|
39
|
+
"title": "What Changed",
|
|
40
|
+
"children": [
|
|
41
|
+
{ "type": "prose", "markdown": "The `auth` module is now a standalone package." },
|
|
42
|
+
{
|
|
43
|
+
"type": "callout",
|
|
44
|
+
"variant": "warn",
|
|
45
|
+
"markdown": "Change `sdk/auth` imports before upgrading."
|
|
46
|
+
}
|
|
47
|
+
]
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
"type": "risk_table",
|
|
51
|
+
"rows": [
|
|
52
|
+
{
|
|
53
|
+
"risk": "Missed imports",
|
|
54
|
+
"likelihood": "medium",
|
|
55
|
+
"impact": "high",
|
|
56
|
+
"mitigation": "Run codemods."
|
|
57
|
+
}
|
|
58
|
+
]
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
"type": "timeline",
|
|
62
|
+
"items": [
|
|
63
|
+
{ "label": "Phase 1", "text": "Audit existing imports", "date": "2026-06-01" },
|
|
64
|
+
{ "label": "Phase 2", "text": "Run migration script" }
|
|
65
|
+
]
|
|
66
|
+
}
|
|
67
|
+
]
|
|
68
|
+
}
|
|
39
69
|
```
|
|
40
70
|
|
|
41
71
|
## Quick block reference
|
|
@@ -17,6 +17,7 @@ import { meta as pillRowMeta } from "./renderers/pill-row.ts";
|
|
|
17
17
|
import { meta as dividerMeta } from "./renderers/divider.ts";
|
|
18
18
|
import { meta as diagramMeta } from "./renderers/diagram.ts";
|
|
19
19
|
import { meta as rawHtmlMeta } from "./renderers/raw-html.ts";
|
|
20
|
+
import { meta as diffMeta } from "./renderers/diff.ts";
|
|
20
21
|
|
|
21
22
|
export const blockCatalog: Record<Block["type"], BlockMeta> = {
|
|
22
23
|
hero: heroMeta,
|
|
@@ -34,6 +35,7 @@ export const blockCatalog: Record<Block["type"], BlockMeta> = {
|
|
|
34
35
|
divider: dividerMeta,
|
|
35
36
|
diagram: diagramMeta,
|
|
36
37
|
raw_html: rawHtmlMeta,
|
|
38
|
+
diff: diffMeta,
|
|
37
39
|
};
|
|
38
40
|
|
|
39
41
|
export const blockTypes = Object.keys(blockCatalog) as Array<Block["type"]>;
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
// Myers O(ND) line-level diff algorithm.
|
|
2
|
+
// src/render/blocks/diff/myers.ts
|
|
3
|
+
//
|
|
4
|
+
// Re-implemented from scratch — no external dependencies.
|
|
5
|
+
// Reference: "An O(ND) Difference Algorithm and Its Variations" — Eugene W. Myers (1986).
|
|
6
|
+
//
|
|
7
|
+
// Implementation follows the standard "trace + backtrack" pattern from:
|
|
8
|
+
// https://blog.jcoglan.com/2017/02/12/the-myers-diff-algorithm-part-1/
|
|
9
|
+
|
|
10
|
+
import type { DiffLine } from "./parse-unified.ts";
|
|
11
|
+
|
|
12
|
+
export type { DiffLine };
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Split a string into lines, trimming a single trailing empty element when the
|
|
16
|
+
* input ends with "\n" (so clean files don't produce a phantom blank final line).
|
|
17
|
+
*/
|
|
18
|
+
function splitLines(text: string): string[] {
|
|
19
|
+
if (text === "") return [];
|
|
20
|
+
const lines = text.split("\n");
|
|
21
|
+
// Trim trailing phantom empty line produced by a trailing newline
|
|
22
|
+
if (lines.length > 0 && lines[lines.length - 1] === "") {
|
|
23
|
+
lines.pop();
|
|
24
|
+
}
|
|
25
|
+
return lines;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// ─── Myers forward pass ───────────────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Run the Myers forward pass and return the trace (one v[] snapshot per d).
|
|
32
|
+
* v[k + offset] = furthest row reached on diagonal k at this step.
|
|
33
|
+
*/
|
|
34
|
+
function myersTrace(a: string[], b: string[]): number[][] {
|
|
35
|
+
const N = a.length;
|
|
36
|
+
const M = b.length;
|
|
37
|
+
const MAX = N + M;
|
|
38
|
+
const offset = MAX;
|
|
39
|
+
|
|
40
|
+
const v: number[] = Array.from({ length: 2 * MAX + 2 }, () => 0);
|
|
41
|
+
v[offset + 1] = 0;
|
|
42
|
+
|
|
43
|
+
const trace: number[][] = [];
|
|
44
|
+
|
|
45
|
+
for (let d = 0; d <= MAX; d++) {
|
|
46
|
+
for (let k = -d; k <= d; k += 2) {
|
|
47
|
+
const ki = k + offset;
|
|
48
|
+
const vKm1 = v[ki - 1] ?? 0;
|
|
49
|
+
const vKp1 = v[ki + 1] ?? 0;
|
|
50
|
+
|
|
51
|
+
let x: number;
|
|
52
|
+
if (k === -d || (k !== d && vKm1 < vKp1)) {
|
|
53
|
+
x = vKp1; // down: insert
|
|
54
|
+
} else {
|
|
55
|
+
x = vKm1 + 1; // right: delete
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
let y = x - k;
|
|
59
|
+
|
|
60
|
+
// Follow the diagonal (matches)
|
|
61
|
+
while (x < N && y < M && a[x] === b[y]) {
|
|
62
|
+
x++;
|
|
63
|
+
y++;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
v[ki] = x;
|
|
67
|
+
|
|
68
|
+
if (x >= N && y >= M) {
|
|
69
|
+
trace.push(v.slice());
|
|
70
|
+
return trace;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
trace.push(v.slice());
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return trace;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ─── Myers backtrack ──────────────────────────────────────────────────────────
|
|
81
|
+
|
|
82
|
+
type Edit =
|
|
83
|
+
| { op: "keep"; aIdx: number; bIdx: number }
|
|
84
|
+
| { op: "delete"; aIdx: number }
|
|
85
|
+
| { op: "insert"; bIdx: number };
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Backtrack through the trace to produce the edit script.
|
|
89
|
+
* Reconstructs the path from (N, M) back to (0, 0).
|
|
90
|
+
*/
|
|
91
|
+
function backtrack(a: string[], b: string[], trace: number[][]): Edit[] {
|
|
92
|
+
const MAX = a.length + b.length;
|
|
93
|
+
const offset = MAX;
|
|
94
|
+
const edits: Edit[] = [];
|
|
95
|
+
|
|
96
|
+
let x = a.length;
|
|
97
|
+
let y = b.length;
|
|
98
|
+
|
|
99
|
+
// Walk backwards through d steps
|
|
100
|
+
for (let d = trace.length - 1; d > 0; d--) {
|
|
101
|
+
const vPrev = trace[d - 1] ?? [];
|
|
102
|
+
const k = x - y;
|
|
103
|
+
const ki = k + offset;
|
|
104
|
+
|
|
105
|
+
const vPrevKm1 = vPrev[ki - 1] ?? 0;
|
|
106
|
+
const vPrevKp1 = vPrev[ki + 1] ?? 0;
|
|
107
|
+
|
|
108
|
+
// Determine which diagonal we came from
|
|
109
|
+
let prevK: number;
|
|
110
|
+
if (k === -d || (k !== d && vPrevKm1 < vPrevKp1)) {
|
|
111
|
+
prevK = k + 1; // came via down (insert)
|
|
112
|
+
} else {
|
|
113
|
+
prevK = k - 1; // came via right (delete)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const prevX = vPrev[prevK + offset] ?? 0;
|
|
117
|
+
const prevY = prevX - prevK;
|
|
118
|
+
|
|
119
|
+
// Retrace the snake (diagonal matches) from (prevX, prevY) to (x, y)
|
|
120
|
+
// but skip the single edit step itself.
|
|
121
|
+
// After the edit step, we were at:
|
|
122
|
+
// insert: (prevX, prevY + 1)
|
|
123
|
+
// delete: (prevX + 1, prevY)
|
|
124
|
+
if (prevK === k + 1) {
|
|
125
|
+
// insert: moved down, x stays same, y += 1
|
|
126
|
+
const snakeX = prevX;
|
|
127
|
+
// snake goes from (snakeX, prevY+1) to (x, y)
|
|
128
|
+
for (let sx = x - 1; sx >= snakeX; sx--) {
|
|
129
|
+
const sy = sx - k;
|
|
130
|
+
edits.push({ op: "keep", aIdx: sx, bIdx: sy });
|
|
131
|
+
}
|
|
132
|
+
edits.push({ op: "insert", bIdx: prevY });
|
|
133
|
+
} else {
|
|
134
|
+
// delete: moved right, x += 1, y stays same
|
|
135
|
+
const snakeX = prevX + 1;
|
|
136
|
+
// snake goes from (snakeX, prevY) to (x, y)
|
|
137
|
+
for (let sx = x - 1; sx >= snakeX; sx--) {
|
|
138
|
+
const sy = sx - k;
|
|
139
|
+
edits.push({ op: "keep", aIdx: sx, bIdx: sy });
|
|
140
|
+
}
|
|
141
|
+
edits.push({ op: "delete", aIdx: prevX });
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
x = prevX;
|
|
145
|
+
y = prevY;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Remaining snake at d=0: (0,0) to (x,y)
|
|
149
|
+
for (let sx = x - 1; sx >= 0; sx--) {
|
|
150
|
+
const sy = sx; // k=0 at the start
|
|
151
|
+
edits.push({ op: "keep", aIdx: sx, bIdx: sy });
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
edits.reverse();
|
|
155
|
+
return edits;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function myersDiff(a: string[], b: string[]): Edit[] {
|
|
159
|
+
if (a.length === 0 && b.length === 0) return [];
|
|
160
|
+
if (a.length === 0) return b.map((_, i) => ({ op: "insert" as const, bIdx: i }));
|
|
161
|
+
if (b.length === 0) return a.map((_, i) => ({ op: "delete" as const, aIdx: i }));
|
|
162
|
+
|
|
163
|
+
const trace = myersTrace(a, b);
|
|
164
|
+
return backtrack(a, b, trace);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ─── Public API ───────────────────────────────────────────────────────────────
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Compute a line-level diff between two text strings.
|
|
171
|
+
* Returns a flat DiffLine[] with 1-indexed line numbers per side.
|
|
172
|
+
* No hunk-sep entries — single contiguous diff.
|
|
173
|
+
*/
|
|
174
|
+
export function diffLines(before: string, after: string): DiffLine[] {
|
|
175
|
+
const aLines = splitLines(before);
|
|
176
|
+
const bLines = splitLines(after);
|
|
177
|
+
|
|
178
|
+
const edits = myersDiff(aLines, bLines);
|
|
179
|
+
|
|
180
|
+
let beforeLine = 1;
|
|
181
|
+
let afterLine = 1;
|
|
182
|
+
|
|
183
|
+
return edits.map((edit): DiffLine => {
|
|
184
|
+
switch (edit.op) {
|
|
185
|
+
case "keep": {
|
|
186
|
+
const text = aLines[edit.aIdx] ?? "";
|
|
187
|
+
const entry: DiffLine = {
|
|
188
|
+
kind: "context",
|
|
189
|
+
text,
|
|
190
|
+
beforeLineNum: beforeLine,
|
|
191
|
+
afterLineNum: afterLine,
|
|
192
|
+
};
|
|
193
|
+
beforeLine++;
|
|
194
|
+
afterLine++;
|
|
195
|
+
return entry;
|
|
196
|
+
}
|
|
197
|
+
case "delete": {
|
|
198
|
+
const text = aLines[edit.aIdx] ?? "";
|
|
199
|
+
const entry: DiffLine = {
|
|
200
|
+
kind: "remove",
|
|
201
|
+
text,
|
|
202
|
+
beforeLineNum: beforeLine,
|
|
203
|
+
afterLineNum: null,
|
|
204
|
+
};
|
|
205
|
+
beforeLine++;
|
|
206
|
+
return entry;
|
|
207
|
+
}
|
|
208
|
+
case "insert": {
|
|
209
|
+
const text = bLines[edit.bIdx] ?? "";
|
|
210
|
+
const entry: DiffLine = {
|
|
211
|
+
kind: "add",
|
|
212
|
+
text,
|
|
213
|
+
beforeLineNum: null,
|
|
214
|
+
afterLineNum: afterLine,
|
|
215
|
+
};
|
|
216
|
+
afterLine++;
|
|
217
|
+
return entry;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// Unified diff parser — converts a unified patch string to a DiffEntry[].
|
|
2
|
+
// src/render/blocks/diff/parse-unified.ts
|
|
3
|
+
|
|
4
|
+
export type DiffLine = {
|
|
5
|
+
kind: "context" | "add" | "remove";
|
|
6
|
+
text: string; // raw text, no leading +/- prefix
|
|
7
|
+
beforeLineNum: number | null; // 1-indexed line in "before" file, null for adds
|
|
8
|
+
afterLineNum: number | null; // 1-indexed line in "after" file, null for removes
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export type DiffEntry = DiffLine | { kind: "hunk-sep"; oldStart: number; newStart: number };
|
|
12
|
+
|
|
13
|
+
// Matches: @@ -<oldStart>[,<oldLines>] +<newStart>[,<newLines>] @@
|
|
14
|
+
const HUNK_HEADER = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Parse a unified diff patch string into a DiffEntry[].
|
|
18
|
+
* Returns null if no hunk header is found (caller falls back to plaintext).
|
|
19
|
+
*/
|
|
20
|
+
export function parseUnifiedDiff(patch: string): DiffEntry[] | null {
|
|
21
|
+
const lines = patch.split("\n");
|
|
22
|
+
const result: DiffEntry[] = [];
|
|
23
|
+
let foundHunk = false;
|
|
24
|
+
let firstHunk = true;
|
|
25
|
+
|
|
26
|
+
// Counters for current hunk position
|
|
27
|
+
let beforeLine = 0;
|
|
28
|
+
let afterLine = 0;
|
|
29
|
+
|
|
30
|
+
for (const line of lines) {
|
|
31
|
+
// Skip file header lines (--- / +++ at the very top)
|
|
32
|
+
if (line.startsWith("--- ") || line.startsWith("+++ ")) {
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Check for hunk header
|
|
37
|
+
const hunkMatch = HUNK_HEADER.exec(line);
|
|
38
|
+
if (hunkMatch !== null) {
|
|
39
|
+
const oldStart = parseInt(hunkMatch[1] ?? "1", 10);
|
|
40
|
+
const newStart = parseInt(hunkMatch[3] ?? "1", 10);
|
|
41
|
+
|
|
42
|
+
if (!firstHunk) {
|
|
43
|
+
// Emit hunk separator between hunks
|
|
44
|
+
result.push({ kind: "hunk-sep", oldStart, newStart });
|
|
45
|
+
}
|
|
46
|
+
firstHunk = false;
|
|
47
|
+
foundHunk = true;
|
|
48
|
+
|
|
49
|
+
beforeLine = oldStart;
|
|
50
|
+
afterLine = newStart;
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (!foundHunk) {
|
|
55
|
+
// Haven't seen a hunk header yet — skip pre-header lines
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Skip "" markers
|
|
60
|
+
if (line.startsWith("\\")) {
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const prefix = line[0];
|
|
65
|
+
const text = line.slice(1);
|
|
66
|
+
|
|
67
|
+
if (prefix === " ") {
|
|
68
|
+
// Context line — appears on both sides
|
|
69
|
+
result.push({
|
|
70
|
+
kind: "context",
|
|
71
|
+
text,
|
|
72
|
+
beforeLineNum: beforeLine,
|
|
73
|
+
afterLineNum: afterLine,
|
|
74
|
+
});
|
|
75
|
+
beforeLine++;
|
|
76
|
+
afterLine++;
|
|
77
|
+
} else if (prefix === "-") {
|
|
78
|
+
// Removed line — only in "before"
|
|
79
|
+
result.push({
|
|
80
|
+
kind: "remove",
|
|
81
|
+
text,
|
|
82
|
+
beforeLineNum: beforeLine,
|
|
83
|
+
afterLineNum: null,
|
|
84
|
+
});
|
|
85
|
+
beforeLine++;
|
|
86
|
+
} else if (prefix === "+") {
|
|
87
|
+
// Added line — only in "after"
|
|
88
|
+
result.push({
|
|
89
|
+
kind: "add",
|
|
90
|
+
text,
|
|
91
|
+
beforeLineNum: null,
|
|
92
|
+
afterLineNum: afterLine,
|
|
93
|
+
});
|
|
94
|
+
afterLine++;
|
|
95
|
+
}
|
|
96
|
+
// Any other prefix: skip (e.g. empty lines after hunk body)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (!foundHunk) return null;
|
|
100
|
+
return result;
|
|
101
|
+
}
|
|
@@ -13,11 +13,7 @@ import { THEME_PRESETS, isThemePresetName } from "../../render/theme.ts";
|
|
|
13
13
|
|
|
14
14
|
// ─── Highlight theme type ─────────────────────────────────────────────────────
|
|
15
15
|
|
|
16
|
-
export type HighlightTheme =
|
|
17
|
-
| "claret-dark"
|
|
18
|
-
| "claret-light"
|
|
19
|
-
| "vitesse-dark"
|
|
20
|
-
| "vitesse-light";
|
|
16
|
+
export type HighlightTheme = "claret-dark" | "claret-light" | "vitesse-dark" | "vitesse-light";
|
|
21
17
|
|
|
22
18
|
// ─── Supported languages ─────────────────────────────────────────────────────
|
|
23
19
|
|
|
@@ -88,12 +84,13 @@ export function resolveHighlightTheme(cesiumThemeName: string | undefined): High
|
|
|
88
84
|
*/
|
|
89
85
|
function isHexDark(hex: string): boolean {
|
|
90
86
|
const clean = hex.replace("#", "");
|
|
91
|
-
const full =
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
87
|
+
const full =
|
|
88
|
+
clean.length === 3
|
|
89
|
+
? clean
|
|
90
|
+
.split("")
|
|
91
|
+
.map((c) => c + c)
|
|
92
|
+
.join("")
|
|
93
|
+
: clean;
|
|
97
94
|
const r = parseInt(full.slice(0, 2), 16);
|
|
98
95
|
const g = parseInt(full.slice(2, 4), 16);
|
|
99
96
|
const b = parseInt(full.slice(4, 6), 16);
|