honeytree 1.1.5 → 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,7 +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";
2
10
 
3
- import { renderFrame } from "./renderer.js";
4
- import { getForestFile, readForest, writeForest } from "./state.js";
11
+ export function createForestWatcher(filePath, onChange) {
12
+ try {
13
+ const stat = fs.statSync(filePath);
14
+ if (!stat.isFile()) return null;
15
+
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
+ }
5
25
 
6
26
  function writeAnsi(code) {
7
27
  process.stdout.write(code);
@@ -23,147 +43,419 @@ function moveHome() {
23
43
  writeAnsi("\x1b[H");
24
44
  }
25
45
 
26
- function renderForest(forest, twinkleSeed = 0) {
27
- moveHome();
28
- process.stdout.write(renderFrame(forest, process.stdout.columns || 80, { twinkleSeed }));
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");
29
56
  }
30
57
 
31
- function delay(ms) {
32
- return new Promise((resolve) => setTimeout(resolve, ms));
58
+ function enableMouseMotion() {
59
+ writeAnsi("\x1b[?1003h");
33
60
  }
34
61
 
35
- async function animateNewTree(forest, newTreeId) {
36
- const tree = forest.trees.find((entry) => entry.id === newTreeId);
37
- if (!tree) {
38
- renderForest(forest);
39
- return;
62
+ function disableMouseMotion() {
63
+ writeAnsi("\x1b[?1003l");
64
+ }
65
+
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);
74
+ process.exit(1);
40
75
  }
41
76
 
42
- const originalGrowth = tree.growth;
43
- const frames = [0.12, 0.32, 0.6, originalGrowth].filter(
44
- (value, index, values) => value <= originalGrowth && values.indexOf(value) === index,
45
- );
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");
46
130
 
47
- for (let index = 0; index < frames.length; index += 1) {
48
- tree.growth = frames[index];
49
- renderForest(forest, index);
50
- await delay(120);
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"));
144
+ }
145
+
146
+ process.stdout.write("\n");
147
+
148
+ const changedCount = changedFiles.size;
149
+ const changeText = changedCount > 0 ? `${changedCount} files changed | ` : "";
150
+ process.stdout.write(renderStatusBar(files.length, screenWidth, changeText));
151
+
152
+ return buf;
51
153
  }
52
154
 
53
- tree.growth = originalGrowth;
54
- renderForest(forest);
55
- }
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);
162
+ }
56
163
 
57
- export async function viewer() {
58
- const forestFile = getForestFile();
59
- let forest = readForest();
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;
60
172
 
61
- if (!forest || !fs.existsSync(forestFile)) {
62
- console.error('No forest found. Run "honeytree init" first.');
63
- process.exit(1);
173
+ for (const file of files) {
174
+ file.changed = changedFiles.has(file.relativePath);
175
+ }
176
+
177
+ if (changed) {
178
+ regeneratePoints();
179
+ needsRedraw = true;
180
+ redraw();
181
+ }
182
+ } catch {
183
+ // ignore poll errors
184
+ }
185
+ polling = false;
64
186
  }
65
187
 
66
- // Save terminal width so plant knows how wide to spread trees
67
- let ignoreNextChange = false;
68
- function syncWidth() {
69
- const cols = process.stdout.columns || 80;
70
- if (forest.viewerWidth !== cols) {
71
- forest.viewerWidth = cols;
72
- ignoreNextChange = true;
73
- writeForest(forest);
188
+ function checkAllResolved() {
189
+ if (!diffPanel) return;
190
+ const allDone = diffPanel.hunkStatus.every(s => s !== "pending");
191
+ if (!allDone) return;
192
+
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
+ }
74
199
  }
200
+
201
+ diffPanel = null;
202
+ clearScreen();
203
+ pollChanges();
75
204
  }
76
205
 
77
- syncWidth();
78
206
  hideCursor();
79
207
  clearScreen();
80
- 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();
81
217
 
82
- let lastMaxId = forest.trees.reduce((max, tree) => Math.max(max, tree.id), 0);
83
- let lastTotalPrompts = forest.totalPrompts;
84
- let animating = false;
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
+ }
85
246
 
86
247
  const cleanup = () => {
248
+ clearInterval(pollTimer);
249
+ if (watcher) watcher.close();
250
+ disableMouseMotion();
251
+ disableMouse();
87
252
  showCursor();
88
253
  clearScreen();
89
- console.log(
90
- `Forest summary: ${forest.trees.length} trees across ${forest.totalPrompts} prompts`,
91
- );
254
+ console.log(`Scanned ${files.length} files in ${dir}`);
92
255
  process.exit(0);
93
256
  };
94
257
 
95
258
  process.on("SIGINT", cleanup);
96
259
  process.on("SIGTERM", cleanup);
97
260
  process.stdout.on("resize", () => {
98
- syncWidth();
261
+ needsRedraw = true;
99
262
  clearScreen();
100
- renderForest(forest);
263
+ redraw();
101
264
  });
102
265
 
103
- // Check for changes — used by both fs.watch and polling fallback
104
- async function checkForUpdates() {
105
- if (animating) return;
266
+ if (process.stdin.isTTY) {
267
+ process.stdin.setRawMode(true);
268
+ process.stdin.resume();
269
+ process.stdin.on("data", (data) => {
270
+ const key = data.toString();
106
271
 
107
- if (ignoreNextChange) {
108
- ignoreNextChange = false;
109
- return;
110
- }
272
+ if (key === "\x03" || key === "q") {
273
+ cleanup();
274
+ return;
275
+ }
111
276
 
112
- const updated = readForest();
113
- if (!updated) return;
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
+ }
334
+ return;
335
+ }
114
336
 
115
- // Only re-render if something actually changed
116
- if (updated.totalPrompts === lastTotalPrompts) return;
337
+ if (key === "d") {
338
+ diffMode = !diffMode;
339
+ needsRedraw = true;
340
+ redraw();
341
+ return;
342
+ }
117
343
 
118
- const nextMaxId = updated.trees.reduce((max, tree) => Math.max(max, tree.id), 0);
119
- forest = updated;
120
- lastTotalPrompts = forest.totalPrompts;
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
+ }
121
356
 
122
- if (nextMaxId > lastMaxId) {
123
- lastMaxId = nextMaxId;
124
- animating = true;
125
- await animateNewTree(forest, nextMaxId);
126
- animating = false;
127
- } else {
128
- renderForest(forest);
129
- }
130
- }
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
+ }
131
381
 
132
- // fs.watch can drop events on macOS after atomic renames, so
133
- // use it for fast response but also poll as a reliable fallback
134
- function startWatcher() {
135
- try {
136
- const watcher = fs.watch(forestFile, () => {
137
- checkForUpdates();
138
- });
139
- watcher.on("error", () => {});
140
- return watcher;
141
- } catch {
142
- return null;
143
- }
144
- }
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
+ }
145
394
 
146
- let watcher = startWatcher();
395
+ const mouse = parseMouseEvent(data);
396
+ if (!mouse) return;
147
397
 
148
- // Poll every 800ms as fallback — cheap since it only reads if mtime changed
149
- let lastMtime = 0;
150
- try {
151
- lastMtime = fs.statSync(forestFile).mtimeMs;
152
- } catch {}
398
+ if (mouse.button === 0 && !mouse.released) {
399
+ mouseDown = true;
400
+ lastMouseX = mouse.x;
401
+ lastMouseY = mouse.y;
402
+ return;
403
+ }
153
404
 
154
- setInterval(() => {
155
- try {
156
- const mtime = fs.statSync(forestFile).mtimeMs;
157
- if (mtime !== lastMtime) {
158
- lastMtime = mtime;
159
- checkForUpdates();
160
-
161
- // Re-establish watcher in case rename killed it
162
- if (watcher) {
163
- try { watcher.close(); } catch {}
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
+ }
164
426
  }
165
- watcher = startWatcher();
427
+ mouseDown = false;
428
+ return;
166
429
  }
167
- } catch {}
168
- }, 800);
430
+
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;
436
+
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
+ }
443
+
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
+ }
457
+ }
458
+ }
459
+ });
460
+ }
169
461
  }