@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/viewer.ts ADDED
@@ -0,0 +1,422 @@
1
+ import type { Theme } from "@mariozechner/pi-coding-agent";
2
+ import { Key, matchesKey, truncateToWidth } from "@mariozechner/pi-tui";
3
+ import { readFileSync } from "node:fs";
4
+ import { relative } from "node:path";
5
+
6
+ import {
7
+ DEFAULT_VIEWER_HEIGHT,
8
+ MAX_VIEWER_HEIGHT,
9
+ MIN_PANEL_HEIGHT,
10
+ SEARCH_SCROLL_OFFSET,
11
+ VIEWER_SCROLL_MARGIN,
12
+ } from "./constants";
13
+ import { loadFileContent } from "./file-viewer";
14
+ import type { FileNode } from "./types";
15
+ import { isUntrackedStatus } from "./utils";
16
+ import { isPrintableChar } from "./input-utils";
17
+
18
+ export interface CommentPayload {
19
+ relPath: string;
20
+ lineRange: string;
21
+ ext: string;
22
+ selectedText: string;
23
+ }
24
+
25
+ export type ViewerAction =
26
+ | { type: "none" }
27
+ | { type: "close" }
28
+ | { type: "navigate"; direction: 1 | -1 };
29
+
30
+ type ViewerMode = "normal" | "select" | "search" | "comment";
31
+
32
+ interface ViewerState {
33
+ file: FileNode | null;
34
+ content: string[];
35
+ rawContent: string;
36
+ scroll: number;
37
+ diffMode: boolean;
38
+ mode: ViewerMode;
39
+ selectStart: number;
40
+ selectEnd: number;
41
+ commentText: string;
42
+ searchQuery: string;
43
+ searchMatches: number[];
44
+ searchIndex: number;
45
+ lastRenderWidth: number;
46
+ height: number;
47
+ }
48
+
49
+ export interface ViewerController {
50
+ isOpen(): boolean;
51
+ getFile(): FileNode | null;
52
+ setFile(file: FileNode): void;
53
+ updateFileRef(file: FileNode | null): void;
54
+ close(): void;
55
+ render(width: number): string[];
56
+ handleInput(data: string): ViewerAction;
57
+ }
58
+
59
+ export function createViewer(
60
+ cwd: string,
61
+ theme: Theme,
62
+ requestComment: (payload: CommentPayload, comment: string) => void
63
+ ): ViewerController {
64
+ const state: ViewerState = {
65
+ file: null,
66
+ content: [],
67
+ rawContent: "",
68
+ scroll: 0,
69
+ diffMode: false,
70
+ mode: "normal",
71
+ selectStart: 0,
72
+ selectEnd: 0,
73
+ commentText: "",
74
+ searchQuery: "",
75
+ searchMatches: [],
76
+ searchIndex: 0,
77
+ lastRenderWidth: 0,
78
+ height: DEFAULT_VIEWER_HEIGHT,
79
+ };
80
+
81
+ function resetSearch(): void {
82
+ state.searchQuery = "";
83
+ state.searchMatches = [];
84
+ state.searchIndex = 0;
85
+ }
86
+
87
+ function resetComment(): void {
88
+ state.commentText = "";
89
+ }
90
+
91
+ function clearSelection(): void {
92
+ state.selectStart = 0;
93
+ state.selectEnd = 0;
94
+ }
95
+
96
+ function setMode(mode: ViewerMode): void {
97
+ state.mode = mode;
98
+ if (mode !== "search") resetSearch();
99
+ if (mode !== "comment") resetComment();
100
+ if (mode === "normal") {
101
+ clearSelection();
102
+ }
103
+ }
104
+
105
+ function reloadContent(width: number): void {
106
+ if (!state.file) return;
107
+ const hasChanges = !!state.file.gitStatus;
108
+ state.content = loadFileContent(state.file.path, cwd, state.diffMode, hasChanges, width);
109
+ state.lastRenderWidth = width;
110
+ }
111
+
112
+ function updateSearchMatches(): void {
113
+ state.searchMatches = [];
114
+ if (!state.searchQuery) return;
115
+
116
+ const q = state.searchQuery.toLowerCase();
117
+ const rawLines = state.rawContent.split("\n");
118
+ for (let i = 0; i < rawLines.length; i++) {
119
+ if (rawLines[i].toLowerCase().includes(q)) {
120
+ state.searchMatches.push(i);
121
+ }
122
+ }
123
+ state.searchIndex = 0;
124
+
125
+ if (state.searchMatches.length > 0) {
126
+ state.scroll = Math.max(0, state.searchMatches[0] - SEARCH_SCROLL_OFFSET);
127
+ }
128
+ }
129
+
130
+ function jumpToNextMatch(direction: 1 | -1): void {
131
+ if (state.searchMatches.length === 0) return;
132
+ state.searchIndex += direction;
133
+ if (state.searchIndex < 0) state.searchIndex = state.searchMatches.length - 1;
134
+ if (state.searchIndex >= state.searchMatches.length) state.searchIndex = 0;
135
+ state.scroll = Math.max(0, state.searchMatches[state.searchIndex] - SEARCH_SCROLL_OFFSET);
136
+ }
137
+
138
+ function buildCommentPayload(): CommentPayload | null {
139
+ if (!state.file) return null;
140
+
141
+ const rawLines = state.rawContent.split("\n");
142
+ const selectedText = rawLines.slice(state.selectStart, state.selectEnd + 1).join("\n");
143
+ const relPath = relative(cwd, state.file.path);
144
+ const lineRange = state.selectStart === state.selectEnd
145
+ ? `line ${state.selectStart + 1}`
146
+ : `lines ${state.selectStart + 1}-${state.selectEnd + 1}`;
147
+ const ext = state.file.name.split(".").pop() || "";
148
+
149
+ return { relPath, lineRange, ext, selectedText };
150
+ }
151
+
152
+ function sendComment(comment: string): void {
153
+ const payload = buildCommentPayload();
154
+ if (!payload) return;
155
+
156
+ requestComment(payload, comment);
157
+ setMode("normal");
158
+ }
159
+
160
+ function renderHeader(width: number): string {
161
+ if (!state.file) return "";
162
+ const isUntracked = isUntrackedStatus(state.file.gitStatus);
163
+
164
+ let header = theme.bold(state.file.name);
165
+ if (isUntracked) {
166
+ header += theme.fg("dim", " [UNTRACKED]");
167
+ } else if (state.diffMode) {
168
+ header += theme.fg("warning", " [DIFF]");
169
+ }
170
+ if (state.mode === "select" || state.mode === "comment") {
171
+ header += theme.fg("accent", ` [SELECT ${state.selectStart + 1}-${state.selectEnd + 1}]`);
172
+ }
173
+
174
+ if (state.file.diffStats) {
175
+ if (state.file.diffStats.additions > 0) {
176
+ header += theme.fg("success", ` +${state.file.diffStats.additions}`);
177
+ }
178
+ if (state.file.diffStats.deletions > 0) {
179
+ header += theme.fg("error", ` -${state.file.diffStats.deletions}`);
180
+ }
181
+ } else if (isUntracked && state.file.lineCount) {
182
+ header += theme.fg("success", ` +${state.file.lineCount}`);
183
+ }
184
+
185
+ if (state.file.lineCount) {
186
+ header += theme.fg("dim", ` ${state.file.lineCount}L`);
187
+ }
188
+
189
+ if (state.mode === "search") {
190
+ header += theme.fg("accent", ` /${state.searchQuery}█`);
191
+ } else if (state.searchQuery && state.searchMatches.length > 0) {
192
+ header += theme.fg("dim", ` [${state.searchIndex + 1}/${state.searchMatches.length}]`);
193
+ }
194
+
195
+ return truncateToWidth(header, width);
196
+ }
197
+
198
+ function renderFooter(width: number): string[] {
199
+ const lines: string[] = [];
200
+ const pct = state.content.length > 0
201
+ ? Math.round((state.scroll / Math.max(1, state.content.length - state.height)) * 100)
202
+ : 0;
203
+
204
+ if (state.mode === "comment") {
205
+ const prompt = theme.fg("accent", `Comment: ${state.commentText}█`);
206
+ lines.push(truncateToWidth(prompt, width));
207
+ lines.push(theme.fg("borderMuted", "─".repeat(width)));
208
+ }
209
+
210
+ let help: string;
211
+ if (state.mode === "comment") {
212
+ help = theme.fg("dim", "Enter: send Esc: cancel");
213
+ } else if (state.mode === "select") {
214
+ help = theme.fg("dim", "j/k: extend c: comment Esc: cancel");
215
+ } else if (state.mode === "search") {
216
+ help = theme.fg("dim", "Type to search Enter: confirm Esc: cancel");
217
+ } else {
218
+ const isUntracked = state.file && isUntrackedStatus(state.file.gitStatus);
219
+ help = theme.fg(
220
+ "dim",
221
+ `j/k: scroll /: search n/N: next/prev match []: files ${state.file?.gitStatus && !isUntracked ? "d: diff " : ""}q: back ${pct}%`
222
+ );
223
+ }
224
+ lines.push(truncateToWidth(help, width));
225
+
226
+ return lines;
227
+ }
228
+
229
+ return {
230
+ isOpen(): boolean {
231
+ return !!state.file;
232
+ },
233
+
234
+ getFile(): FileNode | null {
235
+ return state.file;
236
+ },
237
+
238
+ setFile(file: FileNode): void {
239
+ state.file = file;
240
+ state.scroll = 0;
241
+ state.diffMode = !!file.gitStatus && !isUntrackedStatus(file.gitStatus);
242
+ setMode("normal");
243
+ state.content = [];
244
+ state.lastRenderWidth = 0;
245
+
246
+ try {
247
+ state.rawContent = readFileSync(file.path, "utf-8");
248
+ } catch {
249
+ state.rawContent = "";
250
+ }
251
+ },
252
+
253
+ updateFileRef(file: FileNode | null): void {
254
+ state.file = file;
255
+ },
256
+
257
+ close(): void {
258
+ state.file = null;
259
+ state.content = [];
260
+ setMode("normal");
261
+ },
262
+
263
+ render(width: number): string[] {
264
+ if (!state.file) return [];
265
+
266
+ if (state.lastRenderWidth !== width || state.content.length === 0) {
267
+ reloadContent(width);
268
+ }
269
+
270
+ const lines: string[] = [];
271
+ lines.push(renderHeader(width));
272
+ lines.push(theme.fg("borderMuted", "─".repeat(width)));
273
+
274
+ const visible = state.content.slice(state.scroll, state.scroll + state.height);
275
+ for (let i = 0; i < state.height; i++) {
276
+ if (i < visible.length) {
277
+ const lineIdx = state.scroll + i;
278
+ let line = truncateToWidth(visible[i] || "", width);
279
+ if ((state.mode === "select" || state.mode === "comment") && lineIdx >= state.selectStart && lineIdx <= state.selectEnd) {
280
+ line = theme.bg("selectedBg", line);
281
+ }
282
+ lines.push(line);
283
+ } else {
284
+ lines.push(theme.fg("dim", "~"));
285
+ }
286
+ }
287
+
288
+ lines.push(theme.fg("borderMuted", "─".repeat(width)));
289
+ lines.push(...renderFooter(width));
290
+
291
+ return lines;
292
+ },
293
+
294
+ handleInput(data: string): ViewerAction {
295
+ if (!state.file) return { type: "none" };
296
+
297
+ if (state.mode === "comment") {
298
+ if (matchesKey(data, Key.enter)) {
299
+ const comment = state.commentText.trim();
300
+ if (comment) {
301
+ sendComment(comment);
302
+ } else {
303
+ setMode("normal");
304
+ }
305
+ } else if (matchesKey(data, Key.escape)) {
306
+ setMode("normal");
307
+ } else if (matchesKey(data, Key.backspace)) {
308
+ state.commentText = state.commentText.slice(0, -1);
309
+ } else if (isPrintableChar(data)) {
310
+ state.commentText += data;
311
+ }
312
+ return { type: "none" };
313
+ }
314
+
315
+ if (state.mode === "search") {
316
+ if (matchesKey(data, Key.enter)) {
317
+ setMode("normal");
318
+ } else if (matchesKey(data, Key.escape)) {
319
+ setMode("normal");
320
+ } else if (matchesKey(data, Key.backspace)) {
321
+ state.searchQuery = state.searchQuery.slice(0, -1);
322
+ updateSearchMatches();
323
+ } else if (isPrintableChar(data)) {
324
+ state.searchQuery += data;
325
+ updateSearchMatches();
326
+ }
327
+ return { type: "none" };
328
+ }
329
+
330
+ if (matchesKey(data, "q") && state.mode !== "select") {
331
+ return { type: "close" };
332
+ }
333
+ if (matchesKey(data, Key.escape)) {
334
+ if (state.mode === "select") {
335
+ setMode("normal");
336
+ } else if (state.searchQuery) {
337
+ resetSearch();
338
+ } else {
339
+ return { type: "close" };
340
+ }
341
+ return { type: "none" };
342
+ }
343
+ if (matchesKey(data, "/") && state.mode !== "select") {
344
+ setMode("search");
345
+ return { type: "none" };
346
+ }
347
+ if (matchesKey(data, "n") && state.mode !== "select" && state.searchMatches.length > 0) {
348
+ jumpToNextMatch(1);
349
+ return { type: "none" };
350
+ }
351
+ if (matchesKey(data, "N") && state.mode !== "select" && state.searchMatches.length > 0) {
352
+ jumpToNextMatch(-1);
353
+ return { type: "none" };
354
+ }
355
+ if (matchesKey(data, "j") || matchesKey(data, Key.down)) {
356
+ if (state.mode === "select") {
357
+ state.selectEnd = Math.min(state.content.length - 1, state.selectEnd + 1);
358
+ } else {
359
+ state.scroll = Math.min(Math.max(0, state.content.length - VIEWER_SCROLL_MARGIN), state.scroll + 1);
360
+ }
361
+ return { type: "none" };
362
+ }
363
+ if (matchesKey(data, "k") || matchesKey(data, Key.up)) {
364
+ if (state.mode === "select") {
365
+ state.selectEnd = Math.max(state.selectStart, state.selectEnd - 1);
366
+ } else {
367
+ state.scroll = Math.max(0, state.scroll - 1);
368
+ }
369
+ return { type: "none" };
370
+ }
371
+ if (matchesKey(data, Key.pageDown)) {
372
+ state.scroll = Math.min(Math.max(0, state.content.length - state.height), state.scroll + state.height);
373
+ return { type: "none" };
374
+ }
375
+ if (matchesKey(data, Key.pageUp)) {
376
+ state.scroll = Math.max(0, state.scroll - state.height);
377
+ return { type: "none" };
378
+ }
379
+ if (matchesKey(data, "g")) {
380
+ state.scroll = 0;
381
+ return { type: "none" };
382
+ }
383
+ if (matchesKey(data, "G")) {
384
+ state.scroll = Math.max(0, state.content.length - state.height);
385
+ return { type: "none" };
386
+ }
387
+ if (matchesKey(data, "+") || matchesKey(data, "=")) {
388
+ state.height = Math.min(MAX_VIEWER_HEIGHT, state.height + 5);
389
+ return { type: "none" };
390
+ }
391
+ if (matchesKey(data, "-") || matchesKey(data, "_")) {
392
+ state.height = Math.max(MIN_PANEL_HEIGHT, state.height - 5);
393
+ return { type: "none" };
394
+ }
395
+ if (matchesKey(data, "d") && state.mode !== "select" && state.file.gitStatus && !isUntrackedStatus(state.file.gitStatus)) {
396
+ state.diffMode = !state.diffMode;
397
+ state.lastRenderWidth = 0;
398
+ state.scroll = 0;
399
+ return { type: "none" };
400
+ }
401
+ if (matchesKey(data, "v") && state.mode !== "select") {
402
+ state.mode = "select";
403
+ state.selectStart = state.scroll;
404
+ state.selectEnd = state.scroll;
405
+ return { type: "none" };
406
+ }
407
+ if (matchesKey(data, "c") && state.mode === "select") {
408
+ state.mode = "comment";
409
+ state.commentText = "";
410
+ return { type: "none" };
411
+ }
412
+ if (matchesKey(data, "]") && state.mode !== "select") {
413
+ return { type: "navigate", direction: 1 };
414
+ }
415
+ if (matchesKey(data, "[") && state.mode !== "select") {
416
+ return { type: "navigate", direction: -1 };
417
+ }
418
+
419
+ return { type: "none" };
420
+ },
421
+ };
422
+ }