@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/CHANGELOG.md +70 -0
- package/DESIGN.md +452 -0
- package/README.md +120 -0
- package/TODO.md +141 -0
- package/browser.ts +537 -0
- package/comment.ts +5 -0
- package/constants.ts +12 -0
- package/demo.svg +1 -0
- package/file-tree.ts +156 -0
- package/file-viewer.ts +93 -0
- package/git.ts +72 -0
- package/index.ts +139 -0
- package/input-utils.ts +3 -0
- package/package.json +22 -0
- package/types.ts +26 -0
- package/utils.ts +26 -0
- package/viewer.ts +422 -0
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;
|