@git-stunts/git-warp 10.1.1
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/LICENSE +201 -0
- package/NOTICE +16 -0
- package/README.md +480 -0
- package/SECURITY.md +30 -0
- package/bin/git-warp +24 -0
- package/bin/warp-graph.js +1574 -0
- package/index.d.ts +2366 -0
- package/index.js +180 -0
- package/package.json +129 -0
- package/scripts/install-git-warp.sh +258 -0
- package/scripts/uninstall-git-warp.sh +139 -0
- package/src/domain/WarpGraph.js +3157 -0
- package/src/domain/crdt/Dot.js +160 -0
- package/src/domain/crdt/LWW.js +154 -0
- package/src/domain/crdt/ORSet.js +371 -0
- package/src/domain/crdt/VersionVector.js +222 -0
- package/src/domain/entities/GraphNode.js +60 -0
- package/src/domain/errors/EmptyMessageError.js +47 -0
- package/src/domain/errors/ForkError.js +30 -0
- package/src/domain/errors/IndexError.js +23 -0
- package/src/domain/errors/OperationAbortedError.js +22 -0
- package/src/domain/errors/QueryError.js +39 -0
- package/src/domain/errors/SchemaUnsupportedError.js +17 -0
- package/src/domain/errors/ShardCorruptionError.js +56 -0
- package/src/domain/errors/ShardLoadError.js +57 -0
- package/src/domain/errors/ShardValidationError.js +61 -0
- package/src/domain/errors/StorageError.js +57 -0
- package/src/domain/errors/SyncError.js +30 -0
- package/src/domain/errors/TraversalError.js +23 -0
- package/src/domain/errors/WarpError.js +31 -0
- package/src/domain/errors/WormholeError.js +28 -0
- package/src/domain/errors/WriterError.js +39 -0
- package/src/domain/errors/index.js +21 -0
- package/src/domain/services/AnchorMessageCodec.js +99 -0
- package/src/domain/services/BitmapIndexBuilder.js +225 -0
- package/src/domain/services/BitmapIndexReader.js +435 -0
- package/src/domain/services/BoundaryTransitionRecord.js +463 -0
- package/src/domain/services/CheckpointMessageCodec.js +147 -0
- package/src/domain/services/CheckpointSerializerV5.js +281 -0
- package/src/domain/services/CheckpointService.js +384 -0
- package/src/domain/services/CommitDagTraversalService.js +156 -0
- package/src/domain/services/DagPathFinding.js +712 -0
- package/src/domain/services/DagTopology.js +239 -0
- package/src/domain/services/DagTraversal.js +245 -0
- package/src/domain/services/Frontier.js +108 -0
- package/src/domain/services/GCMetrics.js +101 -0
- package/src/domain/services/GCPolicy.js +122 -0
- package/src/domain/services/GitLogParser.js +205 -0
- package/src/domain/services/HealthCheckService.js +246 -0
- package/src/domain/services/HookInstaller.js +326 -0
- package/src/domain/services/HttpSyncServer.js +262 -0
- package/src/domain/services/IndexRebuildService.js +426 -0
- package/src/domain/services/IndexStalenessChecker.js +103 -0
- package/src/domain/services/JoinReducer.js +582 -0
- package/src/domain/services/KeyCodec.js +113 -0
- package/src/domain/services/LegacyAnchorDetector.js +67 -0
- package/src/domain/services/LogicalTraversal.js +351 -0
- package/src/domain/services/MessageCodecInternal.js +132 -0
- package/src/domain/services/MessageSchemaDetector.js +145 -0
- package/src/domain/services/MigrationService.js +55 -0
- package/src/domain/services/ObserverView.js +265 -0
- package/src/domain/services/PatchBuilderV2.js +669 -0
- package/src/domain/services/PatchMessageCodec.js +140 -0
- package/src/domain/services/ProvenanceIndex.js +337 -0
- package/src/domain/services/ProvenancePayload.js +242 -0
- package/src/domain/services/QueryBuilder.js +835 -0
- package/src/domain/services/StateDiff.js +300 -0
- package/src/domain/services/StateSerializerV5.js +156 -0
- package/src/domain/services/StreamingBitmapIndexBuilder.js +709 -0
- package/src/domain/services/SyncProtocol.js +593 -0
- package/src/domain/services/TemporalQuery.js +201 -0
- package/src/domain/services/TranslationCost.js +221 -0
- package/src/domain/services/TraversalService.js +8 -0
- package/src/domain/services/WarpMessageCodec.js +29 -0
- package/src/domain/services/WarpStateIndexBuilder.js +127 -0
- package/src/domain/services/WormholeService.js +353 -0
- package/src/domain/types/TickReceipt.js +285 -0
- package/src/domain/types/WarpTypes.js +209 -0
- package/src/domain/types/WarpTypesV2.js +200 -0
- package/src/domain/utils/CachedValue.js +140 -0
- package/src/domain/utils/EventId.js +89 -0
- package/src/domain/utils/LRUCache.js +112 -0
- package/src/domain/utils/MinHeap.js +114 -0
- package/src/domain/utils/RefLayout.js +280 -0
- package/src/domain/utils/WriterId.js +205 -0
- package/src/domain/utils/cancellation.js +33 -0
- package/src/domain/utils/canonicalStringify.js +42 -0
- package/src/domain/utils/defaultClock.js +20 -0
- package/src/domain/utils/defaultCodec.js +51 -0
- package/src/domain/utils/nullLogger.js +21 -0
- package/src/domain/utils/roaring.js +181 -0
- package/src/domain/utils/shardVersion.js +9 -0
- package/src/domain/warp/PatchSession.js +217 -0
- package/src/domain/warp/Writer.js +181 -0
- package/src/hooks/post-merge.sh +60 -0
- package/src/infrastructure/adapters/BunHttpAdapter.js +225 -0
- package/src/infrastructure/adapters/ClockAdapter.js +57 -0
- package/src/infrastructure/adapters/ConsoleLogger.js +150 -0
- package/src/infrastructure/adapters/DenoHttpAdapter.js +230 -0
- package/src/infrastructure/adapters/GitGraphAdapter.js +787 -0
- package/src/infrastructure/adapters/GlobalClockAdapter.js +5 -0
- package/src/infrastructure/adapters/NoOpLogger.js +62 -0
- package/src/infrastructure/adapters/NodeCryptoAdapter.js +32 -0
- package/src/infrastructure/adapters/NodeHttpAdapter.js +98 -0
- package/src/infrastructure/adapters/PerformanceClockAdapter.js +5 -0
- package/src/infrastructure/adapters/WebCryptoAdapter.js +121 -0
- package/src/infrastructure/codecs/CborCodec.js +384 -0
- package/src/ports/BlobPort.js +30 -0
- package/src/ports/ClockPort.js +25 -0
- package/src/ports/CodecPort.js +25 -0
- package/src/ports/CommitPort.js +114 -0
- package/src/ports/ConfigPort.js +31 -0
- package/src/ports/CryptoPort.js +38 -0
- package/src/ports/GraphPersistencePort.js +57 -0
- package/src/ports/HttpServerPort.js +25 -0
- package/src/ports/IndexStoragePort.js +39 -0
- package/src/ports/LoggerPort.js +68 -0
- package/src/ports/RefPort.js +51 -0
- package/src/ports/TreePort.js +51 -0
- package/src/visualization/index.js +26 -0
- package/src/visualization/layouts/converters.js +75 -0
- package/src/visualization/layouts/elkAdapter.js +86 -0
- package/src/visualization/layouts/elkLayout.js +95 -0
- package/src/visualization/layouts/index.js +29 -0
- package/src/visualization/renderers/ascii/box.js +16 -0
- package/src/visualization/renderers/ascii/check.js +271 -0
- package/src/visualization/renderers/ascii/colors.js +13 -0
- package/src/visualization/renderers/ascii/formatters.js +73 -0
- package/src/visualization/renderers/ascii/graph.js +344 -0
- package/src/visualization/renderers/ascii/history.js +335 -0
- package/src/visualization/renderers/ascii/index.js +14 -0
- package/src/visualization/renderers/ascii/info.js +245 -0
- package/src/visualization/renderers/ascii/materialize.js +255 -0
- package/src/visualization/renderers/ascii/path.js +240 -0
- package/src/visualization/renderers/ascii/progress.js +32 -0
- package/src/visualization/renderers/ascii/symbols.js +33 -0
- package/src/visualization/renderers/ascii/table.js +19 -0
- package/src/visualization/renderers/browser/index.js +1 -0
- package/src/visualization/renderers/svg/index.js +159 -0
- package/src/visualization/utils/ansi.js +14 -0
- package/src/visualization/utils/time.js +40 -0
- package/src/visualization/utils/truncate.js +40 -0
- package/src/visualization/utils/unicode.js +52 -0
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Check command ASCII visualization renderer.
|
|
3
|
+
*
|
|
4
|
+
* Renders a visual health dashboard with progress bars, status indicators,
|
|
5
|
+
* and color-coded health status.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import chalk from 'chalk';
|
|
9
|
+
import { createBox } from './box.js';
|
|
10
|
+
import { progressBar } from './progress.js';
|
|
11
|
+
import { colors } from './colors.js';
|
|
12
|
+
import { padRight } from '../../utils/unicode.js';
|
|
13
|
+
import { formatAge } from './formatters.js';
|
|
14
|
+
|
|
15
|
+
// Health thresholds
|
|
16
|
+
const TOMBSTONE_HEALTHY_MAX = 0.15; // < 15% tombstones = healthy
|
|
17
|
+
const TOMBSTONE_WARNING_MAX = 0.30; // < 30% tombstones = warning
|
|
18
|
+
const CACHE_STALE_PENALTY = 20; // Reduce "freshness" score for stale cache
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Get cache freshness percentage and state.
|
|
22
|
+
* @param {Object} status - The status object from check payload
|
|
23
|
+
* @returns {{ percent: number, label: string }}
|
|
24
|
+
*/
|
|
25
|
+
function getCacheFreshness(status) {
|
|
26
|
+
if (!status) {
|
|
27
|
+
return { percent: 0, label: 'none' };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
switch (status.cachedState) {
|
|
31
|
+
case 'fresh':
|
|
32
|
+
return { percent: 100, label: 'fresh' };
|
|
33
|
+
case 'stale':
|
|
34
|
+
return { percent: 100 - CACHE_STALE_PENALTY, label: 'stale' };
|
|
35
|
+
case 'none':
|
|
36
|
+
default:
|
|
37
|
+
return { percent: 0, label: 'none' };
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Get tombstone health status and color.
|
|
43
|
+
* @param {number} ratio - Tombstone ratio (0-1)
|
|
44
|
+
* @returns {{ status: string, color: Function }}
|
|
45
|
+
*/
|
|
46
|
+
function getTombstoneHealth(ratio) {
|
|
47
|
+
if (ratio < TOMBSTONE_HEALTHY_MAX) {
|
|
48
|
+
return { status: 'healthy', color: colors.success };
|
|
49
|
+
}
|
|
50
|
+
if (ratio < TOMBSTONE_WARNING_MAX) {
|
|
51
|
+
return { status: 'warning', color: colors.warning };
|
|
52
|
+
}
|
|
53
|
+
return { status: 'critical', color: colors.error };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Create a custom progress bar with inverted colors for tombstones.
|
|
58
|
+
* Lower is better for tombstones.
|
|
59
|
+
* @param {number} percent - Percentage (0-100)
|
|
60
|
+
* @param {number} width - Bar width
|
|
61
|
+
* @returns {string}
|
|
62
|
+
*/
|
|
63
|
+
function tombstoneBar(percent, width = 20) {
|
|
64
|
+
const clampedPercent = Math.max(0, Math.min(100, percent));
|
|
65
|
+
const filledCount = Math.round((clampedPercent / 100) * width);
|
|
66
|
+
const emptyCount = width - filledCount;
|
|
67
|
+
const bar = '\u2588'.repeat(filledCount) + '\u2591'.repeat(emptyCount);
|
|
68
|
+
|
|
69
|
+
// Invert: lower tombstone ratio is better (green), higher is bad (red)
|
|
70
|
+
if (percent <= TOMBSTONE_HEALTHY_MAX * 100) {
|
|
71
|
+
return chalk.green(bar);
|
|
72
|
+
}
|
|
73
|
+
if (percent <= TOMBSTONE_WARNING_MAX * 100) {
|
|
74
|
+
return chalk.yellow(bar);
|
|
75
|
+
}
|
|
76
|
+
return chalk.red(bar);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Format writer information for display.
|
|
81
|
+
* @param {Object[]} heads - Writer heads array
|
|
82
|
+
* @returns {string}
|
|
83
|
+
*/
|
|
84
|
+
function formatWriters(heads) {
|
|
85
|
+
if (!heads || heads.length === 0) {
|
|
86
|
+
return colors.muted('none');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// For now, just list writer IDs with their SHA prefixes
|
|
90
|
+
return heads
|
|
91
|
+
.map((h) => `${colors.primary(h.writerId ?? 'unknown')} (${colors.muted((h.sha ?? '').slice(0, 7) || '?')})`)
|
|
92
|
+
.join(' | ');
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Format checkpoint status line.
|
|
97
|
+
* @param {Object} checkpoint - Checkpoint info
|
|
98
|
+
* @returns {string}
|
|
99
|
+
*/
|
|
100
|
+
function formatCheckpoint(checkpoint) {
|
|
101
|
+
if (!checkpoint?.sha) {
|
|
102
|
+
return `${colors.warning('none')}`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const sha = colors.muted(checkpoint.sha.slice(0, 7));
|
|
106
|
+
const age = formatAge(checkpoint.ageSeconds);
|
|
107
|
+
|
|
108
|
+
// Add checkmark for recent checkpoints (< 5 min), warning for older
|
|
109
|
+
let status;
|
|
110
|
+
if (checkpoint.ageSeconds !== null && checkpoint.ageSeconds < 300) {
|
|
111
|
+
status = colors.success('\u2713');
|
|
112
|
+
} else if (checkpoint.ageSeconds !== null && checkpoint.ageSeconds < 3600) {
|
|
113
|
+
status = colors.warning('\u2713');
|
|
114
|
+
} else {
|
|
115
|
+
status = colors.muted('\u2713');
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return `${sha} (${age} ago) ${status}`;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Format hook status line.
|
|
123
|
+
* @param {Object|null} hook - Hook status
|
|
124
|
+
* @returns {string}
|
|
125
|
+
*/
|
|
126
|
+
function formatHook(hook) {
|
|
127
|
+
if (!hook) {
|
|
128
|
+
return colors.muted('unknown');
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (!hook.installed && hook.foreign) {
|
|
132
|
+
return `${colors.warning('\u26A0')} foreign hook present`;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (!hook.installed) {
|
|
136
|
+
return `${colors.error('\u2717')} not installed`;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (hook.current) {
|
|
140
|
+
return `${colors.success('\u2713')} installed (v${hook.version})`;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return `${colors.warning('\u2713')} installed (v${hook.version}) \u2014 upgrade available`;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Format coverage status line.
|
|
148
|
+
* @param {Object} coverage - Coverage info
|
|
149
|
+
* @returns {string}
|
|
150
|
+
*/
|
|
151
|
+
function formatCoverage(coverage) {
|
|
152
|
+
if (!coverage?.sha) {
|
|
153
|
+
return colors.muted('none');
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const missing = coverage.missingWriters || [];
|
|
157
|
+
if (missing.length === 0) {
|
|
158
|
+
return `${colors.success('\u2713')} all writers merged`;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const missingList = missing.map((w) => colors.warning(w)).join(', ');
|
|
162
|
+
return `${colors.warning('\u26A0')} missing: ${missingList}`;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Get overall health status with color and symbol.
|
|
167
|
+
* @param {Object} health - Health object
|
|
168
|
+
* @returns {{ text: string, symbol: string, color: Function }}
|
|
169
|
+
*/
|
|
170
|
+
function getOverallHealth(health) {
|
|
171
|
+
if (!health) {
|
|
172
|
+
return { text: 'UNKNOWN', symbol: '?', color: colors.muted };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
switch (health.status) {
|
|
176
|
+
case 'healthy':
|
|
177
|
+
return { text: 'HEALTHY', symbol: '\u2713', color: colors.success };
|
|
178
|
+
case 'degraded':
|
|
179
|
+
return { text: 'DEGRADED', symbol: '\u26A0', color: colors.warning };
|
|
180
|
+
case 'unhealthy':
|
|
181
|
+
return { text: 'UNHEALTHY', symbol: '\u2717', color: colors.error };
|
|
182
|
+
default: {
|
|
183
|
+
const safeStatus = typeof health.status === 'string' && health.status.length
|
|
184
|
+
? health.status
|
|
185
|
+
: 'UNKNOWN';
|
|
186
|
+
return { text: safeStatus.toUpperCase(), symbol: '?', color: colors.muted };
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Build the state section lines (cache, tombstones, patches).
|
|
193
|
+
* @param {Object} status - Status object
|
|
194
|
+
* @param {Object} gc - GC metrics
|
|
195
|
+
* @returns {string[]}
|
|
196
|
+
*/
|
|
197
|
+
function buildStateLines(status, gc) {
|
|
198
|
+
const lines = [];
|
|
199
|
+
const cache = getCacheFreshness(status);
|
|
200
|
+
const cacheBar = progressBar(cache.percent, 20, { showPercent: false });
|
|
201
|
+
lines.push(` ${padRight('Cache:', 12)} ${cacheBar} ${cache.percent}% ${cache.label}`);
|
|
202
|
+
|
|
203
|
+
const tombstoneRatio = status?.tombstoneRatio ?? gc?.tombstoneRatio ?? 0;
|
|
204
|
+
const tombstonePercent = Math.round(tombstoneRatio * 100);
|
|
205
|
+
const tombstoneHealth = getTombstoneHealth(tombstoneRatio);
|
|
206
|
+
const tBar = tombstoneBar(tombstonePercent, 20);
|
|
207
|
+
lines.push(` ${padRight('Tombstones:', 12)} ${tBar} ${tombstonePercent}% (${tombstoneHealth.color(tombstoneHealth.status)})`);
|
|
208
|
+
|
|
209
|
+
if (status?.patchesSinceCheckpoint !== undefined) {
|
|
210
|
+
lines.push(` ${padRight('Patches:', 12)} ${status.patchesSinceCheckpoint} since checkpoint`);
|
|
211
|
+
}
|
|
212
|
+
return lines;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Build the metadata section lines (writers, checkpoint, coverage, hooks).
|
|
217
|
+
* @param {Object} opts - Metadata options
|
|
218
|
+
* @param {Object} opts.writers - Writers info
|
|
219
|
+
* @param {Object} opts.checkpoint - Checkpoint info
|
|
220
|
+
* @param {Object} opts.coverage - Coverage info
|
|
221
|
+
* @param {Object} opts.hook - Hook status
|
|
222
|
+
* @returns {string[]}
|
|
223
|
+
*/
|
|
224
|
+
function buildMetadataLines({ writers, checkpoint, coverage, hook }) {
|
|
225
|
+
return [
|
|
226
|
+
` ${padRight('Writers:', 12)} ${formatWriters(writers?.heads)}`,
|
|
227
|
+
` ${padRight('Checkpoint:', 12)} ${formatCheckpoint(checkpoint)}`,
|
|
228
|
+
` ${padRight('Coverage:', 12)} ${formatCoverage(coverage)}`,
|
|
229
|
+
` ${padRight('Hooks:', 12)} ${formatHook(hook)}`,
|
|
230
|
+
];
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Determine border color based on health status.
|
|
235
|
+
* @param {Object} overall - Overall health info
|
|
236
|
+
* @returns {string}
|
|
237
|
+
*/
|
|
238
|
+
function getBorderColor(overall) {
|
|
239
|
+
if (overall.color === colors.success) {return 'green';}
|
|
240
|
+
if (overall.color === colors.warning) {return 'yellow';}
|
|
241
|
+
return 'red';
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Render the check view dashboard.
|
|
246
|
+
* @param {Object} payload - The check command payload
|
|
247
|
+
* @returns {string} Formatted dashboard string
|
|
248
|
+
*/
|
|
249
|
+
export function renderCheckView(payload) {
|
|
250
|
+
const { graph, health, status, writers, checkpoint, coverage, gc, hook } = payload;
|
|
251
|
+
const overall = getOverallHealth(health);
|
|
252
|
+
|
|
253
|
+
const lines = [
|
|
254
|
+
colors.bold(` GRAPH HEALTH: ${graph}`),
|
|
255
|
+
'',
|
|
256
|
+
...buildStateLines(status, gc),
|
|
257
|
+
'',
|
|
258
|
+
...buildMetadataLines({ writers, checkpoint, coverage, hook }),
|
|
259
|
+
'',
|
|
260
|
+
` Overall: ${overall.color(overall.symbol)} ${overall.color(overall.text)}`,
|
|
261
|
+
];
|
|
262
|
+
|
|
263
|
+
const box = createBox(lines.join('\n'), {
|
|
264
|
+
title: 'HEALTH',
|
|
265
|
+
titleAlignment: 'center',
|
|
266
|
+
borderColor: getBorderColor(overall),
|
|
267
|
+
});
|
|
268
|
+
return `${box}\n`;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export default { renderCheckView };
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared formatting functions for ASCII renderers.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from check.js, materialize.js, and info.js to eliminate
|
|
5
|
+
* duplicate formatting logic across renderers.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { colors } from './colors.js';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Format seconds as human-readable time (e.g., "2m", "1h", "3d").
|
|
12
|
+
* @param {number|null} seconds
|
|
13
|
+
* @returns {string}
|
|
14
|
+
*/
|
|
15
|
+
export function formatAge(seconds) {
|
|
16
|
+
if (seconds === null || seconds === undefined) {
|
|
17
|
+
return 'unknown';
|
|
18
|
+
}
|
|
19
|
+
if (typeof seconds !== 'number' || !Number.isFinite(seconds) || seconds < 0) {
|
|
20
|
+
return 'unknown';
|
|
21
|
+
}
|
|
22
|
+
const secs = Math.floor(seconds);
|
|
23
|
+
if (secs < 60) {
|
|
24
|
+
return `${secs}s`;
|
|
25
|
+
}
|
|
26
|
+
const minutes = Math.floor(secs / 60);
|
|
27
|
+
if (minutes < 60) {
|
|
28
|
+
return `${minutes}m`;
|
|
29
|
+
}
|
|
30
|
+
const hours = Math.floor(minutes / 60);
|
|
31
|
+
if (hours < 24) {
|
|
32
|
+
return `${hours}h`;
|
|
33
|
+
}
|
|
34
|
+
const days = Math.floor(hours / 24);
|
|
35
|
+
return `${days}d`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Format a number with thousands separator for readability.
|
|
40
|
+
* @param {number} n - Number to format
|
|
41
|
+
* @returns {string} Formatted number string
|
|
42
|
+
*/
|
|
43
|
+
export function formatNumber(n) {
|
|
44
|
+
if (typeof n !== 'number' || !Number.isFinite(n)) {
|
|
45
|
+
return '0';
|
|
46
|
+
}
|
|
47
|
+
return n.toLocaleString('en-US');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Format a SHA for display (first 7 characters, muted color).
|
|
52
|
+
* @param {string|null} sha - Full SHA or null
|
|
53
|
+
* @returns {string} Shortened SHA or 'none'
|
|
54
|
+
*/
|
|
55
|
+
export function formatSha(sha) {
|
|
56
|
+
return sha ? colors.muted(sha.slice(0, 7)) : colors.muted('none');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Format a writer name for display, truncating to a max length.
|
|
61
|
+
* @param {string} writerId - Writer ID to format
|
|
62
|
+
* @param {number} [maxLen=16] - Maximum length before truncation
|
|
63
|
+
* @returns {string} Formatted writer display name
|
|
64
|
+
*/
|
|
65
|
+
export function formatWriterName(writerId, maxLen = 16) {
|
|
66
|
+
if (!writerId || typeof writerId !== 'string') {
|
|
67
|
+
return 'unknown';
|
|
68
|
+
}
|
|
69
|
+
if (writerId.length > maxLen) {
|
|
70
|
+
return `${writerId.slice(0, maxLen - 1)}\u2026`;
|
|
71
|
+
}
|
|
72
|
+
return writerId;
|
|
73
|
+
}
|
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ASCII graph renderer: maps ELK-positioned nodes and edges onto a character grid.
|
|
3
|
+
*
|
|
4
|
+
* Pixel-to-character scaling:
|
|
5
|
+
* cellW = 8 px/char, cellH = 4 px/char (approximate monospace aspect ratio)
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { createBox } from './box.js';
|
|
9
|
+
import { colors } from './colors.js';
|
|
10
|
+
import { ARROW } from './symbols.js';
|
|
11
|
+
|
|
12
|
+
// ── Scaling constants ────────────────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
const CELL_W = 8;
|
|
15
|
+
const CELL_H = 4;
|
|
16
|
+
const MARGIN = 2;
|
|
17
|
+
|
|
18
|
+
// ── Box-drawing characters (short keys for tight grid-stamping loops) ───────
|
|
19
|
+
|
|
20
|
+
const BOX = {
|
|
21
|
+
tl: '\u250C', // ┌
|
|
22
|
+
tr: '\u2510', // ┐
|
|
23
|
+
bl: '\u2514', // └
|
|
24
|
+
br: '\u2518', // ┘
|
|
25
|
+
h: '\u2500', // ─
|
|
26
|
+
v: '\u2502', // │
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
// ── Grid helpers ─────────────────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
function toCol(px) {
|
|
32
|
+
return Math.round(px / CELL_W) + MARGIN;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function toRow(px) {
|
|
36
|
+
return Math.round(px / CELL_H) + MARGIN;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function scaleW(px) {
|
|
40
|
+
return Math.round(px / CELL_W);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function scaleH(px) {
|
|
44
|
+
return Math.round(px / CELL_H);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function createGrid(rows, cols) {
|
|
48
|
+
const grid = [];
|
|
49
|
+
for (let r = 0; r < rows; r++) {
|
|
50
|
+
grid.push(new Array(cols).fill(' '));
|
|
51
|
+
}
|
|
52
|
+
return grid;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function writeChar(grid, r, c, ch) {
|
|
56
|
+
if (r >= 0 && r < grid.length && c >= 0 && c < grid[0].length) {
|
|
57
|
+
grid[r][c] = ch;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function readChar(grid, r, c) {
|
|
62
|
+
if (r >= 0 && r < grid.length && c >= 0 && c < grid[0].length) {
|
|
63
|
+
return grid[r][c];
|
|
64
|
+
}
|
|
65
|
+
return ' ';
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function writeString(grid, r, c, str) {
|
|
69
|
+
for (let i = 0; i < str.length; i++) {
|
|
70
|
+
writeChar(grid, r, c + i, str[i]);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ── Node stamping ────────────────────────────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
function stampNode(grid, node) {
|
|
77
|
+
const r = toRow(node.y);
|
|
78
|
+
const c = toCol(node.x);
|
|
79
|
+
const w = Math.max(scaleW(node.width), 4);
|
|
80
|
+
const h = Math.max(scaleH(node.height), 3);
|
|
81
|
+
|
|
82
|
+
// Top border
|
|
83
|
+
writeChar(grid, r, c, BOX.tl);
|
|
84
|
+
for (let i = 1; i < w - 1; i++) {
|
|
85
|
+
writeChar(grid, r, c + i, BOX.h);
|
|
86
|
+
}
|
|
87
|
+
writeChar(grid, r, c + w - 1, BOX.tr);
|
|
88
|
+
|
|
89
|
+
// Side borders
|
|
90
|
+
for (let j = 1; j < h - 1; j++) {
|
|
91
|
+
writeChar(grid, r + j, c, BOX.v);
|
|
92
|
+
writeChar(grid, r + j, c + w - 1, BOX.v);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Bottom border
|
|
96
|
+
writeChar(grid, r + h - 1, c, BOX.bl);
|
|
97
|
+
for (let i = 1; i < w - 1; i++) {
|
|
98
|
+
writeChar(grid, r + h - 1, c + i, BOX.h);
|
|
99
|
+
}
|
|
100
|
+
writeChar(grid, r + h - 1, c + w - 1, BOX.br);
|
|
101
|
+
|
|
102
|
+
// Label (centered)
|
|
103
|
+
const label = node.label ?? node.id;
|
|
104
|
+
const maxLabel = w - 4;
|
|
105
|
+
const truncated = label.length > maxLabel
|
|
106
|
+
? `${label.slice(0, Math.max(maxLabel - 1, 1))}\u2026`
|
|
107
|
+
: label;
|
|
108
|
+
const labelRow = r + Math.floor(h / 2);
|
|
109
|
+
const labelCol = c + Math.max(1, Math.floor((w - truncated.length) / 2));
|
|
110
|
+
writeString(grid, labelRow, labelCol, truncated);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ── Edge tracing ─────────────────────────────────────────────────────────────
|
|
114
|
+
|
|
115
|
+
function traceEdge(grid, edge, nodeSet) {
|
|
116
|
+
const { sections } = edge;
|
|
117
|
+
if (!sections || sections.length === 0) {
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
for (const section of sections) {
|
|
122
|
+
const points = buildPointList(section);
|
|
123
|
+
drawSegments(grid, points, nodeSet);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Arrowhead at the end of the last section
|
|
127
|
+
const lastSection = sections[sections.length - 1];
|
|
128
|
+
const ep = lastSection.endPoint;
|
|
129
|
+
if (ep) {
|
|
130
|
+
drawArrowhead(grid, lastSection, nodeSet);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Edge label at midpoint of the longest section segment
|
|
134
|
+
if (edge.label) {
|
|
135
|
+
placeEdgeLabel(grid, sections, edge.label, nodeSet);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function buildPointList(section) {
|
|
140
|
+
const points = [];
|
|
141
|
+
if (section.startPoint) {
|
|
142
|
+
points.push(section.startPoint);
|
|
143
|
+
}
|
|
144
|
+
if (section.bendPoints) {
|
|
145
|
+
points.push(...section.bendPoints);
|
|
146
|
+
}
|
|
147
|
+
if (section.endPoint) {
|
|
148
|
+
points.push(section.endPoint);
|
|
149
|
+
}
|
|
150
|
+
return points;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function drawSegments(grid, points, nodeSet) {
|
|
154
|
+
for (let i = 0; i < points.length - 1; i++) {
|
|
155
|
+
const r1 = toRow(points[i].y);
|
|
156
|
+
const c1 = toCol(points[i].x);
|
|
157
|
+
const r2 = toRow(points[i + 1].y);
|
|
158
|
+
const c2 = toCol(points[i + 1].x);
|
|
159
|
+
drawLine(grid, r1, c1, r2, c2, nodeSet);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function drawLine(grid, r1, c1, r2, c2, nodeSet) {
|
|
164
|
+
if (r1 === r2) {
|
|
165
|
+
drawHorizontal(grid, r1, c1, c2, nodeSet);
|
|
166
|
+
} else if (c1 === c2) {
|
|
167
|
+
drawVertical(grid, c1, r1, r2, nodeSet);
|
|
168
|
+
} else {
|
|
169
|
+
// Diagonal: draw L-shaped bend (horizontal first, then vertical)
|
|
170
|
+
drawHorizontal(grid, r1, c1, c2, nodeSet);
|
|
171
|
+
drawVertical(grid, c2, r1, r2, nodeSet);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function drawHorizontal(grid, row, c1, c2, nodeSet) {
|
|
176
|
+
const start = Math.min(c1, c2);
|
|
177
|
+
const end = Math.max(c1, c2);
|
|
178
|
+
for (let c = start; c <= end; c++) {
|
|
179
|
+
if (!isNodeCell(nodeSet, row, c)) {
|
|
180
|
+
const existing = readChar(grid, row, c);
|
|
181
|
+
if (existing === BOX.v || existing === '|') {
|
|
182
|
+
writeChar(grid, row, c, '+');
|
|
183
|
+
} else {
|
|
184
|
+
writeChar(grid, row, c, BOX.h);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function drawVertical(grid, col, r1, r2, nodeSet) {
|
|
191
|
+
const start = Math.min(r1, r2);
|
|
192
|
+
const end = Math.max(r1, r2);
|
|
193
|
+
for (let r = start; r <= end; r++) {
|
|
194
|
+
if (!isNodeCell(nodeSet, r, col)) {
|
|
195
|
+
const existing = readChar(grid, r, col);
|
|
196
|
+
if (existing === BOX.h || existing === '-') {
|
|
197
|
+
writeChar(grid, r, col, '+');
|
|
198
|
+
} else {
|
|
199
|
+
writeChar(grid, r, col, BOX.v);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function drawArrowhead(grid, section, nodeSet) {
|
|
206
|
+
const ep = section.endPoint;
|
|
207
|
+
if (!ep) {
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
const er = toRow(ep.y);
|
|
211
|
+
const ec = toCol(ep.x);
|
|
212
|
+
|
|
213
|
+
// Determine direction from last segment
|
|
214
|
+
const bends = section.bendPoints ?? [];
|
|
215
|
+
const prev = bends.length > 0 ? bends[bends.length - 1] : section.startPoint;
|
|
216
|
+
if (!prev) {
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
const pr = toRow(prev.y);
|
|
220
|
+
const pc = toCol(prev.x);
|
|
221
|
+
|
|
222
|
+
let arrow;
|
|
223
|
+
if (er > pr) {
|
|
224
|
+
arrow = ARROW.down;
|
|
225
|
+
} else if (er < pr) {
|
|
226
|
+
arrow = ARROW.up;
|
|
227
|
+
} else if (ec > pc) {
|
|
228
|
+
arrow = ARROW.right;
|
|
229
|
+
} else {
|
|
230
|
+
arrow = ARROW.left;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (!isNodeCell(nodeSet, er, ec)) {
|
|
234
|
+
writeChar(grid, er, ec, arrow);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function placeEdgeLabel(grid, sections, label, nodeSet) {
|
|
239
|
+
// Find midpoint of the full path
|
|
240
|
+
const allPoints = [];
|
|
241
|
+
for (const s of sections) {
|
|
242
|
+
allPoints.push(...buildPointList(s));
|
|
243
|
+
}
|
|
244
|
+
if (allPoints.length < 2) {
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Pick midpoint of the longest segment
|
|
249
|
+
let bestLen = 0;
|
|
250
|
+
let midR = 0;
|
|
251
|
+
let midC = 0;
|
|
252
|
+
for (let i = 0; i < allPoints.length - 1; i++) {
|
|
253
|
+
const r1 = toRow(allPoints[i].y);
|
|
254
|
+
const c1 = toCol(allPoints[i].x);
|
|
255
|
+
const r2 = toRow(allPoints[i + 1].y);
|
|
256
|
+
const c2 = toCol(allPoints[i + 1].x);
|
|
257
|
+
const len = Math.abs(r2 - r1) + Math.abs(c2 - c1);
|
|
258
|
+
if (len > bestLen) {
|
|
259
|
+
bestLen = len;
|
|
260
|
+
midR = Math.floor((r1 + r2) / 2);
|
|
261
|
+
midC = Math.floor((c1 + c2) / 2);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const trunc = label.length > 10
|
|
266
|
+
? `${label.slice(0, 9)}\u2026`
|
|
267
|
+
: label;
|
|
268
|
+
const startC = midC - Math.floor(trunc.length / 2);
|
|
269
|
+
|
|
270
|
+
for (let i = 0; i < trunc.length; i++) {
|
|
271
|
+
const tc = startC + i;
|
|
272
|
+
if (!isNodeCell(nodeSet, midR, tc)) {
|
|
273
|
+
writeChar(grid, midR, tc, trunc[i]);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// ── Node occupancy set ───────────────────────────────────────────────────────
|
|
279
|
+
|
|
280
|
+
function buildNodeSet(nodes) {
|
|
281
|
+
const set = new Set();
|
|
282
|
+
for (const node of nodes) {
|
|
283
|
+
const r = toRow(node.y);
|
|
284
|
+
const c = toCol(node.x);
|
|
285
|
+
const w = Math.max(scaleW(node.width), 4);
|
|
286
|
+
const h = Math.max(scaleH(node.height), 3);
|
|
287
|
+
for (let dr = 0; dr < h; dr++) {
|
|
288
|
+
for (let dc = 0; dc < w; dc++) {
|
|
289
|
+
set.add(`${r + dr},${c + dc}`);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
return set;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function isNodeCell(nodeSet, r, c) {
|
|
297
|
+
return nodeSet.has(`${r},${c}`);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// ── Public API ───────────────────────────────────────────────────────────────
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Renders a PositionedGraph (from ELK) as an ASCII box-drawing string.
|
|
304
|
+
*
|
|
305
|
+
* @param {Object} positionedGraph - PositionedGraph from runLayout()
|
|
306
|
+
* @param {{ title?: string }} [options]
|
|
307
|
+
* @returns {string} Rendered ASCII art wrapped in a box
|
|
308
|
+
*/
|
|
309
|
+
export function renderGraphView(positionedGraph, options = {}) {
|
|
310
|
+
const { nodes = [], edges = [] } = positionedGraph;
|
|
311
|
+
|
|
312
|
+
if (nodes.length === 0) {
|
|
313
|
+
return createBox(colors.muted(' (empty graph)'), {
|
|
314
|
+
title: options.title ?? 'GRAPH',
|
|
315
|
+
titleAlignment: 'center',
|
|
316
|
+
borderColor: 'cyan',
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const totalCols = scaleW(positionedGraph.width) + MARGIN * 2;
|
|
321
|
+
const totalRows = scaleH(positionedGraph.height) + MARGIN * 2;
|
|
322
|
+
|
|
323
|
+
const grid = createGrid(totalRows, totalCols);
|
|
324
|
+
const nodeSet = buildNodeSet(nodes);
|
|
325
|
+
|
|
326
|
+
// Edges first (nodes overwrite on overlap)
|
|
327
|
+
for (const edge of edges) {
|
|
328
|
+
traceEdge(grid, edge, nodeSet);
|
|
329
|
+
}
|
|
330
|
+
for (const node of nodes) {
|
|
331
|
+
stampNode(grid, node);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// Colorize and join
|
|
335
|
+
const raw = grid
|
|
336
|
+
.map((row) => row.join('').replace(/\s+$/, ''))
|
|
337
|
+
.join('\n');
|
|
338
|
+
|
|
339
|
+
return createBox(raw, {
|
|
340
|
+
title: options.title ?? 'GRAPH',
|
|
341
|
+
titleAlignment: 'center',
|
|
342
|
+
borderColor: 'cyan',
|
|
343
|
+
});
|
|
344
|
+
}
|