pi-files-widget-overlay 0.3.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.ts ADDED
@@ -0,0 +1,729 @@
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
2
+ import { Key, matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
3
+ import { readFileSync, statSync } from "node:fs";
4
+ import { relative } from "node:path";
5
+
6
+ import {
7
+ DEFAULT_VIEWER_HEIGHT,
8
+ getResponsivePanelHeight,
9
+ OVERLAY_MAX_HEIGHT_RATIO,
10
+ MAX_VIEWER_HEIGHT,
11
+ MIN_PANEL_HEIGHT,
12
+ SEARCH_SCROLL_OFFSET,
13
+ } from "./constants";
14
+ import { loadFileContent, type RenderedLines } from "./file-viewer";
15
+ import type { FileNode } from "./types";
16
+ import { isMarkdownPath, isUntrackedStatus } from "./utils";
17
+ import { createTextInputBuffer } from "./input-utils";
18
+
19
+ const COMMENT_EDITOR_MAX_VISIBLE_LINES = 4;
20
+
21
+ export interface CommentPayload {
22
+ relPath: string;
23
+ lineRange: string;
24
+ ext: string;
25
+ selectedText: string;
26
+ isDiff?: boolean;
27
+ }
28
+
29
+ export type ViewerAction =
30
+ | { type: "none" }
31
+ | { type: "close" }
32
+ | { type: "navigate"; direction: 1 | -1 };
33
+
34
+ type ViewerMode = "normal" | "select" | "search" | "comment";
35
+
36
+ interface ViewerState {
37
+ file: FileNode | null;
38
+ renderedLines: RenderedLines;
39
+ rawContent: string;
40
+ scroll: number;
41
+ cursor: number;
42
+ diffMode: boolean;
43
+ renderMarkdown: boolean;
44
+ wordWrap: boolean;
45
+ mode: ViewerMode;
46
+ selectStart: number;
47
+ selectEnd: number;
48
+ commentText: string;
49
+ searchQuery: string;
50
+ searchMatches: number[];
51
+ searchIndex: number;
52
+ lastRenderWidth: number;
53
+ lastLoadedMtimeMs: number | null;
54
+ height: number;
55
+ }
56
+
57
+ export interface ViewerController {
58
+ isOpen(): boolean;
59
+ getFile(): FileNode | null;
60
+ setFile(file: FileNode): void;
61
+ updateFileRef(file: FileNode | null): void;
62
+ close(): void;
63
+ render(width: number): string[];
64
+ handleInput(data: string): ViewerAction;
65
+ }
66
+
67
+ export interface ViewerConfig {
68
+ getRoot: () => string;
69
+ projectCwd: string;
70
+ }
71
+
72
+ export function createViewer(
73
+ config: ViewerConfig,
74
+ theme: Theme,
75
+ requestComment: (payload: CommentPayload, comment: string) => void
76
+ ): ViewerController {
77
+ const { getRoot, projectCwd } = config;
78
+ const searchInput = createTextInputBuffer();
79
+ const commentInput = createTextInputBuffer({ preserveNewlines: true });
80
+
81
+ const state: ViewerState = {
82
+ file: null,
83
+ renderedLines: { lines: [], rowGroups: [], logicalLines: [] },
84
+ rawContent: "",
85
+ scroll: 0,
86
+ cursor: 0,
87
+ diffMode: false,
88
+ renderMarkdown: true,
89
+ wordWrap: false,
90
+ mode: "normal",
91
+ selectStart: 0,
92
+ selectEnd: 0,
93
+ commentText: "",
94
+ searchQuery: "",
95
+ searchMatches: [],
96
+ searchIndex: 0,
97
+ lastRenderWidth: 0,
98
+ lastLoadedMtimeMs: null,
99
+ height: getResponsivePanelHeight(DEFAULT_VIEWER_HEIGHT, MAX_VIEWER_HEIGHT, 8),
100
+ };
101
+
102
+ function isMarkdownFile(): boolean {
103
+ return !!state.file && isMarkdownPath(state.file.path);
104
+ }
105
+
106
+ function isRenderedMarkdownMode(): boolean {
107
+ return isMarkdownFile() && !state.diffMode && state.renderMarkdown;
108
+ }
109
+
110
+ function switchMarkdownToRaw(): boolean {
111
+ if (!isRenderedMarkdownMode()) return false;
112
+ state.renderMarkdown = false;
113
+ state.cursor = 0;
114
+ state.selectStart = 0;
115
+ state.selectEnd = 0;
116
+ state.scroll = 0;
117
+ const width = state.lastRenderWidth || process.stdout.columns || 80;
118
+ reloadContent(width);
119
+ return true;
120
+ }
121
+
122
+ function toggleMarkdownMode(): void {
123
+ if (!isMarkdownFile() || state.diffMode) return;
124
+ state.renderMarkdown = !state.renderMarkdown;
125
+ state.lastRenderWidth = 0;
126
+ resetSearch();
127
+ setMode("normal");
128
+ clampScroll();
129
+ }
130
+
131
+ function resetSearch(): void {
132
+ state.searchQuery = "";
133
+ state.searchMatches = [];
134
+ state.searchIndex = 0;
135
+ }
136
+
137
+ function resetComment(): void {
138
+ state.commentText = "";
139
+ }
140
+
141
+ function clearSelection(): void {
142
+ state.selectStart = 0;
143
+ state.selectEnd = 0;
144
+ }
145
+
146
+ function setMode(mode: ViewerMode, preserveSearch = false): void {
147
+ if (mode !== state.mode) {
148
+ searchInput.reset();
149
+ commentInput.reset();
150
+ }
151
+
152
+ state.mode = mode;
153
+ if (mode !== "search" && !preserveSearch) resetSearch();
154
+ if (mode !== "comment") resetComment();
155
+ if (mode === "normal") {
156
+ clearSelection();
157
+ }
158
+ }
159
+
160
+ function getMaxScroll(): number {
161
+ return Math.max(0, state.renderedLines.lines.length - state.height);
162
+ }
163
+
164
+ function refreshRawContent(): void {
165
+ if (!state.file) return;
166
+
167
+ try {
168
+ const fileStat = statSync(state.file.path);
169
+ state.rawContent = readFileSync(state.file.path, "utf-8");
170
+ state.file.lineCount = state.rawContent.split("\n").length;
171
+ state.lastLoadedMtimeMs = fileStat.mtimeMs;
172
+ } catch {
173
+ state.rawContent = "";
174
+ state.file.lineCount = undefined;
175
+ state.lastLoadedMtimeMs = null;
176
+ }
177
+ }
178
+
179
+ function hasFileChangedOnDisk(): boolean {
180
+ if (!state.file) return false;
181
+
182
+ try {
183
+ return state.lastLoadedMtimeMs === null || statSync(state.file.path).mtimeMs !== state.lastLoadedMtimeMs;
184
+ } catch {
185
+ return state.lastLoadedMtimeMs !== null;
186
+ }
187
+ }
188
+
189
+ function clampScroll(): void {
190
+ state.scroll = Math.min(getMaxScroll(), Math.max(0, state.scroll));
191
+ }
192
+
193
+ function ensureCursorVisible(): void {
194
+ state.cursor = Math.min(Math.max(0, state.cursor), Math.max(0, state.renderedLines.lines.length - 1));
195
+ if (state.cursor < state.scroll) state.scroll = state.cursor;
196
+ if (state.cursor >= state.scroll + state.height) state.scroll = state.cursor - state.height + 1;
197
+ clampScroll();
198
+ }
199
+
200
+ function rowGroup(index: number): number {
201
+ return state.renderedLines.rowGroups[index] ?? index;
202
+ }
203
+
204
+ function groupStart(index: number): number {
205
+ const group = rowGroup(index);
206
+ while (index > 0 && rowGroup(index - 1) === group) index--;
207
+ return index;
208
+ }
209
+
210
+ function firstRowForGroup(group: number): number {
211
+ const row = state.renderedLines.rowGroups.indexOf(group);
212
+ return row === -1 ? Math.min(Math.max(0, group), Math.max(0, state.renderedLines.lines.length - 1)) : row;
213
+ }
214
+
215
+ function groupEnd(index: number): number {
216
+ const group = rowGroup(index);
217
+ while (index + 1 < state.renderedLines.lines.length && rowGroup(index + 1) === group) index++;
218
+ return index;
219
+ }
220
+
221
+ function selectionBounds(): { start: number; end: number } {
222
+ return {
223
+ start: Math.min(rowGroup(state.selectStart), rowGroup(state.selectEnd)),
224
+ end: Math.max(rowGroup(state.selectStart), rowGroup(state.selectEnd)),
225
+ };
226
+ }
227
+
228
+ function moveCursor(direction: 1 | -1): void {
229
+ const next = stepGroup(state.cursor, direction);
230
+ if (next !== null) state.cursor = next;
231
+ ensureCursorVisible();
232
+ }
233
+
234
+ function stepGroup(index: number, direction: 1 | -1): number | null {
235
+ const next = direction > 0 ? groupEnd(index) + 1 : groupStart(index) - 1;
236
+ return next >= 0 && next < state.renderedLines.lines.length ? next : null;
237
+ }
238
+
239
+ function moveCursorByGroups(direction: 1 | -1, count: number): void {
240
+ for (let step = 0; step < count; step++) {
241
+ const next = stepGroup(state.cursor, direction);
242
+ if (next === null) break;
243
+ state.cursor = next;
244
+ }
245
+ ensureCursorVisible();
246
+ }
247
+
248
+ function reloadContent(width: number): void {
249
+ if (!state.file) return;
250
+ const cursorGroup = rowGroup(state.cursor);
251
+ const selectStartGroup = rowGroup(state.selectStart);
252
+ const selectEndGroup = rowGroup(state.selectEnd);
253
+ const preserveSelection = state.mode === "select" || state.mode === "comment";
254
+ refreshRawContent();
255
+ const hasChanges = !!state.file.gitStatus;
256
+ const result = loadFileContent(
257
+ state.file.path,
258
+ { cwd: getRoot(), diffMode: state.diffMode, hasChanges, width, renderMarkdown: state.renderMarkdown, wordWrap: state.wordWrap },
259
+ theme
260
+ );
261
+ state.renderedLines = result;
262
+ state.cursor = firstRowForGroup(cursorGroup);
263
+ if (preserveSelection) {
264
+ state.selectStart = firstRowForGroup(selectStartGroup);
265
+ state.selectEnd = firstRowForGroup(selectEndGroup);
266
+ }
267
+ ensureCursorVisible();
268
+ state.renderMarkdown = result.renderedMarkdown;
269
+ state.lastRenderWidth = width;
270
+ clampScroll();
271
+ if (state.searchQuery) {
272
+ updateSearchMatches({ preserveActiveMatch: true });
273
+ }
274
+ }
275
+
276
+ function updateSearchMatches(options: { preserveActiveMatch?: boolean } = {}): void {
277
+ const activeMatch = options.preserveActiveMatch ? state.searchMatches[state.searchIndex] : undefined;
278
+
279
+ state.searchMatches = [];
280
+ if (!state.searchQuery) {
281
+ state.searchIndex = 0;
282
+ return;
283
+ }
284
+
285
+ const q = state.searchQuery.toLowerCase();
286
+ const searchableLines = state.diffMode ? state.renderedLines.logicalLines : state.rawContent.split("\n");
287
+ for (let i = 0; i < searchableLines.length; i++) {
288
+ if (searchableLines[i].replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "").toLowerCase().includes(q)) {
289
+ state.searchMatches.push(i);
290
+ }
291
+ }
292
+
293
+ if (state.searchMatches.length === 0) {
294
+ state.searchIndex = 0;
295
+ return;
296
+ }
297
+
298
+ if (activeMatch === undefined) {
299
+ state.searchIndex = 0;
300
+ } else {
301
+ let nearestIndex = 0;
302
+ let nearestDistance = Number.POSITIVE_INFINITY;
303
+ for (let i = 0; i < state.searchMatches.length; i++) {
304
+ const distance = Math.abs(state.searchMatches[i] - activeMatch);
305
+ if (distance < nearestDistance) {
306
+ nearestDistance = distance;
307
+ nearestIndex = i;
308
+ }
309
+ }
310
+ state.searchIndex = nearestIndex;
311
+ }
312
+
313
+ state.cursor = firstRowForGroup(state.searchMatches[state.searchIndex]);
314
+ state.scroll = Math.max(0, state.cursor - SEARCH_SCROLL_OFFSET);
315
+ clampScroll();
316
+ }
317
+
318
+ function jumpToNextMatch(direction: 1 | -1): void {
319
+ if (state.searchMatches.length === 0) return;
320
+ state.searchIndex += direction;
321
+ if (state.searchIndex < 0) state.searchIndex = state.searchMatches.length - 1;
322
+ if (state.searchIndex >= state.searchMatches.length) state.searchIndex = 0;
323
+ state.cursor = firstRowForGroup(state.searchMatches[state.searchIndex]);
324
+ state.scroll = Math.max(0, state.cursor - SEARCH_SCROLL_OFFSET);
325
+ clampScroll();
326
+ }
327
+
328
+ function buildCommentPayload(): CommentPayload | null {
329
+ if (!state.file) return null;
330
+
331
+ const rawLines = state.rawContent.split("\n");
332
+ const bounds = selectionBounds();
333
+ const selectedText = state.diffMode
334
+ ? state.renderedLines.logicalLines
335
+ .slice(bounds.start, bounds.end + 1)
336
+ .map(line => line.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "").replace(/^([+-]?)\s*\d+\s│\s?/, "$1 "))
337
+ .join("\n")
338
+ : rawLines.slice(bounds.start, bounds.end + 1).join("\n");
339
+ const rel = relative(projectCwd, state.file.path);
340
+ const relPath = !rel || rel.startsWith("..") ? state.file.path : rel;
341
+ const lineRange = state.diffMode
342
+ ? `diff lines ${bounds.start + 1}-${bounds.end + 1}`
343
+ : bounds.start === bounds.end
344
+ ? `line ${bounds.start + 1}`
345
+ : `lines ${bounds.start + 1}-${bounds.end + 1}`;
346
+ const ext = state.diffMode ? "diff" : state.file.name.split(".").pop() || "";
347
+
348
+ return { relPath, lineRange, ext, selectedText, isDiff: state.diffMode };
349
+ }
350
+
351
+ function sendComment(comment: string): void {
352
+ const payload = buildCommentPayload();
353
+ if (!payload) return;
354
+
355
+ requestComment(payload, comment);
356
+ setMode("normal");
357
+ }
358
+
359
+ function renderHeader(width: number): string {
360
+ if (!state.file) return "";
361
+ const isUntracked = isUntrackedStatus(state.file.gitStatus);
362
+
363
+ let header = theme.bold(state.file.name);
364
+ if (isUntracked) {
365
+ header += theme.fg("dim", " [UNTRACKED]");
366
+ } else if (state.diffMode) {
367
+ header += theme.fg("warning", " [DIFF]");
368
+ } else if (isMarkdownFile()) {
369
+ header += theme.fg("accent", state.renderMarkdown ? " [RENDERED]" : " [RAW]");
370
+ }
371
+ header += theme.fg("accent", state.wordWrap ? " [WRAP]" : " [NO WRAP]");
372
+ if (state.mode === "select" || state.mode === "comment") {
373
+ const bounds = selectionBounds();
374
+ header += theme.fg("accent", ` [SELECT ${bounds.start + 1}-${bounds.end + 1}]`);
375
+ }
376
+
377
+ if (state.file.diffStats) {
378
+ if (state.file.diffStats.additions > 0) {
379
+ header += theme.fg("success", ` +${state.file.diffStats.additions}`);
380
+ }
381
+ if (state.file.diffStats.deletions > 0) {
382
+ header += theme.fg("error", ` -${state.file.diffStats.deletions}`);
383
+ }
384
+ } else if (isUntracked && state.file.lineCount !== undefined) {
385
+ header += theme.fg("success", ` +${state.file.lineCount}`);
386
+ }
387
+
388
+ if (state.file.lineCount !== undefined) {
389
+ header += theme.fg("dim", ` ${state.file.lineCount}L`);
390
+ }
391
+
392
+ if (state.mode === "search") {
393
+ header += theme.fg("accent", ` /${state.searchQuery}█`);
394
+ } else if (state.searchQuery && state.searchMatches.length > 0) {
395
+ header += theme.fg("dim", ` [${state.searchIndex + 1}/${state.searchMatches.length}]`);
396
+ }
397
+
398
+ return truncateToWidth(header, width);
399
+ }
400
+
401
+ function renderCommentEditor(width: number): string[] {
402
+ const contentWidth = Math.max(1, width - 2);
403
+ const wrappedLines: string[] = [];
404
+ const logicalLines = state.commentText.split("\n");
405
+
406
+ for (const line of logicalLines) {
407
+ if (line.length === 0) {
408
+ wrappedLines.push("");
409
+ continue;
410
+ }
411
+ wrappedLines.push(...wrapTextWithAnsi(line, contentWidth));
412
+ }
413
+
414
+ if (wrappedLines.length === 0) {
415
+ wrappedLines.push("");
416
+ }
417
+
418
+ const lastIndex = wrappedLines.length - 1;
419
+ wrappedLines[lastIndex] = `${wrappedLines[lastIndex]}█`;
420
+
421
+ const overflow = Math.max(0, wrappedLines.length - COMMENT_EDITOR_MAX_VISIBLE_LINES);
422
+ const visibleLines = wrappedLines.slice(-COMMENT_EDITOR_MAX_VISIBLE_LINES);
423
+ if (overflow > 0 && visibleLines.length > 0) {
424
+ visibleLines[0] = `…${visibleLines[0]}`;
425
+ }
426
+
427
+ return [
428
+ truncateToWidth(theme.fg("accent", "Comment:"), width),
429
+ ...visibleLines.map(line => truncateToWidth(` ${line}`, width)),
430
+ ];
431
+ }
432
+
433
+ function renderFooter(width: number): string[] {
434
+ const lines: string[] = [];
435
+ const pct = state.renderedLines.lines.length > 0
436
+ ? Math.round((state.scroll / Math.max(1, state.renderedLines.lines.length - state.height)) * 100)
437
+ : 0;
438
+
439
+ if (state.mode === "comment") {
440
+ lines.push(...renderCommentEditor(width));
441
+ lines.push(theme.fg("border", "─".repeat(width)));
442
+ }
443
+
444
+ let help: string;
445
+ if (state.mode === "comment") {
446
+ help = theme.fg("dim", "Enter: newline Ctrl+Enter/Ctrl+D: send Esc: cancel");
447
+ } else if (state.mode === "select") {
448
+ help = theme.fg("dim", "j/k: extend c: comment v/Esc: cancel");
449
+ } else if (state.mode === "search") {
450
+ help = theme.fg("dim", "Type to search Enter: confirm Esc: cancel");
451
+ } else {
452
+ const isUntracked = state.file && isUntrackedStatus(state.file.gitStatus);
453
+ const markdownHelp = isMarkdownFile() && !state.diffMode ? "m: raw/render " : "";
454
+ help = theme.fg(
455
+ "dim",
456
+ `j/k: cursor v: select /: search n/N: next/prev match w: wrap ${markdownHelp}[]: files ${state.file?.gitStatus && !isUntracked ? "d: diff " : ""}q: back ${pct}%`
457
+ );
458
+ }
459
+ lines.push(truncateToWidth(help, width));
460
+
461
+ return lines;
462
+ }
463
+
464
+ return {
465
+ isOpen(): boolean {
466
+ return !!state.file;
467
+ },
468
+
469
+ getFile(): FileNode | null {
470
+ return state.file;
471
+ },
472
+
473
+ setFile(file: FileNode): void {
474
+ state.file = file;
475
+ state.scroll = 0;
476
+ state.cursor = 0;
477
+ state.diffMode = !!file.gitStatus && !isUntrackedStatus(file.gitStatus);
478
+ state.renderMarkdown = isMarkdownPath(file.path);
479
+ state.wordWrap = false;
480
+ setMode("normal");
481
+ state.renderedLines = { lines: [], rowGroups: [], logicalLines: [] };
482
+ state.lastRenderWidth = 0;
483
+ state.lastLoadedMtimeMs = null;
484
+ refreshRawContent();
485
+ },
486
+
487
+ updateFileRef(file: FileNode | null): void {
488
+ state.file = file;
489
+ },
490
+
491
+ close(): void {
492
+ state.file = null;
493
+ state.renderedLines = { lines: [], rowGroups: [], logicalLines: [] };
494
+ state.rawContent = "";
495
+ state.renderMarkdown = true;
496
+ state.wordWrap = false;
497
+ state.lastLoadedMtimeMs = null;
498
+ setMode("normal");
499
+ },
500
+
501
+ render(width: number): string[] {
502
+ if (!state.file) return [];
503
+
504
+ const shouldAutoRefresh = state.mode !== "select" && state.mode !== "comment";
505
+ if (state.lastRenderWidth !== width || state.renderedLines.lines.length === 0 || (shouldAutoRefresh && hasFileChangedOnDisk())) {
506
+ reloadContent(width);
507
+ }
508
+
509
+ const lines: string[] = [];
510
+ lines.push(renderHeader(width));
511
+ lines.push(theme.fg("border", "─".repeat(width)));
512
+
513
+ const visible = state.renderedLines.lines.slice(state.scroll, state.scroll + state.height);
514
+ for (let i = 0; i < state.height; i++) {
515
+ if (i < visible.length) {
516
+ const lineIdx = state.scroll + i;
517
+ let line = truncateToWidth(visible[i] || "", width);
518
+ const group = rowGroup(lineIdx);
519
+ const bounds = selectionBounds();
520
+ const selected = (state.mode === "select" || state.mode === "comment") && group >= bounds.start && group <= bounds.end;
521
+ if (selected) {
522
+ const marker = lineIdx === groupEnd(state.selectEnd) ? "▸" : "┃";
523
+ const marked = line.replace("│", theme.fg("accent", marker));
524
+ line = theme.bg("selectedBg", marked + " ".repeat(Math.max(0, width - visibleWidth(marked))));
525
+ } else if (group === rowGroup(state.cursor)) {
526
+ line = theme.bg("selectedBg", line + " ".repeat(Math.max(0, width - visibleWidth(line))));
527
+ }
528
+ lines.push(line);
529
+ } else {
530
+ lines.push(theme.fg("dim", "~"));
531
+ }
532
+ }
533
+
534
+ lines.push(theme.fg("border", "─".repeat(width)));
535
+ lines.push(...renderFooter(width));
536
+
537
+ return lines;
538
+ },
539
+
540
+ handleInput(data: string): ViewerAction {
541
+ if (!state.file) return { type: "none" };
542
+
543
+ if (state.mode === "comment") {
544
+ if (matchesKey(data, "ctrl+enter") || matchesKey(data, "ctrl+d") || matchesKey(data, "alt+enter")) {
545
+ const comment = state.commentText.trim();
546
+ if (comment) {
547
+ sendComment(comment);
548
+ } else {
549
+ setMode("normal");
550
+ }
551
+ } else if (matchesKey(data, Key.enter) || matchesKey(data, "shift+enter")) {
552
+ state.commentText += "\n";
553
+ } else if (matchesKey(data, Key.escape) || matchesKey(data, Key.left)) {
554
+ setMode("normal");
555
+ } else if (matchesKey(data, Key.backspace)) {
556
+ state.commentText = state.commentText.slice(0, -1);
557
+ } else {
558
+ const text = commentInput.push(data);
559
+ if (text) {
560
+ state.commentText += text;
561
+ }
562
+ }
563
+ return { type: "none" };
564
+ }
565
+
566
+ if (state.mode === "search") {
567
+ if (matchesKey(data, Key.enter)) {
568
+ setMode("normal", true);
569
+ } else if (matchesKey(data, Key.escape) || matchesKey(data, Key.left)) {
570
+ setMode("normal");
571
+ } else if (matchesKey(data, Key.backspace)) {
572
+ state.searchQuery = state.searchQuery.slice(0, -1);
573
+ updateSearchMatches();
574
+ } else {
575
+ const text = searchInput.push(data);
576
+ if (text) {
577
+ state.searchQuery += text;
578
+ updateSearchMatches();
579
+ }
580
+ }
581
+ return { type: "none" };
582
+ }
583
+
584
+ if (matchesKey(data, "q") && state.mode !== "select") {
585
+ return { type: "close" };
586
+ }
587
+ if (matchesKey(data, Key.escape) || matchesKey(data, Key.left)) {
588
+ if (state.mode === "select") {
589
+ setMode("normal");
590
+ } else if (state.searchQuery) {
591
+ resetSearch();
592
+ } else {
593
+ return { type: "close" };
594
+ }
595
+ return { type: "none" };
596
+ }
597
+ if (matchesKey(data, "/") && state.mode !== "select") {
598
+ switchMarkdownToRaw();
599
+ setMode("search");
600
+ return { type: "none" };
601
+ }
602
+ if (matchesKey(data, "n") && state.mode !== "select" && state.searchMatches.length > 0) {
603
+ jumpToNextMatch(1);
604
+ return { type: "none" };
605
+ }
606
+ if (matchesKey(data, "shift+n") && state.mode !== "select" && state.searchMatches.length > 0) {
607
+ jumpToNextMatch(-1);
608
+ return { type: "none" };
609
+ }
610
+ if (matchesKey(data, "j") || matchesKey(data, Key.down)) {
611
+ if (state.mode === "select") {
612
+ const next = groupEnd(state.selectEnd) + 1;
613
+ if (next < state.renderedLines.lines.length) state.selectEnd = state.cursor = next;
614
+ ensureCursorVisible();
615
+ } else {
616
+ moveCursor(1);
617
+ }
618
+ return { type: "none" };
619
+ }
620
+ if (matchesKey(data, "k") || matchesKey(data, Key.up)) {
621
+ if (state.mode === "select") {
622
+ const previous = groupStart(state.selectEnd) - 1;
623
+ if (previous >= groupStart(state.selectStart)) state.selectEnd = state.cursor = previous;
624
+ ensureCursorVisible();
625
+ } else {
626
+ moveCursor(-1);
627
+ }
628
+ return { type: "none" };
629
+ }
630
+ if (matchesKey(data, Key.pageDown)) {
631
+ if (state.mode === "select") {
632
+ for (let step = 0; step < state.height; step++) {
633
+ const next = stepGroup(state.selectEnd, 1);
634
+ if (next === null) break;
635
+ state.selectEnd = state.cursor = next;
636
+ }
637
+ ensureCursorVisible();
638
+ } else {
639
+ moveCursorByGroups(1, state.height);
640
+ }
641
+ ensureCursorVisible();
642
+ return { type: "none" };
643
+ }
644
+ if (matchesKey(data, Key.pageUp)) {
645
+ if (state.mode === "select") {
646
+ const start = groupStart(state.selectStart);
647
+ for (let step = 0; step < state.height; step++) {
648
+ const previous = stepGroup(state.selectEnd, -1);
649
+ if (previous === null || previous < start) break;
650
+ state.selectEnd = state.cursor = previous;
651
+ }
652
+ ensureCursorVisible();
653
+ } else {
654
+ moveCursorByGroups(-1, state.height);
655
+ }
656
+ ensureCursorVisible();
657
+ return { type: "none" };
658
+ }
659
+ if (matchesKey(data, "g")) {
660
+ if (state.mode === "select") {
661
+ state.selectEnd = state.selectStart;
662
+ state.cursor = state.selectStart;
663
+ } else {
664
+ state.cursor = 0;
665
+ }
666
+ ensureCursorVisible();
667
+ return { type: "none" };
668
+ }
669
+ if (matchesKey(data, "shift+g")) {
670
+ state.cursor = groupStart(Math.max(0, state.renderedLines.lines.length - 1));
671
+ if (state.mode === "select") state.selectEnd = state.cursor;
672
+ ensureCursorVisible();
673
+ return { type: "none" };
674
+ }
675
+ if (matchesKey(data, "+") || matchesKey(data, "=")) {
676
+ const maximumHeight = getResponsivePanelHeight(MAX_VIEWER_HEIGHT, MAX_VIEWER_HEIGHT, 8, process.stdout.rows, OVERLAY_MAX_HEIGHT_RATIO);
677
+ state.height = Math.min(maximumHeight, state.height + 5);
678
+ clampScroll();
679
+ return { type: "none" };
680
+ }
681
+ if (matchesKey(data, "-") || matchesKey(data, "_")) {
682
+ state.height = Math.max(MIN_PANEL_HEIGHT, state.height - 5);
683
+ clampScroll();
684
+ return { type: "none" };
685
+ }
686
+ if (matchesKey(data, "w") && state.mode !== "select") {
687
+ state.wordWrap = !state.wordWrap;
688
+ state.lastRenderWidth = 0;
689
+ return { type: "none" };
690
+ }
691
+ if (matchesKey(data, "d") && state.mode !== "select" && state.file.gitStatus && !isUntrackedStatus(state.file.gitStatus)) {
692
+ state.diffMode = !state.diffMode;
693
+ state.lastRenderWidth = 0;
694
+ state.scroll = 0;
695
+ state.cursor = 0;
696
+ return { type: "none" };
697
+ }
698
+ if (matchesKey(data, "m") && state.mode !== "select") {
699
+ toggleMarkdownMode();
700
+ return { type: "none" };
701
+ }
702
+ if (matchesKey(data, "v")) {
703
+ if (state.mode === "select") {
704
+ setMode("normal");
705
+ return { type: "none" };
706
+ }
707
+ switchMarkdownToRaw();
708
+ state.cursor = groupStart(state.cursor);
709
+ state.mode = "select";
710
+ state.selectStart = state.cursor;
711
+ state.selectEnd = state.cursor;
712
+ return { type: "none" };
713
+ }
714
+ if (matchesKey(data, "c") && state.mode === "select") {
715
+ state.mode = "comment";
716
+ state.commentText = "";
717
+ return { type: "none" };
718
+ }
719
+ if (matchesKey(data, "]") && state.mode !== "select") {
720
+ return { type: "navigate", direction: 1 };
721
+ }
722
+ if (matchesKey(data, "[") && state.mode !== "select") {
723
+ return { type: "navigate", direction: -1 };
724
+ }
725
+
726
+ return { type: "none" };
727
+ },
728
+ };
729
+ }