mellos-mapping 0.20.3 → 0.23.0
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/README.md +130 -69
- package/README.zh-CN.md +126 -65
- package/dist/hook-session-start.mjs +28 -20
- package/dist/mmap.mjs +303 -171
- package/dist/preview.mjs +1457 -0
- package/dist/server.mjs +1984 -741
- package/dist/store-paths.mjs +69 -22
- package/dist/terminal-worker.mjs +3331 -0
- package/dist/watch.mjs +957 -571
- package/dist/web/TERMINAL-LICENSES.txt +70 -0
- package/dist/web/app.css +967 -0
- package/dist/web/app.js +1356 -0
- package/dist/web/index.html +9 -0
- package/dist/web/terminal.css +9 -0
- package/dist/web/terminal.html +7 -0
- package/dist/web/terminal.js +9293 -0
- package/dist/web/xterm.css +285 -0
- package/dist/web.mjs +5646 -0
- package/docs/codex.md +189 -0
- package/docs/map-api.md +148 -0
- package/lib/domain/context.d.ts +11 -0
- package/lib/domain/context.js +27 -0
- package/lib/domain/text.d.ts +9 -0
- package/lib/domain/text.js +54 -0
- package/lib/domain/types.d.ts +3 -0
- package/lib/preview/index.d.ts +3 -0
- package/lib/preview/index.js +3 -0
- package/lib/preview/markdown.d.ts +8 -0
- package/lib/preview/markdown.js +54 -0
- package/lib/preview/presentation.d.ts +6 -0
- package/lib/preview/presentation.js +14 -0
- package/lib/preview/publisher.d.ts +23 -0
- package/lib/preview/publisher.js +143 -0
- package/lib/preview/svg.d.ts +3 -0
- package/lib/preview/svg.js +74 -0
- package/lib/preview/text.d.ts +4 -0
- package/lib/preview/text.js +13 -0
- package/lib/render/canvas.d.ts +1 -1
- package/lib/render/canvas.js +4 -2
- package/lib/render/draw.js +9 -6
- package/lib/render/render.d.ts +6 -0
- package/lib/render/render.js +50 -18
- package/lib/render/width.js +3 -1
- package/lib/store/atomic.d.ts +18 -0
- package/lib/store/atomic.js +86 -0
- package/lib/store/channels.d.ts +40 -0
- package/lib/store/channels.js +135 -0
- package/lib/store/format.js +21 -4
- package/lib/store/json-text.d.ts +9 -0
- package/lib/store/json-text.js +16 -0
- package/lib/store/maps.d.ts +12 -0
- package/lib/store/maps.js +42 -0
- package/lib/store/migration.d.ts +12 -0
- package/lib/store/migration.js +46 -0
- package/lib/store/pages.d.ts +46 -0
- package/lib/store/pages.js +89 -0
- package/lib/store/policy.d.ts +74 -0
- package/lib/store/policy.js +144 -0
- package/lib/store/project.d.ts +2 -0
- package/lib/store/project.js +29 -0
- package/lib/store/store.d.ts +15 -257
- package/lib/store/store.js +16 -695
- package/lib/store/transaction.d.ts +12 -0
- package/lib/store/transaction.js +91 -0
- package/lib/store/viewers.d.ts +81 -0
- package/lib/store/viewers.js +186 -0
- package/package.json +27 -5
- package/scripts/codex-cli.mjs +42 -0
- package/scripts/codex-register.mjs +27 -94
- package/scripts/mmap.mjs +27 -17
- package/scripts/open-pane.mjs +37 -18
- package/scripts/pane-core.mjs +57 -220
- package/scripts/terminal-session.mjs +137 -0
- package/scripts/tmux-session.mjs +90 -0
- package/scripts/watcher-command.mjs +16 -0
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/** Filesystem adapter for derived previews. Never writes back into map JSON. */
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, rmdirSync } from 'node:fs';
|
|
4
|
+
import { dirname, join, resolve } from 'node:path';
|
|
5
|
+
import { EMPTY_MAP, ID_RULE, err, ok } from '../domain/types.js';
|
|
6
|
+
import { describeStoreError, listPageFiles, loadMapFile, pageIdOfFile, writeFileAtomic } from '../store/store.js';
|
|
7
|
+
import { renderMapMarkdown, renderPreviewIndex } from './markdown.js';
|
|
8
|
+
import { documentName } from './presentation.js';
|
|
9
|
+
import { renderMapSvg } from './svg.js';
|
|
10
|
+
export const PREVIEW_DIR_NAME = 'previews';
|
|
11
|
+
const ENABLED = '.enabled';
|
|
12
|
+
const PUBLISH_LOCK = '.publish-lock';
|
|
13
|
+
export function previewDirectory(defaultFile) {
|
|
14
|
+
return join(dirname(defaultFile), PREVIEW_DIR_NAME);
|
|
15
|
+
}
|
|
16
|
+
export function previewFile(defaultFile, page) {
|
|
17
|
+
if (page !== undefined && !ID_RULE.test(page))
|
|
18
|
+
throw new Error('Invalid preview page id.');
|
|
19
|
+
return join(previewDirectory(defaultFile), documentName(page));
|
|
20
|
+
}
|
|
21
|
+
function save(path, contents) {
|
|
22
|
+
try {
|
|
23
|
+
if (readFileSync(path, 'utf8') === contents)
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
catch (error) {
|
|
27
|
+
if (error.code !== 'ENOENT')
|
|
28
|
+
throw error;
|
|
29
|
+
}
|
|
30
|
+
const result = writeFileAtomic(path, contents);
|
|
31
|
+
if (!result.ok)
|
|
32
|
+
throw new Error(describeStoreError(result.error));
|
|
33
|
+
}
|
|
34
|
+
/** Output directories cannot redirect generated writes through a junction. */
|
|
35
|
+
function ownedDirectory(path) {
|
|
36
|
+
mkdirSync(path, { recursive: true });
|
|
37
|
+
const expected = join(realpathSync(dirname(path)), path.slice(dirname(path).length + 1));
|
|
38
|
+
const actual = realpathSync(path);
|
|
39
|
+
if ((process.platform === 'win32' ? actual.toLowerCase() !== expected.toLowerCase() : actual !== expected)) {
|
|
40
|
+
throw new Error(`Preview directory redirects outside its parent: ${path}`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Serialize project-wide projections across processes. Source saves stay
|
|
45
|
+
* independent; the next waiting publisher reads them AFTER taking this lock,
|
|
46
|
+
* so an older project snapshot cannot overwrite a later page's preview.
|
|
47
|
+
* A killed exporter leaves a visible failure, never a silently stale success.
|
|
48
|
+
*/
|
|
49
|
+
function acquireLock(directory) {
|
|
50
|
+
const path = join(directory, PUBLISH_LOCK);
|
|
51
|
+
const deadline = Date.now() + 2_000;
|
|
52
|
+
while (true) {
|
|
53
|
+
try {
|
|
54
|
+
mkdirSync(path);
|
|
55
|
+
return () => rmdirSync(path);
|
|
56
|
+
}
|
|
57
|
+
catch (error) {
|
|
58
|
+
if (error.code !== 'EEXIST')
|
|
59
|
+
throw error;
|
|
60
|
+
if (Date.now() >= deadline)
|
|
61
|
+
throw new Error(`Preview export is busy or was interrupted. Retry; if no exporter is running, remove the stale lock directory: ${path}`);
|
|
62
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 25);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Activation is project-local and survives MCP restarts. Each publisher reads
|
|
68
|
+
* it again, so concurrent clients cooperate without a shared server process.
|
|
69
|
+
* SVG names are content-addressed: a document always points at a complete
|
|
70
|
+
* image and file-preview caches cannot reuse the previous state's picture.
|
|
71
|
+
* Old images stay available to open documents; the preview directory is a
|
|
72
|
+
* disposable cache, never source history.
|
|
73
|
+
*/
|
|
74
|
+
export function createPreviewPublisher(defaultFile) {
|
|
75
|
+
const directory = previewDirectory(defaultFile);
|
|
76
|
+
const enabledFile = join(directory, ENABLED);
|
|
77
|
+
const enabled = () => existsSync(enabledFile);
|
|
78
|
+
const refresh = (page) => {
|
|
79
|
+
try {
|
|
80
|
+
const path = previewFile(defaultFile, page);
|
|
81
|
+
ownedDirectory(directory);
|
|
82
|
+
const release = acquireLock(directory);
|
|
83
|
+
try {
|
|
84
|
+
const pages = [];
|
|
85
|
+
for (const source of listPageFiles(defaultFile)) {
|
|
86
|
+
const slug = pageIdOfFile(defaultFile, source);
|
|
87
|
+
if (slug !== undefined && !ID_RULE.test(slug))
|
|
88
|
+
throw new Error(`Invalid map page filename: ${source}`);
|
|
89
|
+
const loaded = loadMapFile(source);
|
|
90
|
+
if (!loaded.ok)
|
|
91
|
+
throw new Error(describeStoreError(loaded.error));
|
|
92
|
+
pages.push({ page: slug, map: loaded.value });
|
|
93
|
+
}
|
|
94
|
+
// An unopened project can show a standby default map. A misspelled named
|
|
95
|
+
// page must not manufacture a plausible empty diagram.
|
|
96
|
+
if (page !== undefined && !pages.some(p => p.page === page))
|
|
97
|
+
return err(`No map page named "${page}".`);
|
|
98
|
+
if (page === undefined && !pages.some(p => p.page === undefined))
|
|
99
|
+
pages.unshift({ page: undefined, map: EMPTY_MAP });
|
|
100
|
+
const images = join(directory, 'images');
|
|
101
|
+
ownedDirectory(images);
|
|
102
|
+
const present = new Set();
|
|
103
|
+
for (const item of pages) {
|
|
104
|
+
const svg = renderMapSvg(item.map);
|
|
105
|
+
const digest = createHash('sha256').update(svg).digest('hex');
|
|
106
|
+
const image = `images/${digest}.svg`;
|
|
107
|
+
save(join(images, `${digest}.svg`), svg);
|
|
108
|
+
const filename = documentName(item.page);
|
|
109
|
+
save(join(directory, filename), renderMapMarkdown(item.map, image, pages));
|
|
110
|
+
present.add(filename);
|
|
111
|
+
}
|
|
112
|
+
// An already-open deleted page must stop presenting its old state.
|
|
113
|
+
for (const filename of readdirSync(directory)) {
|
|
114
|
+
if (/^(map|page-[a-z0-9][a-z0-9-]{0,63})\.md$/.test(filename) && !present.has(filename)) {
|
|
115
|
+
save(join(directory, filename), '# 地图已删除\n\n此页面已不在项目地图中。\n\n[返回地图目录](index.md)\n');
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
const index = join(directory, 'index.md');
|
|
119
|
+
save(index, renderPreviewIndex(pages));
|
|
120
|
+
return ok({ path: resolve(path), index: resolve(index), pages: pages.length });
|
|
121
|
+
}
|
|
122
|
+
finally {
|
|
123
|
+
release();
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
catch (error) {
|
|
127
|
+
return err(error instanceof Error ? error.message : String(error));
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
const activate = (page) => {
|
|
131
|
+
const published = refresh(page);
|
|
132
|
+
if (!published.ok)
|
|
133
|
+
return published;
|
|
134
|
+
try {
|
|
135
|
+
save(enabledFile, 'Markdown preview updates are enabled for this project.\n');
|
|
136
|
+
}
|
|
137
|
+
catch (error) {
|
|
138
|
+
return err(error instanceof Error ? error.message : String(error));
|
|
139
|
+
}
|
|
140
|
+
return published;
|
|
141
|
+
};
|
|
142
|
+
return { enabled, refresh, activate };
|
|
143
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { flipForSequence, isNeutralKind } from '../semantics/semantics.js';
|
|
2
|
+
import { layoutColumns, layoutRows } from '../render/layout.js';
|
|
3
|
+
import { edgePolyline, routeEdges } from '../render/routing.js';
|
|
4
|
+
import { displayWidth, fitWidth } from '../render/width.js';
|
|
5
|
+
import { zoomGeometry } from '../render/zoom-geometry.js';
|
|
6
|
+
import { isVerified, statusText } from './presentation.js';
|
|
7
|
+
import { xml } from './text.js';
|
|
8
|
+
const X = 8;
|
|
9
|
+
const Y = 26;
|
|
10
|
+
const TOP = 20;
|
|
11
|
+
const PALETTES = {
|
|
12
|
+
planned: ['#f5f7fa', '#98a3b2', '#566477'],
|
|
13
|
+
'in-progress': ['#fff4d9', '#c48c24', '#805910'],
|
|
14
|
+
done: ['#e9f5ee', '#67a883', '#286247'],
|
|
15
|
+
regressed: ['#fdecec', '#cc7575', '#923d3d'],
|
|
16
|
+
unverified: ['#fff6e8', '#b49a77', '#785e3e'],
|
|
17
|
+
neutral: ['#f2f5f9', '#a1adbc', '#364558'],
|
|
18
|
+
};
|
|
19
|
+
export function renderMapSvg(map) {
|
|
20
|
+
const neutral = isNeutralKind(map);
|
|
21
|
+
const originals = new Map(map.nodes.map(n => [n.id, n]));
|
|
22
|
+
const oriented = flipForSequence(map);
|
|
23
|
+
// Reserve enough room for the second status line, cap long labels; full text
|
|
24
|
+
// remains in the document and each SVG node's accessible title.
|
|
25
|
+
const shaped = { ...oriented, nodes: oriented.nodes.map(n => {
|
|
26
|
+
const label = fitWidth(n.label, 32);
|
|
27
|
+
return { ...n, label: label + ' '.repeat(Math.max(0, 20 - displayWidth(label))) };
|
|
28
|
+
}) };
|
|
29
|
+
const geo = { ...zoomGeometry(0), boxGap: 6 };
|
|
30
|
+
const columns = layoutColumns(shaped, geo, true, neutral);
|
|
31
|
+
const routing = routeEdges(shaped, columns);
|
|
32
|
+
const rows = layoutRows(columns, geo, routing.gapRowCount, false, map.lanes.length > 0);
|
|
33
|
+
const width = Math.max(440, (columns.contentWidth + 4 + routing.fallbackCount * 2) * X);
|
|
34
|
+
const height = Math.max(100, TOP + rows.legendY * Y);
|
|
35
|
+
const parts = [
|
|
36
|
+
`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" role="img" aria-labelledby="map-title map-desc">`,
|
|
37
|
+
`<title id="map-title">${xml(map.title ?? '梅勒斯地图')}</title>`,
|
|
38
|
+
`<desc id="map-desc">${map.nodes.length} 个节点,${map.edges.length} 条依赖。完整说明与验证记录在地图文档中。</desc>`,
|
|
39
|
+
'<defs><marker id="arrow" viewBox="0 0 8 8" refX="7" refY="4" markerWidth="6" markerHeight="6" orient="auto-start-reverse"><path d="M0 0 L8 4 L0 8 Z" fill="#8995a5"/></marker></defs>',
|
|
40
|
+
`<rect width="${width}" height="${height}" fill="#ffffff"/>`,
|
|
41
|
+
'<g font-family="Segoe UI, Microsoft YaHei, Noto Sans CJK SC, sans-serif">',
|
|
42
|
+
];
|
|
43
|
+
if (map.nodes.length === 0)
|
|
44
|
+
parts.push('<text x="20" y="52" font-size="14" fill="#657286">尚未声明模块,等待地图更新。</text>');
|
|
45
|
+
columns.bands.forEach((band, i) => {
|
|
46
|
+
const top = TOP + rows.barY[i] * Y;
|
|
47
|
+
const boxes = rows.bandBoxes[i];
|
|
48
|
+
const bottom = boxes.reduce((max, b) => Math.max(max, TOP + (b.y + b.h - 1) * Y), top + 60);
|
|
49
|
+
parts.push(`<rect x="8" y="${top - 10}" width="${width - 16}" height="${bottom - top + 26}" rx="5" fill="#fafbfc"/>`);
|
|
50
|
+
parts.push(`<text x="16" y="${top + 5}" font-size="12" fill="#6a7687">${xml(fitWidth(band.name, Math.floor((width - 40) / X)))}</text>`);
|
|
51
|
+
});
|
|
52
|
+
if (rows.laneHeaderY !== undefined)
|
|
53
|
+
map.lanes.forEach((lane, i) => {
|
|
54
|
+
const region = columns.lanes[i];
|
|
55
|
+
parts.push(`<text x="${(region.x + region.w / 2) * X}" y="${TOP + rows.laneHeaderY * Y + 5}" text-anchor="middle" font-size="12" fill="#566477">${xml(fitWidth(lane.label, region.w))}</text>`);
|
|
56
|
+
});
|
|
57
|
+
for (const edge of routing.edges) {
|
|
58
|
+
const points = edgePolyline(edge, rows).map(([x, y]) => `${x * X},${TOP + y * Y}`).join(' ');
|
|
59
|
+
parts.push(`<polyline points="${points}" fill="none" stroke="#8995a5" stroke-width="1.4" marker-end="url(#arrow)"/>`);
|
|
60
|
+
}
|
|
61
|
+
for (const box of rows.boxOf.values()) {
|
|
62
|
+
const node = originals.get(box.node.id);
|
|
63
|
+
const palette = neutral ? PALETTES.neutral : node.status === 'done' && !isVerified(node) ? PALETTES.unverified : PALETTES[node.status];
|
|
64
|
+
const x = box.x * X, y = TOP + box.y * Y, w = (box.w - 1) * X, h = (box.h - 1) * Y;
|
|
65
|
+
const dash = !neutral && node.status === 'planned' ? ' stroke-dasharray="5 4"' : '';
|
|
66
|
+
parts.push(`<g data-node="${xml(node.id)}"><title>${xml(node.label)}${neutral ? '' : ` · ${xml(statusText(node))}`}</title>`);
|
|
67
|
+
parts.push(`<rect x="${x}" y="${y}" width="${w}" height="${h}" rx="5" fill="${palette[0]}" stroke="${palette[1]}" stroke-width="1.5"${dash}/>`);
|
|
68
|
+
parts.push(`<text x="${x + w / 2}" y="${y + 21}" text-anchor="middle" font-size="14" fill="${palette[2]}">${xml(box.label.trim())}</text>`);
|
|
69
|
+
const secondary = neutral ? node.kind ?? node.id : statusText(node);
|
|
70
|
+
parts.push(`<text x="${x + w / 2}" y="${y + 40}" text-anchor="middle" font-size="11" fill="${palette[2]}">${xml(fitWidth(secondary, box.w - 4))}</text></g>`);
|
|
71
|
+
}
|
|
72
|
+
parts.push('</g></svg>');
|
|
73
|
+
return parts.join('\n');
|
|
74
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** Literal user content stays data in both XML and Markdown output. */
|
|
2
|
+
export function xml(text) {
|
|
3
|
+
return text.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\ufffe\uffff]/g, '')
|
|
4
|
+
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
5
|
+
.replace(/"/g, '"').replace(/'/g, ''');
|
|
6
|
+
}
|
|
7
|
+
export function markdown(text) {
|
|
8
|
+
return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
9
|
+
.replace(/([\\`*_[\]{}()#+.!|~-])/g, '\\$1').replace(/\r?\n/g, ' \n');
|
|
10
|
+
}
|
|
11
|
+
export function cell(text) {
|
|
12
|
+
return markdown(text).replace(/ \n/g, '<br>');
|
|
13
|
+
}
|
package/lib/render/canvas.d.ts
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* produced.
|
|
14
14
|
*/
|
|
15
15
|
import type { RenderOptions, Viewport } from './options.js';
|
|
16
|
-
export type Style = 'none' | 'dim' | 'amber' | 'green' | 'greenDim' | 'red' | 'faint';
|
|
16
|
+
export type Style = 'none' | 'dim' | 'amber' | 'green' | 'greenDim' | 'red' | 'faint' | 'focus';
|
|
17
17
|
/** SGR parameter per style; combined with bold ("1") at emit time. */
|
|
18
18
|
export declare const SGR: Readonly<Record<Style, string>>;
|
|
19
19
|
export declare const ANSI_RESET = "\u001B[0m";
|
package/lib/render/canvas.js
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
* (the ink palette below) and coordinates, and it is the only place ANSI is
|
|
13
13
|
* produced.
|
|
14
14
|
*/
|
|
15
|
+
import { terminalText } from '../domain/text.js';
|
|
15
16
|
import { charWidth } from './width.js';
|
|
16
17
|
/** SGR parameter per style; combined with bold ("1") at emit time. */
|
|
17
18
|
export const SGR = {
|
|
@@ -22,6 +23,7 @@ export const SGR = {
|
|
|
22
23
|
greenDim: '32;2', // done, but nothing behind the claim: green, not fully lit
|
|
23
24
|
red: '31',
|
|
24
25
|
faint: '90',
|
|
26
|
+
focus: '97', // bright foreground without changing font weight
|
|
25
27
|
};
|
|
26
28
|
export const ANSI_RESET = '\x1b[0m';
|
|
27
29
|
// ---------------------------------------------------------------------------
|
|
@@ -91,7 +93,7 @@ export class Canvas {
|
|
|
91
93
|
/** Write literal text starting at (x, y). Returns the column just past it. */
|
|
92
94
|
text(x, y, s, style, bold = false) {
|
|
93
95
|
let cx = x;
|
|
94
|
-
for (const ch of s) {
|
|
96
|
+
for (const ch of terminalText(s)) {
|
|
95
97
|
const w = charWidth(ch.codePointAt(0));
|
|
96
98
|
if (w === 0) {
|
|
97
99
|
// A combining mark, a variation selector or a skin tone takes no
|
|
@@ -162,7 +164,7 @@ export class Canvas {
|
|
|
162
164
|
? ''
|
|
163
165
|
: isWire
|
|
164
166
|
? c.bright
|
|
165
|
-
?
|
|
167
|
+
? SGR.focus // change color only; bold fallback glyphs can visually shift
|
|
166
168
|
: SGR.faint
|
|
167
169
|
: [SGR[c.style], c.bold ? '1' : ''].filter(Boolean).join(';');
|
|
168
170
|
if (opts.color && params !== open) {
|
package/lib/render/draw.js
CHANGED
|
@@ -46,6 +46,9 @@ export function drawBands(canvas, columns, rows, wiredWidth, totalWidth) {
|
|
|
46
46
|
export function drawBox(canvas, box, opts, neutral, face, focused = false) {
|
|
47
47
|
const { node, x, y, w } = box;
|
|
48
48
|
const skin = neutral ? neutralSkin(opts.unicode) : skinFor(face, opts.unicode);
|
|
49
|
+
// Hover must not switch font faces: some terminal fonts draw their bold
|
|
50
|
+
// box/line glyphs at a different offset, making a stationary box jump.
|
|
51
|
+
const borderStyle = focused ? 'focus' : skin.style;
|
|
49
52
|
// Neutral pages give the glyph slot to the node kind (a bullet when kindless).
|
|
50
53
|
const slotGlyph = neutral ? neutralGlyph(node, opts.unicode) : glyphFor(face, opts);
|
|
51
54
|
if (box.borderless) {
|
|
@@ -56,18 +59,18 @@ export function drawBox(canvas, box, opts, neutral, face, focused = false) {
|
|
|
56
59
|
}
|
|
57
60
|
const inner = w - 2;
|
|
58
61
|
const pad = box.pad === 1 ? ' ' : '';
|
|
59
|
-
canvas.text(x, y, skin.corners[0] + skin.h.repeat(inner) + skin.corners[1],
|
|
60
|
-
canvas.text(x, y + 1, skin.v,
|
|
62
|
+
canvas.text(x, y, skin.corners[0] + skin.h.repeat(inner) + skin.corners[1], borderStyle);
|
|
63
|
+
canvas.text(x, y + 1, skin.v, borderStyle);
|
|
61
64
|
canvas.text(x + 1, y + 1, `${pad}${slotGlyph} ${box.label}${pad}`, skin.style, true);
|
|
62
|
-
canvas.text(x + w - 1, y + 1, skin.v,
|
|
65
|
+
canvas.text(x + w - 1, y + 1, skin.v, borderStyle);
|
|
63
66
|
for (let i = 0; i < box.extra.length; i++) {
|
|
64
67
|
const row = box.extra[i];
|
|
65
68
|
const yy = y + 2 + i;
|
|
66
|
-
canvas.text(x, yy, skin.v,
|
|
69
|
+
canvas.text(x, yy, skin.v, borderStyle);
|
|
67
70
|
canvas.text(x + 1, yy, row.text, row.style);
|
|
68
|
-
canvas.text(x + w - 1, yy, skin.v,
|
|
71
|
+
canvas.text(x + w - 1, yy, skin.v, borderStyle);
|
|
69
72
|
}
|
|
70
|
-
canvas.text(x, y + box.h - 1, skin.corners[2] + skin.h.repeat(inner) + skin.corners[3],
|
|
73
|
+
canvas.text(x, y + box.h - 1, skin.corners[2] + skin.h.repeat(inner) + skin.corners[3], borderStyle);
|
|
71
74
|
}
|
|
72
75
|
/** Every wire. Those touching the focused node render bright. */
|
|
73
76
|
export function drawEdges(canvas, edges, rows, opts) {
|
package/lib/render/render.d.ts
CHANGED
|
@@ -86,3 +86,9 @@ export interface WindowedRender {
|
|
|
86
86
|
}
|
|
87
87
|
/** Render only the given viewport of the map, plus the full content extent. */
|
|
88
88
|
export declare function renderMapWindow(map: MellosMap, opts: RenderOptions, viewport: Viewport): WindowedRender;
|
|
89
|
+
/**
|
|
90
|
+
* A pane-owned renderer for immutable map snapshots. Retains only the last
|
|
91
|
+
* scene: map/zoom/glyph changes rebuild geometry; animation, focus and pan
|
|
92
|
+
* only repaint it. There is no global cache or filesystem dependency.
|
|
93
|
+
*/
|
|
94
|
+
export declare function createWindowRenderer(): typeof renderMapWindow;
|
package/lib/render/render.js
CHANGED
|
@@ -68,12 +68,38 @@ export { statusSgr } from './skins.js';
|
|
|
68
68
|
export { ZOOM_DEFAULT, ZOOM_MAX, ZOOM_MIN, clampZoom, isNeutralKind, kindGlyph, spinnerGlyph, statusGlyph, unverifiedDoneGlyph, zoomLabel, } from '../semantics/semantics.js';
|
|
69
69
|
/** Render the whole map as terminal lines. */
|
|
70
70
|
export function renderMap(map, opts) {
|
|
71
|
-
const built =
|
|
71
|
+
const built = paint(prepareScene(map, opts), opts);
|
|
72
72
|
return built.canvas.emit(opts);
|
|
73
73
|
}
|
|
74
74
|
/** Render only the given viewport of the map, plus the full content extent. */
|
|
75
75
|
export function renderMapWindow(map, opts, viewport) {
|
|
76
|
-
|
|
76
|
+
return renderSceneWindow(prepareScene(map, opts), opts, viewport);
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* A pane-owned renderer for immutable map snapshots. Retains only the last
|
|
80
|
+
* scene: map/zoom/glyph changes rebuild geometry; animation, focus and pan
|
|
81
|
+
* only repaint it. There is no global cache or filesystem dependency.
|
|
82
|
+
*/
|
|
83
|
+
export function createWindowRenderer() {
|
|
84
|
+
let previous;
|
|
85
|
+
let frame;
|
|
86
|
+
return (map, opts, viewport) => {
|
|
87
|
+
const zoom = opts.zoom ?? ZOOM_DEFAULT;
|
|
88
|
+
if (previous?.map !== map || previous.unicode !== opts.unicode || previous.zoom !== zoom) {
|
|
89
|
+
// Commit the cache only after preparation succeeds, so failures retry.
|
|
90
|
+
previous = { map, unicode: opts.unicode, zoom, scene: prepareScene(map, opts) };
|
|
91
|
+
}
|
|
92
|
+
const scene = previous.scene;
|
|
93
|
+
if (frame?.scene !== scene || frame.spinner !== opts.spinnerFrame || frame.focus !== opts.focus || frame.color !== opts.color) {
|
|
94
|
+
frame = { scene, spinner: opts.spinnerFrame, focus: opts.focus, color: opts.color, built: paint(scene, opts) };
|
|
95
|
+
}
|
|
96
|
+
return emitWindow(frame.built, opts, viewport);
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
function renderSceneWindow(scene, opts, viewport) {
|
|
100
|
+
return emitWindow(paint(scene, opts), opts, viewport);
|
|
101
|
+
}
|
|
102
|
+
function emitWindow(built, opts, viewport) {
|
|
77
103
|
return {
|
|
78
104
|
lines: built.canvas.emit(opts, viewport),
|
|
79
105
|
contentWidth: built.canvas.width,
|
|
@@ -81,22 +107,20 @@ export function renderMapWindow(map, opts, viewport) {
|
|
|
81
107
|
hits: built.hits,
|
|
82
108
|
};
|
|
83
109
|
}
|
|
84
|
-
|
|
110
|
+
/** Static decisions, independent of spinner frame, focus, color and viewport. */
|
|
111
|
+
function prepareScene(map, opts) {
|
|
85
112
|
const oriented = flipForSequence(map);
|
|
86
113
|
const plainGeo = zoomGeometry(opts.zoom ?? ZOOM_DEFAULT);
|
|
87
114
|
// The far zoom does not shrink the map, it AGGREGATES it: groups become one
|
|
88
115
|
// box each. Which map is drawn is decided here, once.
|
|
89
116
|
const aggregated = plainGeo.mode === 'constellation' ? aggregateMap(oriented) : undefined;
|
|
90
117
|
const drawn = aggregated ?? oriented;
|
|
91
|
-
|
|
118
|
+
const unverified = unverifiedDoneIds(oriented, drawn);
|
|
119
|
+
if (drawn.layers.length === 0)
|
|
120
|
+
return { map: drawn, unverified };
|
|
121
|
+
return { map: drawn, unverified, geometry: prepareGeometry(drawn, opts, aggregated !== undefined ? AGGREGATE_GEO : plainGeo) };
|
|
92
122
|
}
|
|
93
|
-
function
|
|
94
|
-
const canvas = new Canvas();
|
|
95
|
-
if (map.layers.length === 0) {
|
|
96
|
-
canvas.text(0, 0, map.title ?? 'mellos mapping', 'none', true);
|
|
97
|
-
canvas.text(0, 2, '(empty map — declare layers and nodes to begin)', 'dim');
|
|
98
|
-
return { canvas, hits: [] };
|
|
99
|
-
}
|
|
123
|
+
function prepareGeometry(map, opts, geo) {
|
|
100
124
|
const neutral = isNeutralKind(map);
|
|
101
125
|
const columns = layoutColumns(map, geo, opts.unicode, neutral);
|
|
102
126
|
const routing = routeEdges(map, columns);
|
|
@@ -105,6 +129,21 @@ function paint(map, opts, geo, unverified) {
|
|
|
105
129
|
const wiredWidth = routing.fallbackCount > 0 ? columns.contentWidth + 2 + routing.fallbackCount * 2 : columns.contentWidth;
|
|
106
130
|
/** Plus the band labels' own right margin, which nothing else may enter. */
|
|
107
131
|
const totalWidth = wiredWidth + Math.max(...columns.bandLabel.map(displayWidth));
|
|
132
|
+
const hits = [...rows.boxOf.values()].map((b) => ({
|
|
133
|
+
id: b.node.id, x: b.x, y: b.y, w: b.w, h: b.h,
|
|
134
|
+
}));
|
|
135
|
+
return { neutral, columns, routing, rows, wiredWidth, totalWidth, hits };
|
|
136
|
+
}
|
|
137
|
+
/** Dynamic drawing always gets a fresh canvas; frames cannot contaminate each other. */
|
|
138
|
+
function paint(scene, opts) {
|
|
139
|
+
const { map, unverified, geometry } = scene;
|
|
140
|
+
const canvas = new Canvas();
|
|
141
|
+
if (geometry === undefined) {
|
|
142
|
+
canvas.text(0, 0, map.title ?? 'mellos mapping', 'none', true);
|
|
143
|
+
canvas.text(0, 2, '(empty map — declare layers and nodes to begin)', 'dim');
|
|
144
|
+
return { canvas, hits: [] };
|
|
145
|
+
}
|
|
146
|
+
const { neutral, columns, routing, rows, wiredWidth, totalWidth, hits } = geometry;
|
|
108
147
|
if (map.title !== undefined)
|
|
109
148
|
drawTitle(canvas, map.title);
|
|
110
149
|
drawLaneHeaders(canvas, map, columns, rows);
|
|
@@ -117,12 +156,5 @@ function paint(map, opts, geo, unverified) {
|
|
|
117
156
|
}
|
|
118
157
|
drawEdges(canvas, routing.edges, rows, opts);
|
|
119
158
|
drawLegend(canvas, map, opts, rows.legendY, neutral, unverified.size > 0);
|
|
120
|
-
const hits = [...rows.boxOf.values()].map((b) => ({
|
|
121
|
-
id: b.node.id,
|
|
122
|
-
x: b.x,
|
|
123
|
-
y: b.y,
|
|
124
|
-
w: b.w,
|
|
125
|
-
h: b.h,
|
|
126
|
-
}));
|
|
127
159
|
return { canvas, hits };
|
|
128
160
|
}
|
package/lib/render/width.js
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
*
|
|
15
15
|
* Pure functions of a string; no canvas, no options, no I/O.
|
|
16
16
|
*/
|
|
17
|
+
import { terminalText } from '../domain/text.js';
|
|
17
18
|
const WIDE_RANGES = [
|
|
18
19
|
[0x1100, 0x115f], // Hangul Jamo
|
|
19
20
|
// Wide symbols scattered through the BMP — mostly emoji that predate the
|
|
@@ -99,6 +100,7 @@ export function displayWidth(text) {
|
|
|
99
100
|
}
|
|
100
101
|
/** Truncate to a display width, ANSI-free input, appending … when cut. */
|
|
101
102
|
export function fitWidth(s, width) {
|
|
103
|
+
s = terminalText(s);
|
|
102
104
|
if (displayWidth(s) <= width)
|
|
103
105
|
return s;
|
|
104
106
|
let out = '';
|
|
@@ -117,7 +119,7 @@ export function wrapWidth(s, width) {
|
|
|
117
119
|
const lines = [];
|
|
118
120
|
let line = '';
|
|
119
121
|
let w = 0;
|
|
120
|
-
for (const ch of s.replace(/\r/g, '')) {
|
|
122
|
+
for (const ch of terminalText(s.replace(/\r/g, '').replace(/\t/g, ' '), true)) {
|
|
121
123
|
if (ch === '\n') {
|
|
122
124
|
lines.push(line);
|
|
123
125
|
line = '';
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { type Result } from '../domain/types.js';
|
|
2
|
+
import { type StoreError } from './format.js';
|
|
3
|
+
export declare function errnoOf(e: unknown): string;
|
|
4
|
+
/**
|
|
5
|
+
* Write `contents` to `path` atomically (P2).
|
|
6
|
+
*
|
|
7
|
+
* Preconditions: none — the parent directory is created if missing.
|
|
8
|
+
* Postcondition on ok: `path` holds exactly `contents` and no temp file
|
|
9
|
+
* remains. Postcondition on error: `path` is untouched (it keeps its
|
|
10
|
+
* previous content, or stays absent) and no temp file remains.
|
|
11
|
+
*
|
|
12
|
+
* The temp name is private to this call — `<path>.<pid>.<random>.tmp` — so
|
|
13
|
+
* two writers racing on one page cannot install each other's partial content
|
|
14
|
+
* or make each other's rename miss its file.
|
|
15
|
+
*/
|
|
16
|
+
export declare function writeFileAtomic(path: string, contents: string): Result<void, StoreError>;
|
|
17
|
+
/** A heartbeat may yield to the next tick; an authoritative save must retry. */
|
|
18
|
+
export declare function writeAtomic(path: string, contents: string, maxAttempts: number): Result<void, StoreError>;
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { mkdirSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { dirname } from 'node:path';
|
|
3
|
+
import { err, ok } from '../domain/types.js';
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
// atomic writes — the one primitive every save in this module is built on
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
/**
|
|
8
|
+
* How many times a rename is attempted before the save is reported failed.
|
|
9
|
+
* A reader's open handle blocks a rename on Windows for as long as it holds
|
|
10
|
+
* the file; the watcher reads a page in well under a tick, so a handful of
|
|
11
|
+
* attempts spans far more than any legitimate reader needs.
|
|
12
|
+
*/
|
|
13
|
+
const RENAME_MAX_ATTEMPTS = 10;
|
|
14
|
+
/** Backoff granularity: attempt N waits N * this, so ten attempts span ~450ms. */
|
|
15
|
+
const RENAME_BACKOFF_STEP_MS = 10;
|
|
16
|
+
/**
|
|
17
|
+
* errno codes a rename can raise while the target is momentarily unavailable
|
|
18
|
+
* — a reader holding it open (EPERM/EBUSY/EACCES on Windows) or an
|
|
19
|
+
* antivirus/indexer briefly owning it (ENOENT between its own operations).
|
|
20
|
+
* Anything else (ENOSPC, EROFS, ENOTDIR) is a real fault: retrying it only
|
|
21
|
+
* delays the report.
|
|
22
|
+
*/
|
|
23
|
+
const TRANSIENT_RENAME_CODES = new Set(['EPERM', 'EBUSY', 'EACCES', 'ENOENT']);
|
|
24
|
+
/**
|
|
25
|
+
* Block this thread for `ms`. The save path is synchronous by contract (the
|
|
26
|
+
* MCP tool answers after the file is on disk), so the backoff must be too.
|
|
27
|
+
*/
|
|
28
|
+
function sleepSync(ms) {
|
|
29
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
30
|
+
}
|
|
31
|
+
/** The failed write's leftover temp, removed on a best-effort basis. */
|
|
32
|
+
function discardTemp(tmp) {
|
|
33
|
+
try {
|
|
34
|
+
rmSync(tmp, { force: true });
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
// The temp is unreachable for the same reason the write failed; leaving
|
|
38
|
+
// a stray *.tmp is strictly better than masking the original refusal.
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
export function errnoOf(e) {
|
|
42
|
+
return e.code ?? e.message;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Write `contents` to `path` atomically (P2).
|
|
46
|
+
*
|
|
47
|
+
* Preconditions: none — the parent directory is created if missing.
|
|
48
|
+
* Postcondition on ok: `path` holds exactly `contents` and no temp file
|
|
49
|
+
* remains. Postcondition on error: `path` is untouched (it keeps its
|
|
50
|
+
* previous content, or stays absent) and no temp file remains.
|
|
51
|
+
*
|
|
52
|
+
* The temp name is private to this call — `<path>.<pid>.<random>.tmp` — so
|
|
53
|
+
* two writers racing on one page cannot install each other's partial content
|
|
54
|
+
* or make each other's rename miss its file.
|
|
55
|
+
*/
|
|
56
|
+
export function writeFileAtomic(path, contents) {
|
|
57
|
+
return writeAtomic(path, contents, RENAME_MAX_ATTEMPTS);
|
|
58
|
+
}
|
|
59
|
+
/** A heartbeat may yield to the next tick; an authoritative save must retry. */
|
|
60
|
+
export function writeAtomic(path, contents, maxAttempts) {
|
|
61
|
+
const tmp = `${path}.${process.pid}.${Math.random().toString(36).slice(2, 10)}.tmp`;
|
|
62
|
+
try {
|
|
63
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
64
|
+
writeFileSync(tmp, contents, 'utf8');
|
|
65
|
+
}
|
|
66
|
+
catch (e) {
|
|
67
|
+
discardTemp(tmp);
|
|
68
|
+
return err({ kind: 'save-failed', path, detail: `writing the temp file failed: ${errnoOf(e)}` });
|
|
69
|
+
}
|
|
70
|
+
let attempt = 1;
|
|
71
|
+
for (;;) {
|
|
72
|
+
try {
|
|
73
|
+
renameSync(tmp, path);
|
|
74
|
+
return ok(undefined);
|
|
75
|
+
}
|
|
76
|
+
catch (e) {
|
|
77
|
+
const code = errnoOf(e);
|
|
78
|
+
if (!TRANSIENT_RENAME_CODES.has(code) || attempt >= maxAttempts) {
|
|
79
|
+
discardTemp(tmp);
|
|
80
|
+
return err({ kind: 'save-failed', path, detail: `${code} after ${attempt} attempt(s)` });
|
|
81
|
+
}
|
|
82
|
+
sleepSync(attempt * RENAME_BACKOFF_STEP_MS);
|
|
83
|
+
attempt += 1;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { type PageId } from './format.js';
|
|
2
|
+
/** Sibling of the default file carrying a one-shot "show this page" request. */
|
|
3
|
+
export declare const FOCUS_FILE_NAME = "focus";
|
|
4
|
+
export declare function focusFilePath(defaultFile: string, pid?: number): string;
|
|
5
|
+
/** A consumed focus request: the page to show (undefined = the default page). */
|
|
6
|
+
export interface FocusRequest {
|
|
7
|
+
readonly page: PageId | undefined;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Consume a pending focus request: read it, delete the file, return it.
|
|
11
|
+
* Absent file — the overwhelmingly common case — or junk content means no
|
|
12
|
+
* request; the channel is best-effort and junk is swept by the same delete.
|
|
13
|
+
*/
|
|
14
|
+
export declare function takeFocusRequest(defaultFile: string, pid?: number): FocusRequest | undefined;
|
|
15
|
+
/** Sibling of the default file carrying a one-shot "close the pane" request. */
|
|
16
|
+
export declare const QUIT_FILE_NAME = "quit";
|
|
17
|
+
export declare function quitFilePath(defaultFile: string, pid?: number): string;
|
|
18
|
+
/**
|
|
19
|
+
* Consume a pending quit request: read it, delete the file, say whether there
|
|
20
|
+
* was one. Absent file — the overwhelmingly common case — or content that is
|
|
21
|
+
* not a JSON object means NO request; the channel is best-effort and junk is
|
|
22
|
+
* swept by the same delete.
|
|
23
|
+
*
|
|
24
|
+
* The empty JSON object is the whole grammar. It exists so that a stray file
|
|
25
|
+
* of this name — an editor backup, a half-written write from a foreign tool —
|
|
26
|
+
* cannot take a live pane down by accident; a pane closing is the one thing
|
|
27
|
+
* in this channel a user cannot undo by waiting.
|
|
28
|
+
*/
|
|
29
|
+
export declare function takeQuitRequest(defaultFile: string, pid?: number): boolean;
|
|
30
|
+
/**
|
|
31
|
+
* Delete a quit request WITHOUT acting on it — the same file, read as a
|
|
32
|
+
* leftover rather than as a message.
|
|
33
|
+
*
|
|
34
|
+
* A toggle that wrote the request and then lost its watcher (a crash, a
|
|
35
|
+
* closed window, a `taskkill`) leaves the file behind, and the next pane to
|
|
36
|
+
* open would consume it on its first tick and close instantly. The watcher
|
|
37
|
+
* sweeps at STARTUP for exactly that: a request that predates the pane cannot
|
|
38
|
+
* have been addressed to it. Best-effort, like every delete in this channel.
|
|
39
|
+
*/
|
|
40
|
+
export declare function sweepQuitRequest(defaultFile: string, pid?: number): void;
|