@tmustier/pi-files-widget 0.1.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/TODO.md ADDED
@@ -0,0 +1,141 @@
1
+ # Editor Extension - Implementation Checklist
2
+
3
+ ## Pre-requisites
4
+ - [x] Check for required tools (`bat`, `delta`, `glow`, `fd`) and document install commands
5
+ - [ ] Verify pi-tui capabilities for widget sizing and keyboard handling
6
+
7
+ ## Phase 1: File Browser Widget
8
+
9
+ ### Scaffold
10
+ - [x] Create `index.ts` extension entry point
11
+ - [ ] Register `Ctrl+E` toggle shortcut
12
+ - [x] Basic widget rendering with placeholder content
13
+
14
+ ### File Tree
15
+ - [x] Build file tree from current directory
16
+ - [ ] Respect `.gitignore` (use `fd` or manual parsing)
17
+ - [x] Collapse/expand directories
18
+ - [x] Navigation with `j/k` and arrow keys
19
+ - [x] Enter to expand dir or open file
20
+
21
+ ### Git Integration
22
+ - [x] Parse `git status --porcelain` output
23
+ - [x] Show indicators: M (modified), ? (untracked), A (added), D (deleted)
24
+ - [x] Color coding: green (staged), yellow (unstaged), grey (untracked)
25
+
26
+ ### Search
27
+ - [x] `/` to enter search mode
28
+ - [ ] Fuzzy match file names
29
+ - [ ] Highlight matches, Enter to jump
30
+
31
+ ## Phase 2: File Viewer
32
+
33
+ ### Basic Viewer
34
+ - [x] `ctx.ui.custom()` full-screen component
35
+ - [x] Load file content
36
+ - [x] Line numbers
37
+ - [x] Scroll with `j/k`, `PgUp/PgDn`, `g/G`
38
+ - [x] `q` to close
39
+
40
+ ### Syntax Highlighting
41
+ - [x] Detect `bat` availability
42
+ - [x] Shell out to `bat` for highlighting
43
+ - [x] Parse ANSI output for display
44
+ - [x] Fallback to plain text with line numbers
45
+
46
+ ### Markdown Rendering
47
+ - [x] Detect `glow` availability
48
+ - [x] Shell out to `glow` for .md files
49
+ - [ ] Toggle between rendered and raw (`m` key?)
50
+ - [x] Fallback to syntax-highlighted raw
51
+
52
+ ### Diff View
53
+ - [x] `d` to toggle diff mode
54
+ - [x] Shell out to `git diff HEAD -- <file>`
55
+ - [x] Use `delta` if available for nicer output
56
+ - [x] Show only if file has changes
57
+
58
+ ## Phase 3: Select + Comment + Send
59
+
60
+ ### Selection Mode
61
+ - [x] `v` to enter selection mode
62
+ - [x] Track start line and current line
63
+ - [x] Visual highlight of selected range
64
+ - [x] `j/k` to extend selection
65
+ - [x] `Esc` to cancel
66
+
67
+ ### Comment Dialog
68
+ - [x] `c` to open comment input
69
+ - [ ] Multi-line text input
70
+ - [x] `Enter` or `Ctrl+Enter` to confirm
71
+ - [x] `Esc` to cancel
72
+
73
+ ### Send to Agent
74
+ - [x] Format message with file path, line range, code snippet, comment
75
+ - [x] Use `pi.sendUserMessage()` with `deliverAs: "followUp"`
76
+ - [x] Handle case when agent is idle vs streaming
77
+ - [ ] Show confirmation notification
78
+
79
+ ## Phase 4: tuicr Integration (Optional)
80
+
81
+ ### Setup
82
+ - [x] Check for tuicr availability (`which tuicr`)
83
+ - [x] Document install: `brew install agavra/tap/tuicr`
84
+
85
+ ### /review Command
86
+ - [x] Register `/review` command
87
+ - [x] Spawn tuicr with `stdio: "inherit"` (takes over terminal)
88
+ - [x] After exit, read clipboard (`pbpaste` on macOS, `xclip` on Linux)
89
+ - [x] Detect tuicr export format (contains `## Review Summary` or structured markdown)
90
+ - [x] Send review to agent via `pi.sendUserMessage()`
91
+ - [x] Show confirmation notification
92
+
93
+ ### UX
94
+ - [ ] `/review` - review all unstaged changes
95
+ - [ ] `/review --staged` - review staged changes
96
+ - [ ] `/review HEAD` - review last commit
97
+
98
+ ## Phase 5: critique Integration (Optional)
99
+
100
+ ### Setup
101
+ - [ ] Check for critique availability (requires Bun)
102
+ - [ ] Document install: `bun install -g critique`
103
+
104
+ ### /diff Command
105
+ - [x] Register `/diff` command
106
+ - [x] Spawn critique for quick diff viewing
107
+ - [ ] `/diff --watch` for live monitoring while agent works
108
+ - [x] `/diff <file>` for specific file
109
+
110
+ ### Web Preview
111
+ - [ ] `/diff --web` generates shareable URL
112
+ - [ ] Useful for async review or sharing with others
113
+
114
+ ## Phase 6: Agent Awareness
115
+
116
+ ### Track Modifications
117
+ - [x] Subscribe to `tool_result` events
118
+ - [x] Filter for `write` and `edit` tools
119
+ - [x] Extract file paths from tool inputs
120
+ - [ ] Store in extension state with timestamps
121
+
122
+ ### Visual Indicators
123
+ - [x] Badge files in tree with "agent modified" icon (e.g., 🤖)
124
+ - [ ] Different indicator for "agent modified this session" vs "human modified"
125
+ - [ ] Persist across session reload via `pi.appendEntry()`
126
+
127
+ ### Per-Line Attribution (Stretch - Cursor Blame style)
128
+ - [ ] Parse edit tool diffs to get line ranges
129
+ - [ ] Store line-level attribution metadata (which model, which tool call)
130
+ - [ ] Show in file viewer gutter
131
+ - [ ] Differentiate: Tab completions vs agent runs vs human edits
132
+
133
+ ## Polish
134
+
135
+ - [x] Error handling for missing tools
136
+ - [x] Graceful degradation (no git, no bat, etc.)
137
+ - [ ] Performance: cache file tree, lazy load
138
+ - [ ] Help overlay (`?` key)
139
+ - [ ] Configurable keybindings
140
+ - [x] Theme integration (use pi theme colors)
141
+ - [x] Linux clipboard support (`xclip -selection clipboard`)
package/browser.ts ADDED
@@ -0,0 +1,537 @@
1
+ import type { Theme } from "@mariozechner/pi-coding-agent";
2
+ import { Key, matchesKey, truncateToWidth } from "@mariozechner/pi-tui";
3
+ import { basename } from "node:path";
4
+
5
+ import {
6
+ DEFAULT_BROWSER_HEIGHT,
7
+ MAX_BROWSER_HEIGHT,
8
+ MIN_PANEL_HEIGHT,
9
+ POLL_INTERVAL_MS,
10
+ } from "./constants";
11
+ import { getGitBranch, getGitDiffStats, getGitStatus } from "./git";
12
+ import { buildFileTree, flattenTree, getIgnoredNames } from "./file-tree";
13
+ import type { FileNode, FlatNode } from "./types";
14
+ import { isIgnoredStatus, isUntrackedStatus } from "./utils";
15
+ import { createViewer, type CommentPayload, type ViewerAction } from "./viewer";
16
+ import { isPrintableChar } from "./input-utils";
17
+
18
+ export interface BrowserController {
19
+ render(width: number): string[];
20
+ handleInput(data: string): void;
21
+ invalidate(): void;
22
+ }
23
+
24
+
25
+ interface BrowserState {
26
+ root: FileNode | null;
27
+ flatList: FlatNode[];
28
+ selectedIndex: number;
29
+ searchQuery: string;
30
+ searchMode: boolean;
31
+ showOnlyChanged: boolean;
32
+ browserHeight: number;
33
+ lastPollTime: number;
34
+ }
35
+
36
+ interface ChangedFile {
37
+ file: FileNode;
38
+ ancestors: FileNode[];
39
+ }
40
+
41
+ function captureExpandedPaths(root: FileNode | null): Set<string> {
42
+ const expandedPaths = new Set<string>();
43
+
44
+ function traverse(node: FileNode): void {
45
+ if (node.isDirectory && node.expanded) {
46
+ expandedPaths.add(node.path);
47
+ }
48
+ if (node.children) {
49
+ for (const child of node.children) {
50
+ traverse(child);
51
+ }
52
+ }
53
+ }
54
+
55
+ if (root) traverse(root);
56
+ return expandedPaths;
57
+ }
58
+
59
+ function restoreExpandedPaths(root: FileNode | null, expandedPaths: Set<string>): void {
60
+ function traverse(node: FileNode): void {
61
+ if (node.isDirectory) {
62
+ node.expanded = expandedPaths.has(node.path);
63
+ }
64
+ if (node.children) {
65
+ for (const child of node.children) {
66
+ traverse(child);
67
+ }
68
+ }
69
+ }
70
+
71
+ if (root) traverse(root);
72
+ }
73
+
74
+ function findNodeByPath(root: FileNode | null, path: string): FileNode | null {
75
+ if (!root) return null;
76
+ if (root.path === path) return root;
77
+ if (!root.children) return null;
78
+
79
+ for (const child of root.children) {
80
+ const found = findNodeByPath(child, path);
81
+ if (found) return found;
82
+ }
83
+
84
+ return null;
85
+ }
86
+
87
+ function collectChangedFiles(node: FileNode, ancestors: FileNode[] = []): ChangedFile[] {
88
+ const results: ChangedFile[] = [];
89
+
90
+ if (!node.isDirectory && (node.gitStatus || node.agentModified)) {
91
+ results.push({ file: node, ancestors: [...ancestors] });
92
+ }
93
+
94
+ if (node.children) {
95
+ for (const child of node.children) {
96
+ results.push(...collectChangedFiles(child, [...ancestors, node]));
97
+ }
98
+ }
99
+
100
+ return results;
101
+ }
102
+
103
+ function computeTotalStats(root: FileNode | null): { totalLines: number; additions: number; deletions: number } {
104
+ let totalLines = 0;
105
+ let additions = 0;
106
+ let deletions = 0;
107
+
108
+ function traverse(node: FileNode): void {
109
+ if (!node.isDirectory) {
110
+ totalLines += node.lineCount || 0;
111
+ if (node.diffStats) {
112
+ additions += node.diffStats.additions;
113
+ deletions += node.diffStats.deletions;
114
+ }
115
+ }
116
+ if (node.children) {
117
+ for (const child of node.children) {
118
+ traverse(child);
119
+ }
120
+ }
121
+ }
122
+
123
+ if (root) traverse(root);
124
+ return { totalLines, additions, deletions };
125
+ }
126
+
127
+ function formatNodeStatus(node: FileNode, theme: Theme): string {
128
+ if (isIgnoredStatus(node.gitStatus)) return "";
129
+ if (node.agentModified) return theme.fg("accent", " 🤖");
130
+ if (node.gitStatus === "M" || node.gitStatus === "MM") return theme.fg("warning", " M");
131
+ if (isUntrackedStatus(node.gitStatus)) return theme.fg("dim", " ?");
132
+ if (node.gitStatus === "A") return theme.fg("success", " A");
133
+ if (node.gitStatus === "D") return theme.fg("error", " D");
134
+ return "";
135
+ }
136
+
137
+ function formatNodeMeta(node: FileNode, theme: Theme): string {
138
+ if (isIgnoredStatus(node.gitStatus)) return "";
139
+
140
+ const parts: string[] = [];
141
+
142
+ if (node.isDirectory && !node.expanded) {
143
+ if (node.totalAdditions && node.totalAdditions > 0) {
144
+ parts.push(theme.fg("success", `+${node.totalAdditions}`));
145
+ }
146
+ if (node.totalDeletions && node.totalDeletions > 0) {
147
+ parts.push(theme.fg("error", `-${node.totalDeletions}`));
148
+ }
149
+ if (node.totalLines) {
150
+ parts.push(theme.fg("dim", `${node.totalLines}L`));
151
+ }
152
+ } else if (!node.isDirectory) {
153
+ if (node.diffStats) {
154
+ if (node.diffStats.additions > 0) {
155
+ parts.push(theme.fg("success", `+${node.diffStats.additions}`));
156
+ }
157
+ if (node.diffStats.deletions > 0) {
158
+ parts.push(theme.fg("error", `-${node.diffStats.deletions}`));
159
+ }
160
+ } else if (isUntrackedStatus(node.gitStatus) && node.lineCount) {
161
+ parts.push(theme.fg("success", `+${node.lineCount}`));
162
+ }
163
+ if (node.lineCount) {
164
+ parts.push(theme.fg("dim", `${node.lineCount}L`));
165
+ }
166
+ }
167
+
168
+ return parts.length > 0 ? ` ${parts.join(" ")}` : "";
169
+ }
170
+
171
+ function formatNodeName(node: FileNode, theme: Theme): string {
172
+ if (isIgnoredStatus(node.gitStatus)) return theme.fg("dim", node.name);
173
+ if (node.isDirectory) {
174
+ return node.hasChangedChildren ? theme.fg("warning", node.name) : theme.fg("accent", node.name);
175
+ }
176
+ if (node.gitStatus) return theme.fg("warning", node.name);
177
+ return node.name;
178
+ }
179
+
180
+ function collapseAllExcept(node: FileNode, keep: Set<FileNode>): void {
181
+ if (node.isDirectory) {
182
+ node.expanded = keep.has(node);
183
+ if (node.children) {
184
+ for (const child of node.children) {
185
+ collapseAllExcept(child, keep);
186
+ }
187
+ }
188
+ }
189
+ }
190
+
191
+ export function createFileBrowser(
192
+ cwd: string,
193
+ agentModifiedFiles: Set<string>,
194
+ theme: Theme,
195
+ onClose: () => void,
196
+ requestComment: (payload: CommentPayload, comment: string) => void
197
+ ): BrowserController {
198
+ let gitStatus = getGitStatus(cwd);
199
+ let diffStats = getGitDiffStats(cwd);
200
+ const gitBranch = getGitBranch(cwd);
201
+ const ignored = getIgnoredNames();
202
+
203
+ const viewer = createViewer(cwd, theme, requestComment);
204
+
205
+ const browser: BrowserState = {
206
+ root: buildFileTree(cwd, cwd, gitStatus, diffStats, ignored, agentModifiedFiles),
207
+ flatList: [],
208
+ selectedIndex: 0,
209
+ searchQuery: "",
210
+ searchMode: false,
211
+ showOnlyChanged: false,
212
+ browserHeight: DEFAULT_BROWSER_HEIGHT,
213
+ lastPollTime: Date.now(),
214
+ };
215
+
216
+ browser.flatList = browser.root ? flattenTree(browser.root) : [];
217
+
218
+ function refreshGitStatus(): void {
219
+ const currentPath = browser.flatList[browser.selectedIndex]?.node.path;
220
+ const viewingFilePath = viewer.getFile()?.path;
221
+
222
+ const expandedPaths = captureExpandedPaths(browser.root);
223
+
224
+ gitStatus = getGitStatus(cwd);
225
+ diffStats = getGitDiffStats(cwd);
226
+ browser.root = buildFileTree(cwd, cwd, gitStatus, diffStats, ignored, agentModifiedFiles);
227
+
228
+ restoreExpandedPaths(browser.root, expandedPaths);
229
+
230
+ browser.flatList = browser.root ? flattenTree(browser.root) : [];
231
+
232
+ if (currentPath) {
233
+ const newIdx = browser.flatList.findIndex(f => f.node.path === currentPath);
234
+ if (newIdx !== -1) {
235
+ browser.selectedIndex = newIdx;
236
+ }
237
+ }
238
+
239
+ browser.selectedIndex = Math.min(browser.selectedIndex, Math.max(0, browser.flatList.length - 1));
240
+
241
+ if (viewingFilePath && browser.root) {
242
+ const newNode = findNodeByPath(browser.root, viewingFilePath);
243
+ if (newNode) {
244
+ viewer.updateFileRef(newNode);
245
+ }
246
+ }
247
+ }
248
+
249
+ function getDisplayList(): FlatNode[] {
250
+ let list = browser.flatList;
251
+
252
+ if (browser.showOnlyChanged) {
253
+ list = list.filter(f =>
254
+ f.node.gitStatus ||
255
+ f.node.agentModified ||
256
+ (f.node.isDirectory && f.node.hasChangedChildren)
257
+ );
258
+ }
259
+
260
+ if (browser.searchQuery) {
261
+ const q = browser.searchQuery.toLowerCase();
262
+ list = list.filter(f => f.node.name.toLowerCase().includes(q));
263
+ }
264
+
265
+ return list;
266
+ }
267
+
268
+ function navigateToChange(direction: 1 | -1): void {
269
+ if (!browser.root) return;
270
+
271
+ const changedFiles = collectChangedFiles(browser.root);
272
+ if (changedFiles.length === 0) return;
273
+
274
+ const displayList = getDisplayList();
275
+ const currentNode = displayList[browser.selectedIndex]?.node;
276
+
277
+ let currentIdx = -1;
278
+ if (currentNode && !currentNode.isDirectory) {
279
+ currentIdx = changedFiles.findIndex(c => c.file.path === currentNode.path);
280
+ }
281
+
282
+ let nextIdx: number;
283
+ if (currentIdx === -1) {
284
+ nextIdx = direction === 1 ? 0 : changedFiles.length - 1;
285
+ } else {
286
+ nextIdx = currentIdx + direction;
287
+ if (nextIdx < 0) nextIdx = changedFiles.length - 1;
288
+ if (nextIdx >= changedFiles.length) nextIdx = 0;
289
+ }
290
+
291
+ const target = changedFiles[nextIdx];
292
+
293
+ const ancestorSet = new Set(target.ancestors);
294
+ collapseAllExcept(browser.root, ancestorSet);
295
+
296
+ for (const ancestor of target.ancestors) {
297
+ ancestor.expanded = true;
298
+ }
299
+
300
+ browser.flatList = flattenTree(browser.root);
301
+
302
+ const newDisplayList = getDisplayList();
303
+ const targetIdx = newDisplayList.findIndex(f => f.node.path === target.file.path);
304
+ if (targetIdx !== -1) {
305
+ browser.selectedIndex = targetIdx;
306
+ }
307
+ }
308
+
309
+ function toggleDir(node: FileNode): void {
310
+ if (node.isDirectory) {
311
+ node.expanded = !node.expanded;
312
+ browser.flatList = browser.root ? flattenTree(browser.root) : [];
313
+ }
314
+ }
315
+
316
+ function openFile(node: FileNode): void {
317
+ viewer.setFile(node);
318
+ }
319
+
320
+ function renderBrowser(width: number): string[] {
321
+ const lines: string[] = [];
322
+ const pathDisplay = basename(cwd);
323
+ const branchDisplay = gitBranch ? theme.fg("accent", ` (${gitBranch})`) : "";
324
+ const stats = computeTotalStats(browser.root);
325
+
326
+ let statsDisplay = theme.fg("dim", ` ${stats.totalLines}L`);
327
+ if (stats.additions > 0 || stats.deletions > 0) {
328
+ if (stats.additions > 0) statsDisplay += theme.fg("success", ` +${stats.additions}`);
329
+ if (stats.deletions > 0) statsDisplay += theme.fg("error", ` -${stats.deletions}`);
330
+ }
331
+
332
+ const searchIndicator = browser.searchMode
333
+ ? theme.fg("accent", ` /${browser.searchQuery}█`)
334
+ : "";
335
+
336
+ lines.push(truncateToWidth(theme.bold(pathDisplay) + branchDisplay + statsDisplay + searchIndicator, width));
337
+ lines.push(theme.fg("borderMuted", "─".repeat(width)));
338
+
339
+ const displayList = getDisplayList();
340
+ if (displayList.length === 0) {
341
+ lines.push(theme.fg("dim", " (no files" + (browser.searchQuery ? " matching '" + browser.searchQuery + "'" : "") + ")"));
342
+ for (let i = 1; i < browser.browserHeight; i++) {
343
+ lines.push("");
344
+ }
345
+ } else {
346
+ const start = Math.max(
347
+ 0,
348
+ Math.min(browser.selectedIndex - Math.floor(browser.browserHeight / 2), displayList.length - browser.browserHeight)
349
+ );
350
+ const end = Math.min(displayList.length, start + browser.browserHeight);
351
+
352
+ for (let i = start; i < end; i++) {
353
+ const { node, depth } = displayList[i];
354
+ const isSelected = i === browser.selectedIndex;
355
+ const indent = " ".repeat(depth);
356
+ const icon = node.isDirectory
357
+ ? (node.expanded ? "▼ " : "▶ ")
358
+ : " ";
359
+
360
+ const status = formatNodeStatus(node, theme);
361
+ const meta = formatNodeMeta(node, theme);
362
+ const name = formatNodeName(node, theme);
363
+
364
+ let line = `${indent}${icon}${name}${status}${meta}`;
365
+ line = truncateToWidth(line, width);
366
+
367
+ if (isSelected) {
368
+ line = theme.bg("selectedBg", line);
369
+ }
370
+
371
+ lines.push(line);
372
+ }
373
+
374
+ const renderedCount = end - start;
375
+ for (let i = renderedCount; i < browser.browserHeight; i++) {
376
+ lines.push("");
377
+ }
378
+
379
+ const pct = displayList.length > 1
380
+ ? Math.round((browser.selectedIndex / (displayList.length - 1)) * 100)
381
+ : 100;
382
+ lines.push(theme.fg("dim", ` ${browser.selectedIndex + 1}/${displayList.length} (${pct}%)`));
383
+ }
384
+
385
+ lines.push(theme.fg("borderMuted", "─".repeat(width)));
386
+ const changedIndicator = browser.showOnlyChanged ? theme.fg("warning", " [changed only]") : "";
387
+ const help = browser.searchMode
388
+ ? theme.fg("dim", "Type to search ↑↓: nav Enter: confirm Esc: cancel")
389
+ : theme.fg("dim", "j/k: nav []: next/prev change c: toggle changed /: search q: close") + changedIndicator;
390
+ lines.push(truncateToWidth(help, width));
391
+
392
+ return lines;
393
+ }
394
+
395
+ function handleViewerInput(data: string): void {
396
+ const action: ViewerAction = viewer.handleInput(data);
397
+ if (action.type === "close") {
398
+ viewer.close();
399
+ return;
400
+ }
401
+ if (action.type === "navigate") {
402
+ viewer.close();
403
+ navigateToChange(action.direction);
404
+ const displayList = getDisplayList();
405
+ const item = displayList[browser.selectedIndex];
406
+ if (item && !item.node.isDirectory) {
407
+ openFile(item.node);
408
+ }
409
+ }
410
+ }
411
+
412
+ function handleBrowserInput(data: string): void {
413
+ const displayList = getDisplayList();
414
+
415
+ if (matchesKey(data, "q") && !browser.searchMode) {
416
+ onClose();
417
+ return;
418
+ }
419
+ if (matchesKey(data, Key.escape)) {
420
+ if (browser.searchMode) {
421
+ browser.searchMode = false;
422
+ browser.searchQuery = "";
423
+ } else {
424
+ onClose();
425
+ }
426
+ return;
427
+ }
428
+ if (matchesKey(data, "/") && !browser.searchMode) {
429
+ browser.searchMode = true;
430
+ browser.searchQuery = "";
431
+ return;
432
+ }
433
+ if (matchesKey(data, "j") || matchesKey(data, Key.down)) {
434
+ browser.selectedIndex = Math.min(displayList.length - 1, browser.selectedIndex + 1);
435
+ return;
436
+ }
437
+ if (matchesKey(data, "k") || matchesKey(data, Key.up)) {
438
+ browser.selectedIndex = Math.max(0, browser.selectedIndex - 1);
439
+ return;
440
+ }
441
+ if (browser.searchMode) {
442
+ if (matchesKey(data, Key.enter)) {
443
+ browser.searchMode = false;
444
+ browser.selectedIndex = 0;
445
+ } else if (matchesKey(data, Key.backspace)) {
446
+ browser.searchQuery = browser.searchQuery.slice(0, -1);
447
+ browser.selectedIndex = 0;
448
+ } else if (isPrintableChar(data)) {
449
+ browser.searchQuery += data;
450
+ browser.selectedIndex = 0;
451
+ }
452
+ return;
453
+ }
454
+ if (matchesKey(data, Key.enter)) {
455
+ const item = displayList[browser.selectedIndex];
456
+ if (item) {
457
+ if (item.node.isDirectory) {
458
+ toggleDir(item.node);
459
+ } else {
460
+ openFile(item.node);
461
+ }
462
+ }
463
+ return;
464
+ }
465
+ if (matchesKey(data, "l") || matchesKey(data, Key.right)) {
466
+ const item = displayList[browser.selectedIndex];
467
+ if (item?.node.isDirectory && !item.node.expanded) {
468
+ toggleDir(item.node);
469
+ } else if (item && !item.node.isDirectory) {
470
+ openFile(item.node);
471
+ }
472
+ return;
473
+ }
474
+ if (matchesKey(data, "h") || matchesKey(data, Key.left)) {
475
+ const item = displayList[browser.selectedIndex];
476
+ if (item?.node.isDirectory && item.node.expanded) {
477
+ toggleDir(item.node);
478
+ }
479
+ return;
480
+ }
481
+ if (matchesKey(data, Key.pageDown)) {
482
+ browser.selectedIndex = Math.min(displayList.length - 1, browser.selectedIndex + browser.browserHeight);
483
+ return;
484
+ }
485
+ if (matchesKey(data, Key.pageUp)) {
486
+ browser.selectedIndex = Math.max(0, browser.selectedIndex - browser.browserHeight);
487
+ return;
488
+ }
489
+ if (matchesKey(data, "+") || matchesKey(data, "=")) {
490
+ browser.browserHeight = Math.min(MAX_BROWSER_HEIGHT, browser.browserHeight + 5);
491
+ return;
492
+ }
493
+ if (matchesKey(data, "-") || matchesKey(data, "_")) {
494
+ browser.browserHeight = Math.max(MIN_PANEL_HEIGHT, browser.browserHeight - 5);
495
+ return;
496
+ }
497
+ if (matchesKey(data, "c")) {
498
+ browser.showOnlyChanged = !browser.showOnlyChanged;
499
+ browser.selectedIndex = 0;
500
+ return;
501
+ }
502
+ if (matchesKey(data, "]")) {
503
+ navigateToChange(1);
504
+ return;
505
+ }
506
+ if (matchesKey(data, "[")) {
507
+ navigateToChange(-1);
508
+ return;
509
+ }
510
+ }
511
+
512
+ return {
513
+ render(width: number): string[] {
514
+ const now = Date.now();
515
+ if (now - browser.lastPollTime > POLL_INTERVAL_MS) {
516
+ browser.lastPollTime = now;
517
+ refreshGitStatus();
518
+ }
519
+
520
+ if (viewer.isOpen()) {
521
+ return viewer.render(width);
522
+ }
523
+
524
+ return renderBrowser(width);
525
+ },
526
+
527
+ handleInput(data: string): void {
528
+ if (viewer.isOpen()) {
529
+ handleViewerInput(data);
530
+ } else {
531
+ handleBrowserInput(data);
532
+ }
533
+ },
534
+
535
+ invalidate(): void {},
536
+ };
537
+ }
package/comment.ts ADDED
@@ -0,0 +1,5 @@
1
+ import type { CommentPayload } from "./viewer";
2
+
3
+ export function formatCommentMessage(payload: CommentPayload, comment: string): string {
4
+ return `In \`${payload.relPath}\` (${payload.lineRange}):\n\`\`\`${payload.ext}\n${payload.selectedText}\n\`\`\`\n\nComment: ${comment}\n`;
5
+ }
package/constants.ts ADDED
@@ -0,0 +1,12 @@
1
+ export const MAX_TREE_DEPTH = 6;
2
+ export const POLL_INTERVAL_MS = 3000;
3
+
4
+ export const DEFAULT_VIEWER_HEIGHT = 18;
5
+ export const DEFAULT_BROWSER_HEIGHT = 15;
6
+
7
+ export const MIN_PANEL_HEIGHT = 5;
8
+ export const MAX_VIEWER_HEIGHT = 50;
9
+ export const MAX_BROWSER_HEIGHT = 40;
10
+
11
+ export const VIEWER_SCROLL_MARGIN = 10;
12
+ export const SEARCH_SCROLL_OFFSET = 3;