@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.
Files changed (143) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +16 -0
  3. package/README.md +480 -0
  4. package/SECURITY.md +30 -0
  5. package/bin/git-warp +24 -0
  6. package/bin/warp-graph.js +1574 -0
  7. package/index.d.ts +2366 -0
  8. package/index.js +180 -0
  9. package/package.json +129 -0
  10. package/scripts/install-git-warp.sh +258 -0
  11. package/scripts/uninstall-git-warp.sh +139 -0
  12. package/src/domain/WarpGraph.js +3157 -0
  13. package/src/domain/crdt/Dot.js +160 -0
  14. package/src/domain/crdt/LWW.js +154 -0
  15. package/src/domain/crdt/ORSet.js +371 -0
  16. package/src/domain/crdt/VersionVector.js +222 -0
  17. package/src/domain/entities/GraphNode.js +60 -0
  18. package/src/domain/errors/EmptyMessageError.js +47 -0
  19. package/src/domain/errors/ForkError.js +30 -0
  20. package/src/domain/errors/IndexError.js +23 -0
  21. package/src/domain/errors/OperationAbortedError.js +22 -0
  22. package/src/domain/errors/QueryError.js +39 -0
  23. package/src/domain/errors/SchemaUnsupportedError.js +17 -0
  24. package/src/domain/errors/ShardCorruptionError.js +56 -0
  25. package/src/domain/errors/ShardLoadError.js +57 -0
  26. package/src/domain/errors/ShardValidationError.js +61 -0
  27. package/src/domain/errors/StorageError.js +57 -0
  28. package/src/domain/errors/SyncError.js +30 -0
  29. package/src/domain/errors/TraversalError.js +23 -0
  30. package/src/domain/errors/WarpError.js +31 -0
  31. package/src/domain/errors/WormholeError.js +28 -0
  32. package/src/domain/errors/WriterError.js +39 -0
  33. package/src/domain/errors/index.js +21 -0
  34. package/src/domain/services/AnchorMessageCodec.js +99 -0
  35. package/src/domain/services/BitmapIndexBuilder.js +225 -0
  36. package/src/domain/services/BitmapIndexReader.js +435 -0
  37. package/src/domain/services/BoundaryTransitionRecord.js +463 -0
  38. package/src/domain/services/CheckpointMessageCodec.js +147 -0
  39. package/src/domain/services/CheckpointSerializerV5.js +281 -0
  40. package/src/domain/services/CheckpointService.js +384 -0
  41. package/src/domain/services/CommitDagTraversalService.js +156 -0
  42. package/src/domain/services/DagPathFinding.js +712 -0
  43. package/src/domain/services/DagTopology.js +239 -0
  44. package/src/domain/services/DagTraversal.js +245 -0
  45. package/src/domain/services/Frontier.js +108 -0
  46. package/src/domain/services/GCMetrics.js +101 -0
  47. package/src/domain/services/GCPolicy.js +122 -0
  48. package/src/domain/services/GitLogParser.js +205 -0
  49. package/src/domain/services/HealthCheckService.js +246 -0
  50. package/src/domain/services/HookInstaller.js +326 -0
  51. package/src/domain/services/HttpSyncServer.js +262 -0
  52. package/src/domain/services/IndexRebuildService.js +426 -0
  53. package/src/domain/services/IndexStalenessChecker.js +103 -0
  54. package/src/domain/services/JoinReducer.js +582 -0
  55. package/src/domain/services/KeyCodec.js +113 -0
  56. package/src/domain/services/LegacyAnchorDetector.js +67 -0
  57. package/src/domain/services/LogicalTraversal.js +351 -0
  58. package/src/domain/services/MessageCodecInternal.js +132 -0
  59. package/src/domain/services/MessageSchemaDetector.js +145 -0
  60. package/src/domain/services/MigrationService.js +55 -0
  61. package/src/domain/services/ObserverView.js +265 -0
  62. package/src/domain/services/PatchBuilderV2.js +669 -0
  63. package/src/domain/services/PatchMessageCodec.js +140 -0
  64. package/src/domain/services/ProvenanceIndex.js +337 -0
  65. package/src/domain/services/ProvenancePayload.js +242 -0
  66. package/src/domain/services/QueryBuilder.js +835 -0
  67. package/src/domain/services/StateDiff.js +300 -0
  68. package/src/domain/services/StateSerializerV5.js +156 -0
  69. package/src/domain/services/StreamingBitmapIndexBuilder.js +709 -0
  70. package/src/domain/services/SyncProtocol.js +593 -0
  71. package/src/domain/services/TemporalQuery.js +201 -0
  72. package/src/domain/services/TranslationCost.js +221 -0
  73. package/src/domain/services/TraversalService.js +8 -0
  74. package/src/domain/services/WarpMessageCodec.js +29 -0
  75. package/src/domain/services/WarpStateIndexBuilder.js +127 -0
  76. package/src/domain/services/WormholeService.js +353 -0
  77. package/src/domain/types/TickReceipt.js +285 -0
  78. package/src/domain/types/WarpTypes.js +209 -0
  79. package/src/domain/types/WarpTypesV2.js +200 -0
  80. package/src/domain/utils/CachedValue.js +140 -0
  81. package/src/domain/utils/EventId.js +89 -0
  82. package/src/domain/utils/LRUCache.js +112 -0
  83. package/src/domain/utils/MinHeap.js +114 -0
  84. package/src/domain/utils/RefLayout.js +280 -0
  85. package/src/domain/utils/WriterId.js +205 -0
  86. package/src/domain/utils/cancellation.js +33 -0
  87. package/src/domain/utils/canonicalStringify.js +42 -0
  88. package/src/domain/utils/defaultClock.js +20 -0
  89. package/src/domain/utils/defaultCodec.js +51 -0
  90. package/src/domain/utils/nullLogger.js +21 -0
  91. package/src/domain/utils/roaring.js +181 -0
  92. package/src/domain/utils/shardVersion.js +9 -0
  93. package/src/domain/warp/PatchSession.js +217 -0
  94. package/src/domain/warp/Writer.js +181 -0
  95. package/src/hooks/post-merge.sh +60 -0
  96. package/src/infrastructure/adapters/BunHttpAdapter.js +225 -0
  97. package/src/infrastructure/adapters/ClockAdapter.js +57 -0
  98. package/src/infrastructure/adapters/ConsoleLogger.js +150 -0
  99. package/src/infrastructure/adapters/DenoHttpAdapter.js +230 -0
  100. package/src/infrastructure/adapters/GitGraphAdapter.js +787 -0
  101. package/src/infrastructure/adapters/GlobalClockAdapter.js +5 -0
  102. package/src/infrastructure/adapters/NoOpLogger.js +62 -0
  103. package/src/infrastructure/adapters/NodeCryptoAdapter.js +32 -0
  104. package/src/infrastructure/adapters/NodeHttpAdapter.js +98 -0
  105. package/src/infrastructure/adapters/PerformanceClockAdapter.js +5 -0
  106. package/src/infrastructure/adapters/WebCryptoAdapter.js +121 -0
  107. package/src/infrastructure/codecs/CborCodec.js +384 -0
  108. package/src/ports/BlobPort.js +30 -0
  109. package/src/ports/ClockPort.js +25 -0
  110. package/src/ports/CodecPort.js +25 -0
  111. package/src/ports/CommitPort.js +114 -0
  112. package/src/ports/ConfigPort.js +31 -0
  113. package/src/ports/CryptoPort.js +38 -0
  114. package/src/ports/GraphPersistencePort.js +57 -0
  115. package/src/ports/HttpServerPort.js +25 -0
  116. package/src/ports/IndexStoragePort.js +39 -0
  117. package/src/ports/LoggerPort.js +68 -0
  118. package/src/ports/RefPort.js +51 -0
  119. package/src/ports/TreePort.js +51 -0
  120. package/src/visualization/index.js +26 -0
  121. package/src/visualization/layouts/converters.js +75 -0
  122. package/src/visualization/layouts/elkAdapter.js +86 -0
  123. package/src/visualization/layouts/elkLayout.js +95 -0
  124. package/src/visualization/layouts/index.js +29 -0
  125. package/src/visualization/renderers/ascii/box.js +16 -0
  126. package/src/visualization/renderers/ascii/check.js +271 -0
  127. package/src/visualization/renderers/ascii/colors.js +13 -0
  128. package/src/visualization/renderers/ascii/formatters.js +73 -0
  129. package/src/visualization/renderers/ascii/graph.js +344 -0
  130. package/src/visualization/renderers/ascii/history.js +335 -0
  131. package/src/visualization/renderers/ascii/index.js +14 -0
  132. package/src/visualization/renderers/ascii/info.js +245 -0
  133. package/src/visualization/renderers/ascii/materialize.js +255 -0
  134. package/src/visualization/renderers/ascii/path.js +240 -0
  135. package/src/visualization/renderers/ascii/progress.js +32 -0
  136. package/src/visualization/renderers/ascii/symbols.js +33 -0
  137. package/src/visualization/renderers/ascii/table.js +19 -0
  138. package/src/visualization/renderers/browser/index.js +1 -0
  139. package/src/visualization/renderers/svg/index.js +159 -0
  140. package/src/visualization/utils/ansi.js +14 -0
  141. package/src/visualization/utils/time.js +40 -0
  142. package/src/visualization/utils/truncate.js +40 -0
  143. package/src/visualization/utils/unicode.js +52 -0
@@ -0,0 +1,335 @@
1
+ /**
2
+ * ASCII renderer for the `history --view` command.
3
+ * Displays a visual timeline of patches for one or more writers.
4
+ */
5
+
6
+ import { colors } from './colors.js';
7
+ import { createBox } from './box.js';
8
+ import { padRight, padLeft } from '../../utils/unicode.js';
9
+ import { truncate } from '../../utils/truncate.js';
10
+ import { TIMELINE } from './symbols.js';
11
+
12
+ // Default pagination settings
13
+ const DEFAULT_PAGE_SIZE = 20;
14
+
15
+ // Operation type to display info mapping
16
+ const OP_DISPLAY = {
17
+ NodeAdd: { symbol: '+', label: 'node', color: colors.success },
18
+ NodeTombstone: { symbol: '-', label: 'node', color: colors.error },
19
+ EdgeAdd: { symbol: '+', label: 'edge', color: colors.success },
20
+ EdgeTombstone: { symbol: '-', label: 'edge', color: colors.error },
21
+ PropSet: { symbol: '~', label: 'prop', color: colors.warning },
22
+ BlobValue: { symbol: '+', label: 'blob', color: colors.primary },
23
+ };
24
+
25
+ // Default empty operation summary
26
+ const EMPTY_OP_SUMMARY = Object.freeze({
27
+ NodeAdd: 0,
28
+ EdgeAdd: 0,
29
+ PropSet: 0,
30
+ NodeTombstone: 0,
31
+ EdgeTombstone: 0,
32
+ BlobValue: 0,
33
+ });
34
+
35
+ /**
36
+ * Summarizes operations in a patch.
37
+ * @param {Object[]} ops - Array of patch operations
38
+ * @returns {Object} Summary with counts by operation type
39
+ */
40
+ function summarizeOps(ops) {
41
+ const summary = { ...EMPTY_OP_SUMMARY };
42
+ for (const op of ops) {
43
+ if (op.type && summary[op.type] !== undefined) {
44
+ summary[op.type]++;
45
+ }
46
+ }
47
+ return summary;
48
+ }
49
+
50
+ /**
51
+ * Formats operation summary as a colored string.
52
+ * @param {Object} summary - Operation counts by type
53
+ * @param {number} maxWidth - Maximum width for the summary string
54
+ * @returns {string} Formatted summary string
55
+ */
56
+ function formatOpSummary(summary, maxWidth = 40) {
57
+ const order = ['NodeAdd', 'EdgeAdd', 'PropSet', 'NodeTombstone', 'EdgeTombstone', 'BlobValue'];
58
+ const parts = order
59
+ .filter((opType) => summary[opType] > 0)
60
+ .map((opType) => {
61
+ const display = OP_DISPLAY[opType];
62
+ return { text: `${display.symbol}${summary[opType]}${display.label}`, color: display.color };
63
+ });
64
+
65
+ if (parts.length === 0) {
66
+ return colors.muted('(empty)');
67
+ }
68
+
69
+ // Truncate plain text first to avoid breaking ANSI escape sequences
70
+ const plain = parts.map((p) => p.text).join(' ');
71
+ const truncated = truncate(plain, maxWidth);
72
+ if (truncated === plain) {
73
+ return parts.map((p) => p.color(p.text)).join(' ');
74
+ }
75
+ return colors.muted(truncated);
76
+ }
77
+
78
+ /**
79
+ * Ensures entry has an opSummary, computing one if needed.
80
+ * @param {Object} entry - Patch entry
81
+ * @returns {Object} Operation summary
82
+ */
83
+ function ensureOpSummary(entry) {
84
+ if (entry.opSummary) {
85
+ return entry.opSummary;
86
+ }
87
+ if (entry.ops) {
88
+ return summarizeOps(entry.ops);
89
+ }
90
+ return { ...EMPTY_OP_SUMMARY };
91
+ }
92
+
93
+ /**
94
+ * Paginates entries, returning display entries and truncation info.
95
+ * @param {Object[]} entries - All entries
96
+ * @param {number} pageSize - Page size
97
+ * @param {boolean} showAll - Whether to show all
98
+ * @returns {{displayEntries: Object[], truncated: boolean, hiddenCount: number}}
99
+ */
100
+ function paginateEntries(entries, pageSize, showAll) {
101
+ if (showAll || entries.length <= pageSize) {
102
+ return { displayEntries: entries, truncated: false, hiddenCount: 0 };
103
+ }
104
+ return {
105
+ displayEntries: entries.slice(-pageSize),
106
+ truncated: true,
107
+ hiddenCount: entries.length - pageSize,
108
+ };
109
+ }
110
+
111
+ /**
112
+ * Renders the truncation indicator at the top of the timeline.
113
+ * @param {boolean} truncated - Whether entries are truncated
114
+ * @param {number} hiddenCount - Number of hidden entries
115
+ * @returns {string[]} Lines to prepend
116
+ */
117
+ function renderTruncationIndicator(truncated, hiddenCount) {
118
+ if (truncated) {
119
+ return [
120
+ colors.muted(` ${TIMELINE.top}${TIMELINE.vertical} ... ${hiddenCount} older patches hidden`),
121
+ ` ${TIMELINE.vertical}`,
122
+ ];
123
+ }
124
+ return [` ${TIMELINE.top}`];
125
+ }
126
+
127
+ /**
128
+ * Renders a single patch entry line.
129
+ * @param {Object} params - Entry parameters
130
+ * @returns {string} Formatted entry line
131
+ */
132
+ function renderEntryLine({ entry, isLast, lamportWidth, writerStr, maxWriterIdLen }) {
133
+ const connector = isLast ? TIMELINE.end : TIMELINE.connector;
134
+ const shortSha = (entry.sha || '').slice(0, 7);
135
+ const lamportStr = padLeft(String(entry.lamport), lamportWidth);
136
+ const opSummary = ensureOpSummary(entry);
137
+ const opSummaryStr = formatOpSummary(opSummary, writerStr ? 30 : 40);
138
+
139
+ if (writerStr) {
140
+ const paddedWriter = padRight(writerStr, maxWriterIdLen);
141
+ return ` ${connector}${TIMELINE.dot} ${colors.muted(`L${lamportStr}`)} ${colors.primary(paddedWriter)}:${colors.muted(shortSha)} ${opSummaryStr}`;
142
+ }
143
+ return ` ${connector}${TIMELINE.dot} ${colors.muted(`L${lamportStr}`)} ${colors.primary(shortSha)} ${opSummaryStr}`;
144
+ }
145
+
146
+ /**
147
+ * Renders single-writer timeline header.
148
+ * @param {string} writer - Writer ID
149
+ * @returns {string[]} Header lines
150
+ */
151
+ function renderSingleWriterHeader(writer) {
152
+ return [colors.bold(` WRITER: ${writer}`), ''];
153
+ }
154
+
155
+ /**
156
+ * Renders single-writer timeline footer.
157
+ * @param {number} totalCount - Total entry count
158
+ * @returns {string[]} Footer lines
159
+ */
160
+ function renderSingleWriterFooter(totalCount) {
161
+ const label = totalCount === 1 ? 'patch' : 'patches';
162
+ return ['', colors.muted(` Total: ${totalCount} ${label}`)];
163
+ }
164
+
165
+ /**
166
+ * Renders single-writer timeline view.
167
+ * @param {Object} payload - History payload
168
+ * @param {Object} options - Rendering options
169
+ * @returns {string[]} Lines for the timeline
170
+ */
171
+ function renderSingleWriterTimeline(payload, options) {
172
+ const { entries, writer } = payload;
173
+ const { pageSize = DEFAULT_PAGE_SIZE, showAll = false } = options;
174
+
175
+ const lines = renderSingleWriterHeader(writer);
176
+
177
+ if (entries.length === 0) {
178
+ lines.push(colors.muted(' (no patches)'));
179
+ return lines;
180
+ }
181
+
182
+ const { displayEntries, truncated, hiddenCount } = paginateEntries(entries, pageSize, showAll);
183
+ if (displayEntries.length === 0) {
184
+ lines.push(colors.muted(' (no patches)'));
185
+ return lines;
186
+ }
187
+ const maxLamport = Math.max(...displayEntries.map((e) => e.lamport));
188
+ const lamportWidth = String(maxLamport).length;
189
+
190
+ lines.push(...renderTruncationIndicator(truncated, hiddenCount));
191
+
192
+ for (let i = 0; i < displayEntries.length; i++) {
193
+ const isLast = i === displayEntries.length - 1;
194
+ lines.push(renderEntryLine({ entry: displayEntries[i], isLast, lamportWidth }));
195
+ }
196
+
197
+ lines.push(...renderSingleWriterFooter(entries.length));
198
+ return lines;
199
+ }
200
+
201
+ /**
202
+ * Merges and sorts entries from all writers by lamport timestamp.
203
+ * @param {Object} writers - Map of writerId to entries
204
+ * @returns {Object[]} Sorted entries with writerId attached
205
+ */
206
+ function mergeWriterEntries(writers) {
207
+ const allEntries = [];
208
+ for (const [writerId, writerEntries] of Object.entries(writers)) {
209
+ for (const entry of writerEntries) {
210
+ allEntries.push({ ...entry, writerId });
211
+ }
212
+ }
213
+ allEntries.sort((a, b) => a.lamport - b.lamport || a.writerId.localeCompare(b.writerId));
214
+ return allEntries;
215
+ }
216
+
217
+ /**
218
+ * Renders multi-writer timeline header.
219
+ * @param {string} graph - Graph name
220
+ * @param {number} writerCount - Number of writers
221
+ * @returns {string[]} Header lines
222
+ */
223
+ function renderMultiWriterHeader(graph, writerCount) {
224
+ return [
225
+ colors.bold(` GRAPH: ${graph}`),
226
+ colors.muted(` Writers: ${writerCount}`),
227
+ '',
228
+ ];
229
+ }
230
+
231
+ /**
232
+ * Renders multi-writer timeline footer.
233
+ * @param {number} totalCount - Total entry count
234
+ * @param {number} writerCount - Number of writers
235
+ * @returns {string[]} Footer lines
236
+ */
237
+ function renderMultiWriterFooter(totalCount, writerCount) {
238
+ const label = totalCount === 1 ? 'patch' : 'patches';
239
+ return ['', colors.muted(` Total: ${totalCount} ${label} across ${writerCount} writers`)];
240
+ }
241
+
242
+ /**
243
+ * Renders multi-writer timeline view with parallel columns.
244
+ * @param {Object} payload - History payload with allWriters data
245
+ * @param {Object} options - Rendering options
246
+ * @returns {string[]} Lines for the timeline
247
+ */
248
+ function renderMultiWriterTimeline(payload, options) {
249
+ const { writers, graph } = payload;
250
+ const { pageSize = DEFAULT_PAGE_SIZE, showAll = false } = options;
251
+ const writerIds = Object.keys(writers);
252
+
253
+ const lines = renderMultiWriterHeader(graph, writerIds.length);
254
+
255
+ if (writerIds.length === 0) {
256
+ lines.push(colors.muted(' (no writers)'));
257
+ return lines;
258
+ }
259
+
260
+ const allEntries = mergeWriterEntries(writers);
261
+
262
+ if (allEntries.length === 0) {
263
+ lines.push(colors.muted(' (no patches)'));
264
+ return lines;
265
+ }
266
+
267
+ const { displayEntries, truncated, hiddenCount } = paginateEntries(allEntries, pageSize, showAll);
268
+ if (displayEntries.length === 0) {
269
+ lines.push(colors.muted(' (no patches)'));
270
+ return lines;
271
+ }
272
+ const maxLamport = Math.max(...displayEntries.map((e) => e.lamport));
273
+ const lamportWidth = String(maxLamport).length;
274
+ const maxWriterIdLen = Math.max(...writerIds.map((id) => id.length), 6);
275
+
276
+ lines.push(...renderTruncationIndicator(truncated, hiddenCount));
277
+
278
+ for (let i = 0; i < displayEntries.length; i++) {
279
+ const entry = displayEntries[i];
280
+ const isLast = i === displayEntries.length - 1;
281
+ lines.push(renderEntryLine({
282
+ entry,
283
+ isLast,
284
+ lamportWidth,
285
+ writerStr: entry.writerId,
286
+ maxWriterIdLen,
287
+ }));
288
+ }
289
+
290
+ lines.push(...renderMultiWriterFooter(allEntries.length, writerIds.length));
291
+ return lines;
292
+ }
293
+
294
+ /**
295
+ * Renders the history view with ASCII timeline.
296
+ * @param {Object} payload - History payload from handleHistory
297
+ * @param {string} payload.graph - Graph name
298
+ * @param {string} [payload.writer] - Writer ID (single writer mode)
299
+ * @param {string|null} [payload.nodeFilter] - Node filter if applied
300
+ * @param {Object[]} [payload.entries] - Array of patch entries (single writer mode)
301
+ * @param {Object} [payload.writers] - Map of writerId to entries (multi-writer mode)
302
+ * @param {Object} [options] - Rendering options
303
+ * @param {number} [options.pageSize=20] - Number of patches to show per page
304
+ * @param {boolean} [options.showAll=false] - Show all patches (no pagination)
305
+ * @returns {string} Formatted ASCII output
306
+ */
307
+ export function renderHistoryView(payload, options = {}) {
308
+ if (!payload) {
309
+ return `${colors.error('No data available')}\n`;
310
+ }
311
+
312
+ const isMultiWriter = payload.writers && typeof payload.writers === 'object';
313
+ const contentLines = isMultiWriter
314
+ ? renderMultiWriterTimeline(payload, options)
315
+ : renderSingleWriterTimeline(payload, options);
316
+
317
+ // Add node filter indicator if present
318
+ if (payload.nodeFilter) {
319
+ contentLines.splice(1, 0, colors.muted(` Filter: node=${payload.nodeFilter}`));
320
+ }
321
+
322
+ const content = contentLines.join('\n');
323
+
324
+ const box = createBox(content, {
325
+ title: 'PATCH HISTORY',
326
+ titleAlignment: 'center',
327
+ borderColor: 'cyan',
328
+ });
329
+
330
+ return `${box}\n`;
331
+ }
332
+
333
+ export { summarizeOps };
334
+
335
+ export default { renderHistoryView, summarizeOps };
@@ -0,0 +1,14 @@
1
+ /**
2
+ * ASCII renderer exports
3
+ */
4
+
5
+ export { colors, default as colorsDefault } from './colors.js';
6
+ export { createBox } from './box.js';
7
+ export { createTable } from './table.js';
8
+ export { progressBar } from './progress.js';
9
+ export { renderInfoView } from './info.js';
10
+ export { renderCheckView } from './check.js';
11
+ export { renderMaterializeView } from './materialize.js';
12
+ export { renderHistoryView, summarizeOps } from './history.js';
13
+ export { renderPathView } from './path.js';
14
+ export { renderGraphView } from './graph.js';
@@ -0,0 +1,245 @@
1
+ /**
2
+ * ASCII renderer for the `info --view` command.
3
+ * Displays a beautiful box with graph summaries and writer timelines.
4
+ */
5
+
6
+ import boxen from 'boxen';
7
+ import stringWidth from 'string-width';
8
+ import { colors } from './colors.js';
9
+ import { padRight } from '../../utils/unicode.js';
10
+ import { timeAgo } from '../../utils/time.js';
11
+ import { TIMELINE } from './symbols.js';
12
+
13
+ // Box drawing characters (info.js uses verbose key names for card rendering)
14
+ const BOX = {
15
+ topLeft: '\u250C', // ┌
16
+ topRight: '\u2510', // ┐
17
+ bottomLeft: '\u2514', // └
18
+ bottomRight: '\u2518', // ┘
19
+ horizontal: '\u2500', // ─
20
+ vertical: '\u2502', // │
21
+ };
22
+
23
+ // Maximum timeline width for patches
24
+ const MAX_TIMELINE_WIDTH = 40;
25
+
26
+ /**
27
+ * Builds a timeline string for a writer based on patch count.
28
+ * @param {number} patchCount - Number of patches
29
+ * @param {number} maxPatches - Maximum patches across all writers (for scaling)
30
+ * @returns {string} Timeline string like "────●────●────●────● (12 patches)"
31
+ */
32
+ function buildTimeline(patchCount, maxPatches) {
33
+ if (patchCount === 0) {
34
+ return colors.muted('(no patches)');
35
+ }
36
+
37
+ // Scale timeline width based on patch count relative to max
38
+ const scaledWidth = Math.max(
39
+ 4,
40
+ Math.floor((patchCount / Math.max(maxPatches, 1)) * MAX_TIMELINE_WIDTH)
41
+ );
42
+
43
+ // Determine number of dots (max 8 dots for visual clarity)
44
+ const dotCount = Math.min(patchCount, 8);
45
+ const segmentWidth = Math.floor(scaledWidth / dotCount);
46
+
47
+ let timeline = '';
48
+ for (let i = 0; i < dotCount; i++) {
49
+ if (i > 0) {
50
+ timeline += colors.muted(TIMELINE.line.repeat(segmentWidth));
51
+ }
52
+ timeline += colors.primary(TIMELINE.dot);
53
+ }
54
+
55
+ // Add trailing line
56
+ const remaining = scaledWidth - (dotCount * segmentWidth);
57
+ if (remaining > 0) {
58
+ timeline += colors.muted(TIMELINE.line.repeat(remaining));
59
+ }
60
+
61
+ const patchLabel = patchCount === 1 ? 'patch' : 'patches';
62
+ return `${timeline} ${colors.muted(`(${patchCount} ${patchLabel})`)}`;
63
+ }
64
+
65
+ /**
66
+ * Builds the writer names display string.
67
+ * @param {string[]} writerIds - Array of writer IDs
68
+ * @returns {string} Formatted writer names
69
+ */
70
+ function formatWriterNames(writerIds) {
71
+ if (writerIds.length === 0) {
72
+ return '';
73
+ }
74
+ const displayed = writerIds.slice(0, 5).join(', ');
75
+ return writerIds.length > 5 ? `${displayed}...` : displayed;
76
+ }
77
+
78
+ /**
79
+ * Renders the header lines for a graph card.
80
+ * @param {Object} graph - Graph info object
81
+ * @param {number} contentWidth - Available content width
82
+ * @returns {string[]} Header lines
83
+ */
84
+ function renderCardHeader(graph, contentWidth) {
85
+ const lines = [];
86
+ const graphIcon = '\uD83D\uDCCA'; // 📊
87
+ const graphName = `${graphIcon} ${colors.bold(graph.name)}`;
88
+ lines.push(`${BOX.topLeft}${BOX.horizontal.repeat(contentWidth + 2)}${BOX.topRight}`);
89
+ lines.push(`${BOX.vertical} ${padRight(graphName, contentWidth)} ${BOX.vertical}`);
90
+
91
+ // Writers summary
92
+ const writerCount = graph.writers?.count ?? 0;
93
+ const writerIds = graph.writers?.ids ?? [];
94
+ const writerNames = formatWriterNames(writerIds);
95
+ const writerLine = writerNames
96
+ ? `Writers: ${writerCount} (${writerNames})`
97
+ : `Writers: ${writerCount}`;
98
+ lines.push(`${BOX.vertical} ${padRight(writerLine, contentWidth)} ${BOX.vertical}`);
99
+
100
+ return lines;
101
+ }
102
+
103
+ /**
104
+ * Renders writer timeline lines for a graph card.
105
+ * @param {Object} writerPatches - Map of writerId to patch count
106
+ * @param {number} contentWidth - Available content width
107
+ * @returns {string[]} Timeline lines
108
+ */
109
+ function renderWriterTimelines(writerPatches, contentWidth) {
110
+ if (!writerPatches || Object.keys(writerPatches).length === 0) {
111
+ return [];
112
+ }
113
+
114
+ const lines = [];
115
+ const patchCounts = Object.values(writerPatches);
116
+ const maxPatches = Math.max(...patchCounts, 1);
117
+
118
+ // Find the longest writer ID for alignment
119
+ const maxWriterIdLen = Math.max(
120
+ ...Object.keys(writerPatches).map((id) => stringWidth(id)),
121
+ 6
122
+ );
123
+
124
+ for (const [writerId, patchCount] of Object.entries(writerPatches)) {
125
+ const paddedId = padRight(writerId, maxWriterIdLen);
126
+ const timeline = buildTimeline(patchCount, maxPatches);
127
+ const writerTimeline = ` ${colors.muted(paddedId)} ${timeline}`;
128
+ lines.push(`${BOX.vertical} ${padRight(writerTimeline, contentWidth)} ${BOX.vertical}`);
129
+ }
130
+
131
+ return lines;
132
+ }
133
+
134
+ /**
135
+ * Renders checkpoint and coverage lines for a graph card.
136
+ * @param {Object} graph - Graph info object
137
+ * @param {number} contentWidth - Available content width
138
+ * @returns {string[]} Status lines
139
+ */
140
+ function renderCardStatus(graph, contentWidth) {
141
+ const lines = [];
142
+
143
+ // Checkpoint info
144
+ if (graph.checkpoint) {
145
+ const { sha, date } = graph.checkpoint;
146
+ if (sha) {
147
+ const shortSha = sha.slice(0, 7);
148
+ const timeStr = date ? timeAgo(date) : '';
149
+ const checkIcon = colors.success('\u2713'); // ✓
150
+ const timePart = timeStr ? ` (${timeStr})` : '';
151
+ const checkpointLine = `Checkpoint: ${shortSha}${timePart} ${checkIcon}`;
152
+ lines.push(`${BOX.vertical} ${padRight(checkpointLine, contentWidth)} ${BOX.vertical}`);
153
+ } else {
154
+ const warnIcon = colors.warning('\u26A0'); // ⚠
155
+ const noCheckpointLine = `Checkpoint: none ${warnIcon}`;
156
+ lines.push(`${BOX.vertical} ${padRight(noCheckpointLine, contentWidth)} ${BOX.vertical}`);
157
+ }
158
+ }
159
+
160
+ // Coverage info (if present)
161
+ if (graph.coverage?.sha) {
162
+ const shortSha = graph.coverage.sha.slice(0, 7);
163
+ const coverageLine = `Coverage: ${shortSha}`;
164
+ lines.push(`${BOX.vertical} ${padRight(colors.muted(coverageLine), contentWidth)} ${BOX.vertical}`);
165
+ }
166
+
167
+ return lines;
168
+ }
169
+
170
+ /**
171
+ * Renders a single graph card.
172
+ * @param {Object} graph - Graph info object
173
+ * @param {number} innerWidth - Available width inside the card
174
+ * @returns {string[]} Array of lines for this graph card
175
+ */
176
+ function renderGraphCard(graph, innerWidth) {
177
+ const contentWidth = innerWidth - 4; // Account for │ padding on each side
178
+
179
+ const headerLines = renderCardHeader(graph, contentWidth);
180
+ const timelineLines = renderWriterTimelines(graph.writerPatches, contentWidth);
181
+ const statusLines = renderCardStatus(graph, contentWidth);
182
+ const bottomBorder = `${BOX.bottomLeft}${BOX.horizontal.repeat(contentWidth + 2)}${BOX.bottomRight}`;
183
+
184
+ return [...headerLines, ...timelineLines, ...statusLines, bottomBorder];
185
+ }
186
+
187
+ /**
188
+ * Renders the info view with ASCII box art.
189
+ * @param {Object} data - Info payload from handleInfo
190
+ * @param {string} data.repo - Repository path
191
+ * @param {Object[]} data.graphs - Array of graph info objects
192
+ * @returns {string} Formatted ASCII output
193
+ */
194
+ export function renderInfoView(data) {
195
+ if (!data || !data.graphs) {
196
+ return `${colors.error('No data available')}\n`;
197
+ }
198
+
199
+ const { graphs } = data;
200
+
201
+ if (graphs.length === 0) {
202
+ const content = colors.muted('No WARP graphs found in this repository.');
203
+ const box = boxen(content, {
204
+ title: 'WARP GRAPHS',
205
+ titleAlignment: 'center',
206
+ padding: 1,
207
+ borderStyle: 'double',
208
+ borderColor: 'cyan',
209
+ });
210
+ return `${box}\n`;
211
+ }
212
+
213
+ // Calculate inner width (for consistent card sizing)
214
+ const innerWidth = 60;
215
+
216
+ // Build content
217
+ const contentLines = [];
218
+
219
+ for (let i = 0; i < graphs.length; i++) {
220
+ const graph = graphs[i];
221
+ const cardLines = renderGraphCard(graph, innerWidth);
222
+
223
+ // Add spacing between cards
224
+ if (i > 0) {
225
+ contentLines.push('');
226
+ }
227
+
228
+ contentLines.push(...cardLines);
229
+ }
230
+
231
+ const content = contentLines.join('\n');
232
+
233
+ // Wrap in outer box
234
+ const output = boxen(content, {
235
+ title: 'WARP GRAPHS IN REPOSITORY',
236
+ titleAlignment: 'center',
237
+ padding: 1,
238
+ borderStyle: 'double',
239
+ borderColor: 'cyan',
240
+ });
241
+
242
+ return `${output}\n`;
243
+ }
244
+
245
+ export default { renderInfoView };