honeytree 1.1.6 → 1.2.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/src/viewer.js CHANGED
@@ -1,9 +1,27 @@
1
- import fs from "node:fs";
1
+ import chalk from "chalk";
2
+ import { scanCodebase } from "./scanner.js";
3
+ import { generateForestCloud, generateGroundPlane } from "./pointcloud.js";
4
+ import { createCamera, rotatePoint, projectPoint, clampElevation, clampAzimuth } from "./camera.js";
5
+ import { createFrameBuffer, rasterize, renderBufferToString, renderTopBar, renderStatusBar } from "./renderer3d.js";
6
+ import { getChangedFiles, getFileDiff, watchForChanges } from "./diffwatch.js";
7
+ import { parseDiff } from "./diffparser.js";
8
+ import { createDiffPanel, renderDiffPanel } from "./diffpanel.js";
9
+ import { stageHunk, revertHunk } from "./diffactions.js";
10
+
11
+ export function createForestWatcher(filePath, onChange) {
12
+ try {
13
+ const stat = fs.statSync(filePath);
14
+ if (!stat.isFile()) return null;
2
15
 
3
- import { renderFrame } from "./renderer.js";
4
- import { getForestFile, readForest, writeForest } from "./state.js";
5
- import { migrateLayout } from "./migrate.js";
6
- import { getVirtualWidth } from "./plant.js";
16
+ const watcher = fs.watch(filePath, onChange);
17
+ watcher.on("error", () => {
18
+ try { watcher.close(); } catch {}
19
+ });
20
+ return watcher;
21
+ } catch {
22
+ return null;
23
+ }
24
+ }
7
25
 
8
26
  function writeAnsi(code) {
9
27
  process.stdout.write(code);
@@ -25,210 +43,419 @@ function moveHome() {
25
43
  writeAnsi("\x1b[H");
26
44
  }
27
45
 
28
- function delay(ms) {
29
- return new Promise((resolve) => setTimeout(resolve, ms));
46
+ function enableMouse() {
47
+ writeAnsi("\x1b[?1000h");
48
+ writeAnsi("\x1b[?1002h");
49
+ writeAnsi("\x1b[?1006h");
50
+ }
51
+
52
+ function disableMouse() {
53
+ writeAnsi("\x1b[?1006l");
54
+ writeAnsi("\x1b[?1002l");
55
+ writeAnsi("\x1b[?1000l");
30
56
  }
31
57
 
32
- export async function viewer() {
33
- const forestFile = getForestFile();
34
- let forest = readForest();
58
+ function enableMouseMotion() {
59
+ writeAnsi("\x1b[?1003h");
60
+ }
61
+
62
+ function disableMouseMotion() {
63
+ writeAnsi("\x1b[?1003l");
64
+ }
35
65
 
36
- if (!forest || !fs.existsSync(forestFile)) {
37
- console.error('No forest found. Run "honeytree init" first.');
66
+ export async function viewer(targetDir) {
67
+ const dir = targetDir || process.cwd();
68
+
69
+ process.stdout.write("Scanning codebase...\n");
70
+ const files = scanCodebase(dir);
71
+
72
+ if (files.length === 0) {
73
+ console.error("No source files found in", dir);
38
74
  process.exit(1);
39
75
  }
40
76
 
41
- // Migrate old layouts on first view
42
- if (forest && (!forest.layoutVersion || forest.layoutVersion < 2)) {
43
- const termWidth = process.stdout.columns || 80;
44
- migrateLayout(forest, termWidth);
45
- // Will be written to disk by syncWidth below
46
- }
77
+ const { points: treePoints, filePaths } = generateForestCloud(files);
78
+ const groundRadius = Math.max(25, Math.sqrt(files.length) * 7);
79
+ const groundPoints = generateGroundPlane(groundRadius);
80
+ const allPoints = [...treePoints, ...groundPoints];
81
+
82
+ const camera = createCamera();
83
+ let mouseDown = false;
84
+ let lastMouseX = 0;
85
+ let lastMouseY = 0;
86
+ let hoveredFile = "";
87
+ let hoveredModified = false;
88
+ let needsRedraw = true;
89
+
90
+ let changedFiles = new Set();
91
+ let diffPanel = null;
92
+ let diffMode = false;
93
+ let polling = false;
94
+ let watcher = null;
95
+
96
+ let screenWidth = process.stdout.columns || 80;
97
+ let screenHeight = (process.stdout.rows || 24) - 2;
98
+
99
+ function renderFrame() {
100
+ screenWidth = process.stdout.columns || 80;
101
+ screenHeight = (process.stdout.rows || 24) - 2;
102
+
103
+ const forestWidth = diffPanel ? Math.floor(screenWidth * 0.4) : screenWidth;
104
+ const buf = createFrameBuffer(forestWidth, screenHeight);
105
+
106
+ const projected = [];
107
+ for (const p of allPoints) {
108
+ const [rx, ry, rz] = rotatePoint(p.x, p.y, p.z, camera.azimuth, camera.elevation);
109
+ const proj = projectPoint(rx, ry, rz, forestWidth, screenHeight, camera.distance);
110
+ if (proj.visible) {
111
+ projected.push({
112
+ ...proj,
113
+ color: p.color,
114
+ fileIndex: p.fileIndex,
115
+ });
116
+ }
117
+ }
118
+
119
+ rasterize(buf, projected);
120
+
121
+ const changedIndices = diffMode
122
+ ? new Set(files.map((f, i) => f.changed ? i : -1).filter(i => i >= 0))
123
+ : null;
124
+
125
+ moveHome();
126
+ process.stdout.write(renderTopBar(hoveredFile, screenWidth, hoveredModified));
127
+ process.stdout.write("\n");
128
+
129
+ const forestLines = renderBufferToString(buf, undefined, changedIndices).split("\n");
47
130
 
48
- // Save terminal width so plant knows how wide to spread trees
49
- let ignoreNextChange = false;
50
- function syncWidth() {
51
- const cols = process.stdout.columns || 80;
52
- if (forest.viewerWidth !== cols) {
53
- forest.viewerWidth = cols;
54
- ignoreNextChange = true;
55
- writeForest(forest);
131
+ if (diffPanel) {
132
+ const panelWidth = screenWidth - forestWidth - 1;
133
+ const panelLines = renderDiffPanel(diffPanel, panelWidth, screenHeight);
134
+ const border = chalk.hex("#555555")("│");
135
+
136
+ for (let y = 0; y < screenHeight; y++) {
137
+ const fLine = forestLines[y] || "";
138
+ const pLine = panelLines[y] || "";
139
+ process.stdout.write(fLine + border + pLine);
140
+ if (y < screenHeight - 1) process.stdout.write("\n");
141
+ }
142
+ } else {
143
+ process.stdout.write(forestLines.join("\n"));
56
144
  }
57
- }
58
145
 
59
- let lastMaxId = forest.trees.reduce((max, tree) => Math.max(max, tree.id), 0);
60
- let lastTotalPrompts = forest.totalPrompts;
61
- let animating = false;
146
+ process.stdout.write("\n");
62
147
 
63
- let viewportX = forest.viewportX || 0;
64
- const PAN_STEP = 4;
148
+ const changedCount = changedFiles.size;
149
+ const changeText = changedCount > 0 ? `${changedCount} files changed | ` : "";
150
+ process.stdout.write(renderStatusBar(files.length, screenWidth, changeText));
65
151
 
66
- function getViewportWidth() {
67
- return process.stdout.columns || 80;
152
+ return buf;
68
153
  }
69
154
 
70
- function clampViewport(x) {
71
- const vw = getVirtualWidth(forest.trees.length, getViewportWidth());
72
- return Math.max(0, Math.min(x, Math.max(0, vw - getViewportWidth())));
155
+ function regeneratePoints() {
156
+ const { points: newTreePoints, filePaths: newPaths } = generateForestCloud(files);
157
+ const newGround = generateGroundPlane(groundRadius);
158
+ allPoints.length = 0;
159
+ allPoints.push(...newTreePoints, ...newGround);
160
+ filePaths.length = 0;
161
+ filePaths.push(...newPaths);
73
162
  }
74
163
 
75
- function renderForest(forest, twinkleSeed = 0) {
76
- clearScreen();
77
- const termWidth = process.stdout.columns || 80;
78
- const vw = getVirtualWidth(forest.trees.length, termWidth);
79
- process.stdout.write(renderFrame(forest, termWidth, {
80
- twinkleSeed,
81
- viewportX,
82
- virtualWidth: vw,
83
- }));
84
- }
164
+ async function pollChanges() {
165
+ if (polling) return;
166
+ polling = true;
167
+ try {
168
+ const newChanged = await getChangedFiles(dir);
169
+ const changed = newChanged.size !== changedFiles.size ||
170
+ [...newChanged].some(f => !changedFiles.has(f));
171
+ changedFiles = newChanged;
172
+
173
+ for (const file of files) {
174
+ file.changed = changedFiles.has(file.relativePath);
175
+ }
85
176
 
86
- async function animateNewTree(forest, newTreeId) {
87
- const tree = forest.trees.find((entry) => entry.id === newTreeId);
88
- if (!tree) {
89
- renderForest(forest);
90
- return;
177
+ if (changed) {
178
+ regeneratePoints();
179
+ needsRedraw = true;
180
+ redraw();
181
+ }
182
+ } catch {
183
+ // ignore poll errors
91
184
  }
185
+ polling = false;
186
+ }
92
187
 
93
- const originalGrowth = tree.growth;
94
- const frames = [0.12, 0.32, 0.6, originalGrowth].filter(
95
- (value, index, values) => value <= originalGrowth && values.indexOf(value) === index,
96
- );
188
+ function checkAllResolved() {
189
+ if (!diffPanel) return;
190
+ const allDone = diffPanel.hunkStatus.every(s => s !== "pending");
191
+ if (!allDone) return;
97
192
 
98
- for (let index = 0; index < frames.length; index += 1) {
99
- tree.growth = frames[index];
100
- renderForest(forest, index);
101
- await delay(120);
193
+ for (let i = 0; i < diffPanel.hunks.length; i++) {
194
+ if (diffPanel.hunkStatus[i] === "accepted") {
195
+ stageHunk(dir, diffPanel.filePath, diffPanel.hunks[i]);
196
+ } else if (diffPanel.hunkStatus[i] === "rejected") {
197
+ revertHunk(dir, diffPanel.filePath, diffPanel.hunks[i]);
198
+ }
102
199
  }
103
200
 
104
- tree.growth = originalGrowth;
105
- renderForest(forest);
201
+ diffPanel = null;
202
+ clearScreen();
203
+ pollChanges();
106
204
  }
107
205
 
108
- syncWidth();
109
206
  hideCursor();
110
207
  clearScreen();
111
- renderForest(forest);
208
+ enableMouse();
209
+ enableMouseMotion();
210
+
211
+ // Render first frame before starting change detection
212
+ let currentBuf = renderFrame();
213
+ needsRedraw = false;
214
+
215
+ // Start async change detection after first render
216
+ pollChanges();
217
+
218
+ // Watch filesystem for instant change detection
219
+ watcher = watchForChanges(dir, () => pollChanges());
220
+
221
+ // Fallback poll every 3 seconds in case fs.watch misses events
222
+ const pollTimer = setInterval(() => pollChanges(), 3000);
223
+
224
+ function redraw() {
225
+ if (!needsRedraw) return;
226
+ needsRedraw = false;
227
+ try {
228
+ currentBuf = renderFrame();
229
+ } catch {
230
+ // swallow render errors to prevent terminal corruption
231
+ }
232
+ }
233
+
234
+ function parseMouseEvent(data) {
235
+ const str = data.toString();
236
+ const match = str.match(/\x1b\[<(\d+);(\d+);(\d+)([Mm])/);
237
+ if (!match) return null;
238
+
239
+ const button = parseInt(match[1]);
240
+ const x = parseInt(match[2]) - 1;
241
+ const y = parseInt(match[3]) - 1;
242
+ const released = match[4] === "m";
243
+
244
+ return { button, x, y, released };
245
+ }
112
246
 
113
247
  const cleanup = () => {
114
- // Persist viewport position for next session
115
- forest.viewportX = viewportX;
116
- ignoreNextChange = true;
117
- writeForest(forest);
248
+ clearInterval(pollTimer);
249
+ if (watcher) watcher.close();
250
+ disableMouseMotion();
251
+ disableMouse();
118
252
  showCursor();
119
253
  clearScreen();
120
- console.log(
121
- `Forest summary: ${forest.trees.length} trees across ${forest.totalPrompts} prompts`,
122
- );
254
+ console.log(`Scanned ${files.length} files in ${dir}`);
123
255
  process.exit(0);
124
256
  };
125
257
 
126
258
  process.on("SIGINT", cleanup);
127
259
  process.on("SIGTERM", cleanup);
128
260
  process.stdout.on("resize", () => {
129
- syncWidth();
261
+ needsRedraw = true;
130
262
  clearScreen();
131
- renderForest(forest);
263
+ redraw();
132
264
  });
133
265
 
134
- // Enable raw mode for keypress handling
135
266
  if (process.stdin.isTTY) {
136
267
  process.stdin.setRawMode(true);
137
268
  process.stdin.resume();
138
269
  process.stdin.on("data", (data) => {
139
270
  const key = data.toString();
140
- // Ctrl+C or q to quit
271
+
141
272
  if (key === "\x03" || key === "q") {
142
273
  cleanup();
143
274
  return;
144
275
  }
145
- // Left arrow: \x1b[D
146
- if (key === "\x1b[D") {
147
- viewportX = clampViewport(viewportX - PAN_STEP);
148
- forest.viewportX = viewportX;
149
- renderForest(forest);
276
+
277
+ // Panel keybindings (when panel is open)
278
+ if (diffPanel) {
279
+ if (key === "\x1b" || key === "\x1b\x1b") {
280
+ diffPanel = null;
281
+ needsRedraw = true;
282
+ clearScreen();
283
+ redraw();
284
+ return;
285
+ }
286
+ if (key === "j" || key === "\x1b[B") {
287
+ diffPanel.currentHunk = Math.min(diffPanel.currentHunk + 1, diffPanel.hunks.length - 1);
288
+ needsRedraw = true;
289
+ redraw();
290
+ return;
291
+ }
292
+ if (key === "k" || key === "\x1b[A") {
293
+ diffPanel.currentHunk = Math.max(diffPanel.currentHunk - 1, 0);
294
+ needsRedraw = true;
295
+ redraw();
296
+ return;
297
+ }
298
+ if (key === "a") {
299
+ diffPanel.hunkStatus[diffPanel.currentHunk] = "accepted";
300
+ const next = diffPanel.hunkStatus.findIndex((s, i) => i > diffPanel.currentHunk && s === "pending");
301
+ if (next >= 0) diffPanel.currentHunk = next;
302
+ checkAllResolved();
303
+ needsRedraw = true;
304
+ redraw();
305
+ return;
306
+ }
307
+ if (key === "r") {
308
+ diffPanel.hunkStatus[diffPanel.currentHunk] = "rejected";
309
+ const next = diffPanel.hunkStatus.findIndex((s, i) => i > diffPanel.currentHunk && s === "pending");
310
+ if (next >= 0) diffPanel.currentHunk = next;
311
+ checkAllResolved();
312
+ needsRedraw = true;
313
+ redraw();
314
+ return;
315
+ }
316
+ if (key === "A") {
317
+ for (let i = 0; i < diffPanel.hunkStatus.length; i++) {
318
+ if (diffPanel.hunkStatus[i] === "pending") diffPanel.hunkStatus[i] = "accepted";
319
+ }
320
+ checkAllResolved();
321
+ needsRedraw = true;
322
+ redraw();
323
+ return;
324
+ }
325
+ if (key === "R") {
326
+ for (let i = 0; i < diffPanel.hunkStatus.length; i++) {
327
+ if (diffPanel.hunkStatus[i] === "pending") diffPanel.hunkStatus[i] = "rejected";
328
+ }
329
+ checkAllResolved();
330
+ needsRedraw = true;
331
+ redraw();
332
+ return;
333
+ }
150
334
  return;
151
335
  }
152
- // Right arrow: \x1b[C
153
- if (key === "\x1b[C") {
154
- viewportX = clampViewport(viewportX + PAN_STEP);
155
- forest.viewportX = viewportX;
156
- renderForest(forest);
336
+
337
+ if (key === "d") {
338
+ diffMode = !diffMode;
339
+ needsRedraw = true;
340
+ redraw();
157
341
  return;
158
342
  }
159
- });
160
- }
161
343
 
162
- // Check for changes — used by both fs.watch and polling fallback
163
- async function checkForUpdates() {
164
- if (animating) return;
165
-
166
- if (ignoreNextChange) {
167
- ignoreNextChange = false;
168
- return;
169
- }
344
+ if (key === "r") {
345
+ process.stdout.write("\x1b[H");
346
+ process.stdout.write("Rescanning...");
347
+ const newFiles = scanCodebase(dir);
348
+ files.length = 0;
349
+ files.push(...newFiles);
350
+ regeneratePoints();
351
+ needsRedraw = true;
352
+ clearScreen();
353
+ redraw();
354
+ return;
355
+ }
170
356
 
171
- const updated = readForest();
172
- if (!updated) return;
357
+ if (key === "\x1b[D") {
358
+ camera.azimuth = clampAzimuth(camera.azimuth - 5);
359
+ needsRedraw = true;
360
+ redraw();
361
+ return;
362
+ }
363
+ if (key === "\x1b[C") {
364
+ camera.azimuth = clampAzimuth(camera.azimuth + 5);
365
+ needsRedraw = true;
366
+ redraw();
367
+ return;
368
+ }
369
+ if (key === "\x1b[A") {
370
+ camera.elevation = clampElevation(camera.elevation + 5);
371
+ needsRedraw = true;
372
+ redraw();
373
+ return;
374
+ }
375
+ if (key === "\x1b[B") {
376
+ camera.elevation = clampElevation(camera.elevation - 5);
377
+ needsRedraw = true;
378
+ redraw();
379
+ return;
380
+ }
173
381
 
174
- // Only re-render if something actually changed
175
- if (updated.totalPrompts === lastTotalPrompts) return;
382
+ if (key === "+" || key === "=") {
383
+ camera.distance = Math.max(10, camera.distance - 5);
384
+ needsRedraw = true;
385
+ redraw();
386
+ return;
387
+ }
388
+ if (key === "-" || key === "_") {
389
+ camera.distance = Math.min(120, camera.distance + 5);
390
+ needsRedraw = true;
391
+ redraw();
392
+ return;
393
+ }
176
394
 
177
- const nextMaxId = updated.trees.reduce((max, tree) => Math.max(max, tree.id), 0);
178
- forest = updated;
179
- lastTotalPrompts = forest.totalPrompts;
395
+ const mouse = parseMouseEvent(data);
396
+ if (!mouse) return;
180
397
 
181
- if (nextMaxId > lastMaxId) {
182
- lastMaxId = nextMaxId;
183
- // Center viewport on the new tree
184
- const newTree = forest.trees.find((t) => t.id === nextMaxId);
185
- if (newTree) {
186
- const termWidth = getViewportWidth();
187
- viewportX = clampViewport(newTree.x - Math.floor(termWidth / 2));
398
+ if (mouse.button === 0 && !mouse.released) {
399
+ mouseDown = true;
400
+ lastMouseX = mouse.x;
401
+ lastMouseY = mouse.y;
402
+ return;
188
403
  }
189
- animating = true;
190
- await animateNewTree(forest, nextMaxId);
191
- animating = false;
192
- } else {
193
- renderForest(forest);
194
- }
195
- }
196
404
 
197
- // fs.watch can drop events on macOS after atomic renames, so
198
- // use it for fast response but also poll as a reliable fallback
199
- function startWatcher() {
200
- try {
201
- const watcher = fs.watch(forestFile, () => {
202
- checkForUpdates();
203
- });
204
- watcher.on("error", () => {});
205
- return watcher;
206
- } catch {
207
- return null;
208
- }
209
- }
405
+ if (mouse.released && mouse.button === 0) {
406
+ const sy = mouse.y - 1;
407
+ const sx = mouse.x;
408
+ if (!diffPanel && currentBuf && sy >= 0 && sy < currentBuf.height && sx >= 0 && sx < currentBuf.width) {
409
+ const fi = currentBuf.fileIndices[sy][sx];
410
+ if (fi >= 0 && files[fi] && files[fi].changed) {
411
+ const filePath = filePaths[fi];
412
+ getFileDiff(dir, filePath).then((diff) => {
413
+ if (diff) {
414
+ const hunks = parseDiff(diff);
415
+ if (hunks.length > 0) {
416
+ diffPanel = createDiffPanel(filePath, hunks);
417
+ needsRedraw = true;
418
+ clearScreen();
419
+ redraw();
420
+ }
421
+ }
422
+ });
423
+ mouseDown = false;
424
+ return;
425
+ }
426
+ }
427
+ mouseDown = false;
428
+ return;
429
+ }
210
430
 
211
- let watcher = startWatcher();
431
+ if (mouse.button === 32 && mouseDown) {
432
+ const dx = mouse.x - lastMouseX;
433
+ const dy = mouse.y - lastMouseY;
434
+ lastMouseX = mouse.x;
435
+ lastMouseY = mouse.y;
212
436
 
213
- // Poll every 800ms as fallback — cheap since it only reads if mtime changed
214
- let lastMtime = 0;
215
- try {
216
- lastMtime = fs.statSync(forestFile).mtimeMs;
217
- } catch {}
437
+ camera.azimuth = clampAzimuth(camera.azimuth + dx * 0.8);
438
+ camera.elevation = clampElevation(camera.elevation - dy * 0.8);
439
+ needsRedraw = true;
440
+ redraw();
441
+ return;
442
+ }
218
443
 
219
- setInterval(() => {
220
- try {
221
- const mtime = fs.statSync(forestFile).mtimeMs;
222
- if (mtime !== lastMtime) {
223
- lastMtime = mtime;
224
- checkForUpdates();
225
-
226
- // Re-establish watcher in case rename killed it
227
- if (watcher) {
228
- try { watcher.close(); } catch {}
444
+ if (mouse.button === 35 || (mouse.button >= 32 && mouse.released === false && !mouseDown)) {
445
+ const sy = mouse.y - 1;
446
+ const sx = mouse.x;
447
+ if (currentBuf && sy >= 0 && sy < currentBuf.height && sx >= 0 && sx < currentBuf.width) {
448
+ const fi = currentBuf.fileIndices[sy][sx];
449
+ const newHover = fi >= 0 ? filePaths[fi] : "";
450
+ const isModified = fi >= 0 && files[fi] && files[fi].changed;
451
+ if (newHover !== hoveredFile) {
452
+ hoveredFile = newHover;
453
+ hoveredModified = isModified;
454
+ writeAnsi(`\x1b[1;1H`);
455
+ process.stdout.write(renderTopBar(hoveredFile, screenWidth, hoveredModified));
456
+ }
229
457
  }
230
- watcher = startWatcher();
231
458
  }
232
- } catch {}
233
- }, 800);
459
+ });
460
+ }
234
461
  }