@narumitw/pi-files 0.0.0-stage → 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 +9 -0
- package/LICENSE +21 -0
- package/README.md +102 -2
- package/dist/chunks/chunk-3GQ7VS22.ts +273 -0
- package/dist/chunks/chunk-3GQ7VS22.ts.map +7 -0
- package/dist/chunks/chunk-AXW73HDJ.ts +31 -0
- package/dist/chunks/chunk-AXW73HDJ.ts.map +7 -0
- package/dist/chunks/chunk-F5QFC255.ts +101 -0
- package/dist/chunks/chunk-F5QFC255.ts.map +7 -0
- package/dist/chunks/chunk-NFNGJETD.ts +77 -0
- package/dist/chunks/chunk-NFNGJETD.ts.map +7 -0
- package/dist/chunks/chunk-OPGTWNJI.ts +258 -0
- package/dist/chunks/chunk-OPGTWNJI.ts.map +7 -0
- package/dist/chunks/chunk-WOTDNRTG.ts +242 -0
- package/dist/chunks/chunk-WOTDNRTG.ts.map +7 -0
- package/dist/chunks/file-context-explorer-WR6GQZHF.ts +1793 -0
- package/dist/chunks/file-context-explorer-WR6GQZHF.ts.map +7 -0
- package/dist/chunks/file-context-menu-VPLHUUQ6.ts +302 -0
- package/dist/chunks/file-context-menu-VPLHUUQ6.ts.map +7 -0
- package/dist/chunks/files-menu-MES4MQ4F.ts +119 -0
- package/dist/chunks/files-menu-MES4MQ4F.ts.map +7 -0
- package/dist/chunks/files-settings-NYXQU2HV.ts +295 -0
- package/dist/chunks/files-settings-NYXQU2HV.ts.map +7 -0
- package/dist/chunks/menu-P6IBMROH.ts +876 -0
- package/dist/chunks/menu-P6IBMROH.ts.map +7 -0
- package/dist/index.ts +1789 -0
- package/dist/index.ts.map +7 -0
- package/docs/edit-scope-reference.md +142 -0
- package/docs/file-context-reference.md +91 -0
- package/docs/migration.md +105 -0
- package/docs/verification.md +99 -0
- package/package.json +58 -4
- package/src/content-search-session.ts +204 -0
- package/src/content-search-ui.ts +159 -0
- package/src/content-search.ts +170 -0
- package/src/context-service.ts +515 -0
- package/src/external-editor.ts +143 -0
- package/src/file-browser-ui.ts +128 -0
- package/src/file-browser.ts +76 -0
- package/src/file-context-explorer.ts +992 -0
- package/src/file-context-menu.ts +360 -0
- package/src/file-context-preview-ui.ts +354 -0
- package/src/file-context-settings.ts +301 -0
- package/src/file-context.ts +321 -0
- package/src/file-search.ts +246 -0
- package/src/files-menu.ts +125 -0
- package/src/files-settings.ts +314 -0
- package/src/files-widget.ts +65 -0
- package/src/files.ts +148 -0
- package/src/git-context.ts +485 -0
- package/src/index.ts +1 -0
- package/src/scope/contracts.ts +243 -0
- package/src/scope/display.ts +27 -0
- package/src/scope/edit-scope.ts +435 -0
- package/src/scope/files.ts +118 -0
- package/src/scope/input.ts +207 -0
- package/src/scope/menu.ts +277 -0
- package/src/scope/paths.ts +142 -0
- package/src/scope/picker-keys.ts +154 -0
- package/src/scope/picker.ts +484 -0
- package/src/scope/policy.ts +55 -0
- package/src/scope/settings.ts +138 -0
- package/src/scope/state.ts +91 -0
- package/src/scope/ui.ts +106 -0
|
@@ -0,0 +1,992 @@
|
|
|
1
|
+
import type { KeybindingsManager, Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import {
|
|
3
|
+
type Component,
|
|
4
|
+
type Focusable,
|
|
5
|
+
Input,
|
|
6
|
+
Key,
|
|
7
|
+
matchesKey,
|
|
8
|
+
type TUI,
|
|
9
|
+
truncateToWidth,
|
|
10
|
+
visibleWidth,
|
|
11
|
+
} from "@earendil-works/pi-tui";
|
|
12
|
+
import type { ContentSearchMatch } from "./content-search.js";
|
|
13
|
+
import { ContentSearchSession } from "./content-search-session.js";
|
|
14
|
+
import {
|
|
15
|
+
safeTerminalText as escapeTerminalControls,
|
|
16
|
+
type ProjectBrowserItem,
|
|
17
|
+
ProjectFileBrowser,
|
|
18
|
+
parentProjectDirectory,
|
|
19
|
+
} from "./file-browser.js";
|
|
20
|
+
import { renderFileBrowser } from "./file-browser-ui.js";
|
|
21
|
+
import {
|
|
22
|
+
createFileQuoteSnapshot,
|
|
23
|
+
createPreviewQuote,
|
|
24
|
+
type FileQuote,
|
|
25
|
+
type LoadedProjectTextFile,
|
|
26
|
+
} from "./file-context.js";
|
|
27
|
+
import {
|
|
28
|
+
fitRows,
|
|
29
|
+
renderFileHistory,
|
|
30
|
+
renderFilePreview,
|
|
31
|
+
renderFilePreviewHelp,
|
|
32
|
+
type SelectedContextState,
|
|
33
|
+
} from "./file-context-preview-ui.js";
|
|
34
|
+
import { ProjectFileSearch } from "./file-search.js";
|
|
35
|
+
import type { GitBlameInfo, GitContext, GitFileContext, GitHistoryEntry, GitRevisionFile } from "./git-context.js";
|
|
36
|
+
|
|
37
|
+
const RESERVED_APP_ROWS = 3;
|
|
38
|
+
const EXPLORER_CHROME_ROWS = 4;
|
|
39
|
+
const DIFF_CHROME_ROWS = 3;
|
|
40
|
+
|
|
41
|
+
export type FileQuoteExplorerResult =
|
|
42
|
+
| { kind: "quote"; quote: FileQuote }
|
|
43
|
+
| { kind: "reference"; path: string }
|
|
44
|
+
| { kind: "back" }
|
|
45
|
+
| { kind: "close" };
|
|
46
|
+
|
|
47
|
+
interface FileQuoteExplorerOptions {
|
|
48
|
+
tui: TUI;
|
|
49
|
+
theme: Theme;
|
|
50
|
+
keybindings: KeybindingsManager;
|
|
51
|
+
files: readonly string[];
|
|
52
|
+
cwd?: string;
|
|
53
|
+
loadFile: (path: string, signal?: AbortSignal) => Promise<LoadedProjectTextFile>;
|
|
54
|
+
editFile?: (path: string, signal?: AbortSignal) => Promise<void>;
|
|
55
|
+
gitContext?: GitContext;
|
|
56
|
+
rootNavigation?: boolean;
|
|
57
|
+
getSelectedContextState?: () => SelectedContextState;
|
|
58
|
+
validateQuote?: (quote: FileQuote) => void;
|
|
59
|
+
onAddAndContinue?: (quote: FileQuote) => void;
|
|
60
|
+
done: (result: FileQuoteExplorerResult | undefined) => void;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Keeps the explorer's coupled mode transitions and request generations in one controller so every
|
|
65
|
+
* cancellation path invalidates the same state instead of coordinating lifecycle across components.
|
|
66
|
+
*/
|
|
67
|
+
export class FileQuoteExplorer implements Component, Focusable {
|
|
68
|
+
private readonly search = new Input();
|
|
69
|
+
private readonly contentSearch: ContentSearchSession;
|
|
70
|
+
private readonly revisionInput = new Input();
|
|
71
|
+
private readonly fileSearch: ProjectFileSearch;
|
|
72
|
+
private readonly fileBrowser: ProjectFileBrowser;
|
|
73
|
+
private fileItems: ProjectBrowserItem[];
|
|
74
|
+
private currentDirectory = "";
|
|
75
|
+
private readonly directoryPositions = new Map<string, { index: number; offset: number }>();
|
|
76
|
+
private selectedFileIndex = 0;
|
|
77
|
+
private fileScrollOffset = 0;
|
|
78
|
+
private mode: "files" | "contents" | "preview" | "preview-help" | "history" | "revision" | "diff" = "files";
|
|
79
|
+
private previewReturnMode: "files" | "contents" = "files";
|
|
80
|
+
private activeContentMatch: ContentSearchMatch | undefined;
|
|
81
|
+
private loadedFile: LoadedProjectTextFile | undefined;
|
|
82
|
+
private loadedGit: GitFileContext | undefined;
|
|
83
|
+
private previewCursor = 0;
|
|
84
|
+
private previewAnchor: number | undefined;
|
|
85
|
+
private previewScrollOffset = 0;
|
|
86
|
+
private hunkIndex = -1;
|
|
87
|
+
private blame: GitBlameInfo | undefined;
|
|
88
|
+
private history: GitHistoryEntry[] = [];
|
|
89
|
+
private historyIndex = 0;
|
|
90
|
+
private loadedRevision: GitRevisionFile | undefined;
|
|
91
|
+
private diffHunkIndex = 0;
|
|
92
|
+
private diffScrollOffset = 0;
|
|
93
|
+
private detailRequest = 0;
|
|
94
|
+
private detailController: AbortController | undefined;
|
|
95
|
+
private openRequest = 0;
|
|
96
|
+
private openController: AbortController | undefined;
|
|
97
|
+
private editRequest = 0;
|
|
98
|
+
private editController: AbortController | undefined;
|
|
99
|
+
private loading = false;
|
|
100
|
+
private error: string | undefined;
|
|
101
|
+
private finished = false;
|
|
102
|
+
private disposed = false;
|
|
103
|
+
private isFocused = false;
|
|
104
|
+
|
|
105
|
+
constructor(private readonly options: FileQuoteExplorerOptions) {
|
|
106
|
+
this.fileSearch = new ProjectFileSearch(options.files);
|
|
107
|
+
this.fileBrowser = new ProjectFileBrowser(options.files);
|
|
108
|
+
this.fileItems = this.fileBrowser.list("");
|
|
109
|
+
this.contentSearch = new ContentSearchSession({
|
|
110
|
+
tui: options.tui,
|
|
111
|
+
theme: options.theme,
|
|
112
|
+
keybindings: options.keybindings,
|
|
113
|
+
files: options.files,
|
|
114
|
+
cwd: options.cwd,
|
|
115
|
+
loadFile: options.loadFile,
|
|
116
|
+
onPreview: (match) => {
|
|
117
|
+
void this.openFile(match.path, {
|
|
118
|
+
returnMode: "contents",
|
|
119
|
+
cursorIndex: match.lineNumber - 1,
|
|
120
|
+
match,
|
|
121
|
+
});
|
|
122
|
+
},
|
|
123
|
+
onReference: (path) => this.finish({ kind: "reference", path }),
|
|
124
|
+
onSwitchFiles: () => this.showFileSearch(),
|
|
125
|
+
onCancel: () => this.finish(this.rootExit("back")),
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
get capturesViewportInput(): boolean {
|
|
130
|
+
// Revision entry and static help do not own list or preview navigation.
|
|
131
|
+
return this.mode !== "revision" && this.mode !== "preview-help";
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
get focused(): boolean {
|
|
135
|
+
return this.isFocused;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
set focused(value: boolean) {
|
|
139
|
+
this.isFocused = value;
|
|
140
|
+
this.search.focused = value && this.mode === "files";
|
|
141
|
+
this.contentSearch.focused = value && this.mode === "contents";
|
|
142
|
+
this.revisionInput.focused = value && this.mode === "revision";
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
render(width: number): string[] {
|
|
146
|
+
const safeWidth = Number.isFinite(width) ? Math.max(0, Math.floor(width)) : 0;
|
|
147
|
+
if (safeWidth === 0) return [];
|
|
148
|
+
if (this.mode === "files") return this.renderFileList(safeWidth);
|
|
149
|
+
if (this.mode === "contents") return this.renderContentSearch(safeWidth);
|
|
150
|
+
if (this.mode === "history") return this.renderHistory(safeWidth);
|
|
151
|
+
if (this.mode === "revision") return this.renderRevisionInput(safeWidth);
|
|
152
|
+
if (this.mode === "diff") return this.renderDiff(safeWidth);
|
|
153
|
+
if (this.mode === "preview-help") return this.renderPreviewHelp(safeWidth);
|
|
154
|
+
return this.renderPreview(safeWidth);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
handleInput(data: string): void {
|
|
158
|
+
if (this.finished || this.disposed) return;
|
|
159
|
+
if (matchesKey(data, Key.ctrl("c"))) {
|
|
160
|
+
this.finish(this.rootExit("close"));
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
if (this.mode === "files") this.handleFileInput(data);
|
|
164
|
+
else if (this.mode === "contents") this.handleContentInput(data);
|
|
165
|
+
else if (this.mode === "history") this.handleHistoryInput(data);
|
|
166
|
+
else if (this.mode === "revision") this.handleRevisionInput(data);
|
|
167
|
+
else if (this.mode === "diff") this.handleDiffInput(data);
|
|
168
|
+
else if (this.mode === "preview-help") this.handlePreviewHelpInput(data);
|
|
169
|
+
else this.handlePreviewInput(data);
|
|
170
|
+
if (!this.finished) this.options.tui.requestRender();
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
invalidate(): void {
|
|
174
|
+
this.search.invalidate();
|
|
175
|
+
this.contentSearch.invalidate();
|
|
176
|
+
this.revisionInput.invalidate();
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
dispose(): void {
|
|
180
|
+
if (this.disposed) return;
|
|
181
|
+
this.disposed = true;
|
|
182
|
+
this.contentSearch.dispose();
|
|
183
|
+
this.cancelOpenRequest();
|
|
184
|
+
this.cancelDetailRequest();
|
|
185
|
+
this.cancelEditRequest();
|
|
186
|
+
if (!this.finished) {
|
|
187
|
+
this.finished = true;
|
|
188
|
+
this.options.done(undefined);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
private renderFileList(width: number): string[] {
|
|
193
|
+
const availableRows = Math.max(1, this.options.tui.terminal.rows - RESERVED_APP_ROWS);
|
|
194
|
+
const listHeight = Math.max(1, availableRows - EXPLORER_CHROME_ROWS);
|
|
195
|
+
this.keepFileVisible(listHeight);
|
|
196
|
+
const project = this.options.gitContext?.project;
|
|
197
|
+
const repositoryLabel = project
|
|
198
|
+
? ` · ${escapeTerminalControls(project.branch)}@${project.head.slice(0, 12)}${project.dirty ? " · dirty" : ""}`
|
|
199
|
+
: "";
|
|
200
|
+
const queryLabel = this.options.theme.fg("muted", "Search: ");
|
|
201
|
+
const queryWidth = Math.max(1, width - visibleWidth(queryLabel));
|
|
202
|
+
const searchLine = `${queryLabel}${this.search.render(queryWidth)[0] ?? ""}`;
|
|
203
|
+
return fitRows(
|
|
204
|
+
renderFileBrowser({
|
|
205
|
+
theme: this.options.theme,
|
|
206
|
+
keybindings: this.options.keybindings,
|
|
207
|
+
width,
|
|
208
|
+
height: listHeight,
|
|
209
|
+
items: this.fileItems,
|
|
210
|
+
selectedIndex: this.selectedFileIndex,
|
|
211
|
+
scrollOffset: this.fileScrollOffset,
|
|
212
|
+
currentDirectory: this.currentDirectory,
|
|
213
|
+
searchLine,
|
|
214
|
+
searchActive: this.isFileSearchActive(),
|
|
215
|
+
repositoryLabel,
|
|
216
|
+
statuses: this.options.gitContext?.statuses,
|
|
217
|
+
loading: this.loading,
|
|
218
|
+
error: this.error,
|
|
219
|
+
}),
|
|
220
|
+
availableRows,
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
private renderContentSearch(width: number): string[] {
|
|
225
|
+
const availableRows = Math.max(1, this.options.tui.terminal.rows - RESERVED_APP_ROWS);
|
|
226
|
+
return this.contentSearch.render(width, availableRows, this.loading, this.error);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
private renderPreview(width: number): string[] {
|
|
230
|
+
const availableRows = Math.max(1, this.options.tui.terminal.rows - RESERVED_APP_ROWS);
|
|
231
|
+
if (!this.loadedFile) return [this.options.theme.fg("warning", "Loading preview…")];
|
|
232
|
+
return renderFilePreview({
|
|
233
|
+
theme: this.options.theme,
|
|
234
|
+
keybindings: this.options.keybindings,
|
|
235
|
+
width,
|
|
236
|
+
availableRows,
|
|
237
|
+
file: this.loadedFile,
|
|
238
|
+
fileGit: this.loadedGit,
|
|
239
|
+
project: this.options.gitContext?.project,
|
|
240
|
+
revision: this.loadedRevision,
|
|
241
|
+
contentMatch: this.activeContentMatch,
|
|
242
|
+
blame: this.blame,
|
|
243
|
+
cursor: this.previewCursor,
|
|
244
|
+
anchor: this.previewAnchor,
|
|
245
|
+
scrollOffset: this.previewScrollOffset,
|
|
246
|
+
error: this.error,
|
|
247
|
+
selectedContext: this.options.getSelectedContextState?.(),
|
|
248
|
+
canContinue: this.options.onAddAndContinue !== undefined,
|
|
249
|
+
canEdit: this.options.editFile !== undefined && this.loadedRevision === undefined,
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
private renderPreviewHelp(width: number): string[] {
|
|
254
|
+
const availableRows = Math.max(1, this.options.tui.terminal.rows - RESERVED_APP_ROWS);
|
|
255
|
+
return renderFilePreviewHelp(
|
|
256
|
+
this.options.theme,
|
|
257
|
+
this.options.keybindings,
|
|
258
|
+
width,
|
|
259
|
+
availableRows,
|
|
260
|
+
this.options.editFile !== undefined && this.loadedRevision === undefined,
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
private renderRevisionInput(width: number): string[] {
|
|
265
|
+
const availableRows = Math.max(1, this.options.tui.terminal.rows - RESERVED_APP_ROWS);
|
|
266
|
+
const title = this.options.theme.fg(
|
|
267
|
+
"accent",
|
|
268
|
+
this.options.theme.bold(`Open Git revision · ${escapeTerminalControls(this.loadedFile?.path ?? "")}`),
|
|
269
|
+
);
|
|
270
|
+
const label = this.options.theme.fg("muted", "Revision: ");
|
|
271
|
+
const inputWidth = Math.max(1, width - visibleWidth(label));
|
|
272
|
+
const input = `${label}${this.revisionInput.render(inputWidth)[0] ?? ""}`;
|
|
273
|
+
const state = this.error
|
|
274
|
+
? this.options.theme.fg("error", escapeTerminalControls(this.error))
|
|
275
|
+
: this.loading
|
|
276
|
+
? this.options.theme.fg("warning", "Loading revision…")
|
|
277
|
+
: this.options.theme.fg("muted", "Enter open commit/branch/tag · Esc preview");
|
|
278
|
+
return fitRows(
|
|
279
|
+
[truncateToWidth(title, width, ""), truncateToWidth(input, width, ""), truncateToWidth(state, width, "")],
|
|
280
|
+
availableRows,
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
private renderDiff(width: number): string[] {
|
|
285
|
+
const availableRows = Math.max(1, this.options.tui.terminal.rows - RESERVED_APP_ROWS);
|
|
286
|
+
const contentHeight = Math.max(1, availableRows - DIFF_CHROME_ROWS);
|
|
287
|
+
const hunk = this.loadedGit?.hunks[this.diffHunkIndex];
|
|
288
|
+
const path = escapeTerminalControls(this.loadedFile?.path ?? "");
|
|
289
|
+
const title = this.options.theme.fg("accent", this.options.theme.bold(`Git diff · ${path} · HEAD → worktree`));
|
|
290
|
+
const hunkLines = hunk?.lines ?? ["No changed hunks"];
|
|
291
|
+
const maxScroll = Math.max(0, hunkLines.length - contentHeight);
|
|
292
|
+
this.diffScrollOffset = Math.min(this.diffScrollOffset, maxScroll);
|
|
293
|
+
const lines = hunkLines
|
|
294
|
+
.slice(this.diffScrollOffset, this.diffScrollOffset + contentHeight)
|
|
295
|
+
.map((line) =>
|
|
296
|
+
truncateToWidth(
|
|
297
|
+
line.startsWith("+")
|
|
298
|
+
? this.options.theme.fg("success", escapeTerminalControls(line))
|
|
299
|
+
: line.startsWith("-")
|
|
300
|
+
? this.options.theme.fg("error", escapeTerminalControls(line))
|
|
301
|
+
: escapeTerminalControls(line),
|
|
302
|
+
width,
|
|
303
|
+
"",
|
|
304
|
+
),
|
|
305
|
+
);
|
|
306
|
+
const text = hunk?.lines.join("\n") ?? "";
|
|
307
|
+
const tokens = Math.max(1, Math.ceil(Buffer.byteLength(text, "utf8") / 4));
|
|
308
|
+
const footer = this.error
|
|
309
|
+
? this.options.theme.fg("error", escapeTerminalControls(this.error))
|
|
310
|
+
: `~${tokens} tokens · Enter attach diff · Hunk ${hunk ? this.diffHunkIndex + 1 : 0}/${this.loadedGit?.hunks.length ?? 0} · rows ${hunkLines.length === 0 ? 0 : this.diffScrollOffset + 1}-${Math.min(hunkLines.length, this.diffScrollOffset + contentHeight)}/${hunkLines.length} · ↑↓ scroll · [] navigate · Esc preview`;
|
|
311
|
+
return fitRows(
|
|
312
|
+
[truncateToWidth(title, width, ""), ...lines, truncateToWidth(this.options.theme.fg("muted", footer), width, "")],
|
|
313
|
+
availableRows,
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
private renderHistory(width: number): string[] {
|
|
318
|
+
return renderFileHistory({
|
|
319
|
+
width,
|
|
320
|
+
availableRows: Math.max(1, this.options.tui.terminal.rows - RESERVED_APP_ROWS),
|
|
321
|
+
theme: this.options.theme,
|
|
322
|
+
path: this.loadedFile?.path ?? "",
|
|
323
|
+
history: this.history,
|
|
324
|
+
index: this.historyIndex,
|
|
325
|
+
error: this.error,
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
private handleFileInput(data: string): void {
|
|
330
|
+
if (this.options.keybindings.matches(data, "tui.select.cancel")) {
|
|
331
|
+
this.cancelFileList();
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
if (matchesKey(data, Key.ctrl("f"))) {
|
|
335
|
+
this.showContentSearch();
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
if (matchesKey(data, Key.escape)) {
|
|
339
|
+
this.cancelFileList();
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
if (this.loading) return;
|
|
343
|
+
if (this.options.keybindings.matches(data, "tui.select.up")) {
|
|
344
|
+
this.selectedFileIndex = Math.max(0, this.selectedFileIndex - 1);
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
if (this.options.keybindings.matches(data, "tui.select.down")) {
|
|
348
|
+
this.selectedFileIndex = Math.min(Math.max(0, this.fileItems.length - 1), this.selectedFileIndex + 1);
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
if (this.options.keybindings.matches(data, "tui.select.pageUp")) {
|
|
352
|
+
this.selectedFileIndex = Math.max(0, this.selectedFileIndex - 10);
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
if (this.options.keybindings.matches(data, "tui.select.pageDown")) {
|
|
356
|
+
this.selectedFileIndex = Math.min(Math.max(0, this.fileItems.length - 1), this.selectedFileIndex + 10);
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
if (this.options.keybindings.matches(data, "tui.select.confirm")) {
|
|
360
|
+
this.openSelectedFileItem();
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
if (this.options.keybindings.matches(data, "tui.input.tab")) {
|
|
364
|
+
const item = this.fileItems[this.selectedFileIndex];
|
|
365
|
+
if (item?.kind === "file") this.finish({ kind: "reference", path: item.path });
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
if (
|
|
369
|
+
!this.isFileSearchActive() &&
|
|
370
|
+
this.currentDirectory &&
|
|
371
|
+
(matchesKey(data, Key.left) || matchesKey(data, Key.backspace))
|
|
372
|
+
) {
|
|
373
|
+
this.showParentDirectory();
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
if (!this.isFileSearchActive() && matchesKey(data, Key.right)) {
|
|
377
|
+
const item = this.fileItems[this.selectedFileIndex];
|
|
378
|
+
if (item?.kind === "directory") this.showDirectory(item.path);
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
const previousQuery = this.search.getValue();
|
|
383
|
+
this.search.handleInput(data);
|
|
384
|
+
const query = this.search.getValue();
|
|
385
|
+
if (query !== previousQuery) {
|
|
386
|
+
this.refreshFileItems(query);
|
|
387
|
+
this.selectedFileIndex = 0;
|
|
388
|
+
this.fileScrollOffset = 0;
|
|
389
|
+
this.error = undefined;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
private handleContentInput(data: string): void {
|
|
394
|
+
this.cancelOpenRequest();
|
|
395
|
+
this.error = undefined;
|
|
396
|
+
this.contentSearch.handleInput(data);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
private handlePreviewInput(data: string): void {
|
|
400
|
+
const loadedFile = this.loadedFile;
|
|
401
|
+
if (!loadedFile) return;
|
|
402
|
+
const lines = loadedFile.lines;
|
|
403
|
+
if (this.options.keybindings.matches(data, "app.editor.external")) {
|
|
404
|
+
if (this.loadedRevision) {
|
|
405
|
+
this.error = "Historical revisions are read-only; return to the worktree preview to edit";
|
|
406
|
+
} else if (this.options.editFile && !this.loading) {
|
|
407
|
+
void this.editCurrentFile();
|
|
408
|
+
} else if (!this.options.editFile) {
|
|
409
|
+
this.error = "External editing is unavailable";
|
|
410
|
+
}
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
if (matchesKey(data, Key.escape)) {
|
|
414
|
+
this.returnToOrigin();
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
if (data === "?") {
|
|
418
|
+
this.cancelDetailRequest();
|
|
419
|
+
this.mode = "preview-help";
|
|
420
|
+
this.search.focused = false;
|
|
421
|
+
this.contentSearch.focused = false;
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
if (data === "a" && this.options.onAddAndContinue) {
|
|
425
|
+
try {
|
|
426
|
+
const quote = this.createSelectedQuote();
|
|
427
|
+
this.options.onAddAndContinue(quote);
|
|
428
|
+
this.returnToOrigin();
|
|
429
|
+
} catch (error: unknown) {
|
|
430
|
+
this.error = formatError(error);
|
|
431
|
+
}
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
if (data === " ") {
|
|
435
|
+
this.previewAnchor = this.previewAnchor === undefined ? this.previewCursor : undefined;
|
|
436
|
+
this.error = undefined;
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
if (data === "b") {
|
|
440
|
+
void this.loadBlame();
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
if (data === "h") {
|
|
444
|
+
void this.loadHistory();
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
if (data === "r") {
|
|
448
|
+
this.mode = "revision";
|
|
449
|
+
this.revisionInput.setValue("");
|
|
450
|
+
this.revisionInput.focused = this.isFocused;
|
|
451
|
+
this.error = undefined;
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
454
|
+
if (data === "d") {
|
|
455
|
+
if (this.loadedRevision) {
|
|
456
|
+
this.error = "Diff context is available from the worktree preview";
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
if ((this.loadedGit?.hunks.length ?? 0) === 0) {
|
|
460
|
+
this.error = "No changed hunks for this file";
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
this.diffHunkIndex = Math.max(0, this.hunkIndex);
|
|
464
|
+
this.diffScrollOffset = 0;
|
|
465
|
+
this.mode = "diff";
|
|
466
|
+
this.error = undefined;
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
if (this.options.keybindings.matches(data, "tui.select.up")) {
|
|
470
|
+
this.movePreviewCursor(Math.max(0, this.previewCursor - 1));
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
if (this.options.keybindings.matches(data, "tui.select.down")) {
|
|
474
|
+
this.movePreviewCursor(Math.min(Math.max(0, lines.length - 1), this.previewCursor + 1));
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
if (this.options.keybindings.matches(data, "tui.select.pageUp")) {
|
|
478
|
+
this.movePreviewCursor(Math.max(0, this.previewCursor - 10));
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
if (this.options.keybindings.matches(data, "tui.select.pageDown")) {
|
|
482
|
+
this.movePreviewCursor(Math.min(Math.max(0, lines.length - 1), this.previewCursor + 10));
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
if (data === "]" || data === "[") {
|
|
486
|
+
this.navigateHunk(data === "]" ? 1 : -1);
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
if (this.options.keybindings.matches(data, "tui.select.confirm")) {
|
|
490
|
+
try {
|
|
491
|
+
const quote = this.createSelectedQuote();
|
|
492
|
+
this.options.validateQuote?.(quote);
|
|
493
|
+
this.finish({ kind: "quote", quote });
|
|
494
|
+
} catch (error: unknown) {
|
|
495
|
+
this.error = formatError(error);
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
private handlePreviewHelpInput(data: string): void {
|
|
501
|
+
if (matchesKey(data, Key.escape)) this.mode = "preview";
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
private handleHistoryInput(data: string): void {
|
|
505
|
+
if (matchesKey(data, Key.escape)) {
|
|
506
|
+
this.cancelDetailRequest();
|
|
507
|
+
this.mode = "preview";
|
|
508
|
+
this.error = undefined;
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
511
|
+
if (this.options.keybindings.matches(data, "tui.select.up")) {
|
|
512
|
+
this.historyIndex = Math.max(0, this.historyIndex - 1);
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
if (this.options.keybindings.matches(data, "tui.select.down")) {
|
|
516
|
+
this.historyIndex = Math.min(Math.max(0, this.history.length - 1), this.historyIndex + 1);
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
519
|
+
if (this.options.keybindings.matches(data, "tui.select.confirm")) {
|
|
520
|
+
const entry = this.history[this.historyIndex];
|
|
521
|
+
if (entry) void this.loadRevision(entry.commit, entry.path);
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
private handleRevisionInput(data: string): void {
|
|
526
|
+
if (matchesKey(data, Key.escape)) {
|
|
527
|
+
this.cancelDetailRequest();
|
|
528
|
+
this.mode = "preview";
|
|
529
|
+
this.revisionInput.focused = false;
|
|
530
|
+
this.error = undefined;
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
if (this.loading) return;
|
|
534
|
+
if (this.options.keybindings.matches(data, "tui.select.confirm")) {
|
|
535
|
+
void this.loadRevision(this.revisionInput.getValue());
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
this.revisionInput.handleInput(data);
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
private handleDiffInput(data: string): void {
|
|
542
|
+
if (matchesKey(data, Key.escape)) {
|
|
543
|
+
this.mode = "preview";
|
|
544
|
+
this.error = undefined;
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
547
|
+
const hunks = this.loadedGit?.hunks ?? [];
|
|
548
|
+
const hunkLines = hunks[this.diffHunkIndex]?.lines ?? [];
|
|
549
|
+
if (this.options.keybindings.matches(data, "tui.select.up")) {
|
|
550
|
+
this.diffScrollOffset = Math.max(0, this.diffScrollOffset - 1);
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
if (this.options.keybindings.matches(data, "tui.select.down")) {
|
|
554
|
+
this.diffScrollOffset = Math.min(Math.max(0, hunkLines.length - 1), this.diffScrollOffset + 1);
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
557
|
+
if (this.options.keybindings.matches(data, "tui.select.pageUp")) {
|
|
558
|
+
this.diffScrollOffset = Math.max(0, this.diffScrollOffset - 10);
|
|
559
|
+
return;
|
|
560
|
+
}
|
|
561
|
+
if (this.options.keybindings.matches(data, "tui.select.pageDown")) {
|
|
562
|
+
this.diffScrollOffset = Math.min(Math.max(0, hunkLines.length - 1), this.diffScrollOffset + 10);
|
|
563
|
+
return;
|
|
564
|
+
}
|
|
565
|
+
if (data === "]" || data === "[") {
|
|
566
|
+
if (hunks.length > 0) {
|
|
567
|
+
const direction = data === "]" ? 1 : -1;
|
|
568
|
+
this.diffHunkIndex = (this.diffHunkIndex + direction + hunks.length) % hunks.length;
|
|
569
|
+
this.diffScrollOffset = 0;
|
|
570
|
+
}
|
|
571
|
+
return;
|
|
572
|
+
}
|
|
573
|
+
if (this.options.keybindings.matches(data, "tui.select.confirm")) {
|
|
574
|
+
const hunk = hunks[this.diffHunkIndex];
|
|
575
|
+
const loadedFile = this.loadedFile;
|
|
576
|
+
const project = this.options.gitContext?.project;
|
|
577
|
+
if (!hunk || !loadedFile || !project) return;
|
|
578
|
+
try {
|
|
579
|
+
const startLine = Math.max(1, hunk.newStart);
|
|
580
|
+
const endLine = Math.max(startLine, hunk.newStart + hunk.newCount - 1);
|
|
581
|
+
const quote = createFileQuoteSnapshot(loadedFile.path, startLine, endLine, hunk.lines.join("\n"), {
|
|
582
|
+
head: project.head,
|
|
583
|
+
branch: project.branch,
|
|
584
|
+
status: this.loadedGit?.status?.label ?? "modified",
|
|
585
|
+
blob: this.loadedGit?.blob,
|
|
586
|
+
source: "git_diff",
|
|
587
|
+
base: "HEAD",
|
|
588
|
+
});
|
|
589
|
+
this.options.validateQuote?.(quote);
|
|
590
|
+
this.finish({ kind: "quote", quote });
|
|
591
|
+
} catch (error: unknown) {
|
|
592
|
+
this.error = formatError(error);
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
private async editCurrentFile(): Promise<void> {
|
|
598
|
+
const path = this.loadedFile?.path;
|
|
599
|
+
const editFile = this.options.editFile;
|
|
600
|
+
if (!path || !editFile || this.loadedRevision) return;
|
|
601
|
+
this.cancelOpenRequest();
|
|
602
|
+
this.cancelDetailRequest();
|
|
603
|
+
this.cancelEditRequest();
|
|
604
|
+
const request = this.editRequest;
|
|
605
|
+
const controller = new AbortController();
|
|
606
|
+
this.editController = controller;
|
|
607
|
+
this.loading = true;
|
|
608
|
+
this.error = undefined;
|
|
609
|
+
this.options.tui.requestRender();
|
|
610
|
+
try {
|
|
611
|
+
await editFile(path, controller.signal);
|
|
612
|
+
if (!this.isCurrentEditRequest(request, controller, path)) return;
|
|
613
|
+
const gitContext = this.options.gitContext;
|
|
614
|
+
const [loadedFile, loadedGit] = await Promise.all([
|
|
615
|
+
this.options.loadFile(path, controller.signal),
|
|
616
|
+
gitContext
|
|
617
|
+
? (gitContext.refreshFileContext?.(path, controller.signal) ??
|
|
618
|
+
gitContext.getFileContext(path, controller.signal))
|
|
619
|
+
: undefined,
|
|
620
|
+
]);
|
|
621
|
+
if (!this.isCurrentEditRequest(request, controller, path)) return;
|
|
622
|
+
const maximumIndex = Math.max(0, loadedFile.lines.length - 1);
|
|
623
|
+
this.loadedFile = loadedFile;
|
|
624
|
+
this.loadedGit = loadedGit;
|
|
625
|
+
this.previewCursor = Math.min(this.previewCursor, maximumIndex);
|
|
626
|
+
if (this.previewAnchor !== undefined) {
|
|
627
|
+
this.previewAnchor = Math.min(this.previewAnchor, maximumIndex);
|
|
628
|
+
}
|
|
629
|
+
this.previewScrollOffset = Math.min(this.previewScrollOffset, maximumIndex);
|
|
630
|
+
this.activeContentMatch = undefined;
|
|
631
|
+
this.hunkIndex = -1;
|
|
632
|
+
this.blame = undefined;
|
|
633
|
+
this.history = [];
|
|
634
|
+
this.error = undefined;
|
|
635
|
+
} catch (error: unknown) {
|
|
636
|
+
if (this.isCurrentEditRequest(request, controller, path) && !isAbortError(error)) {
|
|
637
|
+
this.error = formatError(error);
|
|
638
|
+
}
|
|
639
|
+
} finally {
|
|
640
|
+
if (request === this.editRequest && this.editController === controller) {
|
|
641
|
+
this.loading = false;
|
|
642
|
+
this.editController = undefined;
|
|
643
|
+
}
|
|
644
|
+
if (!this.finished && !this.disposed) this.options.tui.requestRender(true);
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
private async loadRevision(revision: string, historicalPath?: string): Promise<void> {
|
|
649
|
+
const path = this.loadedFile?.path;
|
|
650
|
+
const gitContext = this.options.gitContext;
|
|
651
|
+
if (!path || !gitContext) {
|
|
652
|
+
this.error = "Git revision browsing is unavailable";
|
|
653
|
+
return;
|
|
654
|
+
}
|
|
655
|
+
const { request, signal } = this.beginDetailRequest();
|
|
656
|
+
this.error = undefined;
|
|
657
|
+
this.options.tui.requestRender();
|
|
658
|
+
try {
|
|
659
|
+
const loadedRevision = await gitContext.loadRevision(path, revision, historicalPath, signal);
|
|
660
|
+
if (this.finished || request !== this.detailRequest) return;
|
|
661
|
+
this.loadedRevision = loadedRevision;
|
|
662
|
+
this.loadedFile = { path: loadedRevision.path, lines: loadedRevision.lines };
|
|
663
|
+
this.activeContentMatch = undefined;
|
|
664
|
+
this.previewCursor = 0;
|
|
665
|
+
this.previewAnchor = undefined;
|
|
666
|
+
this.previewScrollOffset = 0;
|
|
667
|
+
this.blame = undefined;
|
|
668
|
+
this.mode = "preview";
|
|
669
|
+
this.revisionInput.focused = false;
|
|
670
|
+
} catch (error: unknown) {
|
|
671
|
+
if (request === this.detailRequest && !isAbortError(error)) this.error = formatError(error);
|
|
672
|
+
} finally {
|
|
673
|
+
this.finishDetailRequest(request);
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
private async loadBlame(): Promise<void> {
|
|
678
|
+
const path = this.loadedFile?.path;
|
|
679
|
+
const gitContext = this.options.gitContext;
|
|
680
|
+
if (!path || !gitContext) {
|
|
681
|
+
this.error = "Git blame is unavailable";
|
|
682
|
+
return;
|
|
683
|
+
}
|
|
684
|
+
const { request, signal } = this.beginDetailRequest();
|
|
685
|
+
const requestedLine = this.previewCursor + 1;
|
|
686
|
+
this.error = undefined;
|
|
687
|
+
this.options.tui.requestRender();
|
|
688
|
+
try {
|
|
689
|
+
const blame = await gitContext.getBlame(path, requestedLine, this.loadedRevision?.commit, signal);
|
|
690
|
+
if (
|
|
691
|
+
this.finished ||
|
|
692
|
+
request !== this.detailRequest ||
|
|
693
|
+
this.mode !== "preview" ||
|
|
694
|
+
requestedLine !== this.previewCursor + 1
|
|
695
|
+
) {
|
|
696
|
+
return;
|
|
697
|
+
}
|
|
698
|
+
this.blame = blame;
|
|
699
|
+
if (!blame) this.error = "No blame information for this line";
|
|
700
|
+
} catch (error: unknown) {
|
|
701
|
+
if (request === this.detailRequest && !isAbortError(error)) this.error = formatError(error);
|
|
702
|
+
} finally {
|
|
703
|
+
this.finishDetailRequest(request);
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
private async loadHistory(): Promise<void> {
|
|
708
|
+
const path = this.loadedFile?.path;
|
|
709
|
+
const gitContext = this.options.gitContext;
|
|
710
|
+
if (!path || !gitContext) {
|
|
711
|
+
this.error = "Git history is unavailable";
|
|
712
|
+
return;
|
|
713
|
+
}
|
|
714
|
+
const { request, signal } = this.beginDetailRequest();
|
|
715
|
+
this.error = undefined;
|
|
716
|
+
this.options.tui.requestRender();
|
|
717
|
+
try {
|
|
718
|
+
const history = await gitContext.getHistory(path, signal);
|
|
719
|
+
if (this.finished || request !== this.detailRequest || this.mode !== "preview") return;
|
|
720
|
+
this.history = history;
|
|
721
|
+
this.historyIndex = 0;
|
|
722
|
+
this.mode = "history";
|
|
723
|
+
} catch (error: unknown) {
|
|
724
|
+
if (request === this.detailRequest && !isAbortError(error)) this.error = formatError(error);
|
|
725
|
+
} finally {
|
|
726
|
+
this.finishDetailRequest(request);
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
private async openFile(
|
|
731
|
+
path: string,
|
|
732
|
+
options: {
|
|
733
|
+
returnMode?: "files" | "contents";
|
|
734
|
+
cursorIndex?: number;
|
|
735
|
+
match?: ContentSearchMatch;
|
|
736
|
+
} = {},
|
|
737
|
+
): Promise<void> {
|
|
738
|
+
this.cancelOpenRequest();
|
|
739
|
+
const request = this.openRequest;
|
|
740
|
+
const controller = new AbortController();
|
|
741
|
+
this.openController = controller;
|
|
742
|
+
this.loading = true;
|
|
743
|
+
this.error = undefined;
|
|
744
|
+
this.options.tui.requestRender();
|
|
745
|
+
try {
|
|
746
|
+
const [loadedFile, loadedGit] = await Promise.all([
|
|
747
|
+
this.options.loadFile(path, controller.signal),
|
|
748
|
+
this.options.gitContext?.getFileContext(path, controller.signal),
|
|
749
|
+
]);
|
|
750
|
+
if (!this.isCurrentOpenRequest(request, controller)) return;
|
|
751
|
+
this.loadedFile = loadedFile;
|
|
752
|
+
this.loadedGit = loadedGit;
|
|
753
|
+
this.loadedRevision = undefined;
|
|
754
|
+
this.mode = "preview";
|
|
755
|
+
this.previewReturnMode = options.returnMode ?? "files";
|
|
756
|
+
this.activeContentMatch = options.match;
|
|
757
|
+
this.previewCursor = Math.max(0, Math.min(Math.max(0, loadedFile.lines.length - 1), options.cursorIndex ?? 0));
|
|
758
|
+
this.previewAnchor = undefined;
|
|
759
|
+
this.previewScrollOffset = Math.max(0, this.previewCursor - 1);
|
|
760
|
+
this.hunkIndex = -1;
|
|
761
|
+
this.blame = undefined;
|
|
762
|
+
this.history = [];
|
|
763
|
+
this.detailRequest += 1;
|
|
764
|
+
this.error = undefined;
|
|
765
|
+
this.search.focused = false;
|
|
766
|
+
this.contentSearch.focused = false;
|
|
767
|
+
} catch (error: unknown) {
|
|
768
|
+
if (this.isCurrentOpenRequest(request, controller) && !isAbortError(error)) {
|
|
769
|
+
this.error = formatError(error);
|
|
770
|
+
}
|
|
771
|
+
} finally {
|
|
772
|
+
if (request === this.openRequest) {
|
|
773
|
+
this.loading = false;
|
|
774
|
+
this.openController = undefined;
|
|
775
|
+
}
|
|
776
|
+
if (!this.finished && !this.disposed) this.options.tui.requestRender();
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
private navigateHunk(direction: 1 | -1): void {
|
|
781
|
+
const hunks = this.loadedGit?.hunks ?? [];
|
|
782
|
+
const lineCount = this.loadedFile?.lines.length ?? 0;
|
|
783
|
+
if (hunks.length === 0 || lineCount === 0) {
|
|
784
|
+
this.error = "No changed hunks for this file";
|
|
785
|
+
return;
|
|
786
|
+
}
|
|
787
|
+
this.hunkIndex =
|
|
788
|
+
this.hunkIndex < 0
|
|
789
|
+
? direction > 0
|
|
790
|
+
? 0
|
|
791
|
+
: hunks.length - 1
|
|
792
|
+
: (this.hunkIndex + direction + hunks.length) % hunks.length;
|
|
793
|
+
const hunk = hunks[this.hunkIndex];
|
|
794
|
+
const selectedLines = hunk.changedLines.length > 0 ? hunk.changedLines : [hunk.newStart];
|
|
795
|
+
const start = Math.max(0, Math.min(...selectedLines) - 1);
|
|
796
|
+
const end = Math.max(start, Math.min(lineCount - 1, Math.max(...selectedLines) - 1));
|
|
797
|
+
this.previewAnchor = start;
|
|
798
|
+
this.movePreviewCursor(end);
|
|
799
|
+
this.error = undefined;
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
private showContentSearch(): void {
|
|
803
|
+
this.cancelOpenRequest();
|
|
804
|
+
this.mode = "contents";
|
|
805
|
+
this.search.focused = false;
|
|
806
|
+
this.contentSearch.activate();
|
|
807
|
+
this.contentSearch.focused = this.isFocused;
|
|
808
|
+
this.error = undefined;
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
private showFileSearch(): void {
|
|
812
|
+
this.contentSearch.deactivate();
|
|
813
|
+
this.cancelOpenRequest();
|
|
814
|
+
this.mode = "files";
|
|
815
|
+
this.search.focused = this.isFocused;
|
|
816
|
+
this.error = undefined;
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
private openSelectedFileItem(): void {
|
|
820
|
+
const item = this.fileItems[this.selectedFileIndex];
|
|
821
|
+
if (item?.kind === "directory") {
|
|
822
|
+
this.showDirectory(item.path);
|
|
823
|
+
return;
|
|
824
|
+
}
|
|
825
|
+
if (item?.kind === "file") void this.openFile(item.path);
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
private showDirectory(directory: string): void {
|
|
829
|
+
this.directoryPositions.set(this.currentDirectory, {
|
|
830
|
+
index: this.selectedFileIndex,
|
|
831
|
+
offset: this.fileScrollOffset,
|
|
832
|
+
});
|
|
833
|
+
this.currentDirectory = directory;
|
|
834
|
+
this.refreshFileItems("");
|
|
835
|
+
const position = this.directoryPositions.get(directory);
|
|
836
|
+
this.selectedFileIndex = Math.min(position?.index ?? 0, Math.max(0, this.fileItems.length - 1));
|
|
837
|
+
this.fileScrollOffset = Math.min(position?.offset ?? 0, this.selectedFileIndex);
|
|
838
|
+
this.error = undefined;
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
private showParentDirectory(): void {
|
|
842
|
+
this.cancelOpenRequest();
|
|
843
|
+
this.currentDirectory = parentProjectDirectory(this.currentDirectory);
|
|
844
|
+
this.refreshFileItems("");
|
|
845
|
+
const position = this.directoryPositions.get(this.currentDirectory);
|
|
846
|
+
this.selectedFileIndex = Math.min(position?.index ?? 0, Math.max(0, this.fileItems.length - 1));
|
|
847
|
+
this.fileScrollOffset = Math.min(position?.offset ?? 0, this.selectedFileIndex);
|
|
848
|
+
this.error = undefined;
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
private refreshFileItems(query: string): void {
|
|
852
|
+
this.fileItems = this.isFileSearchActive(query)
|
|
853
|
+
? this.fileBrowser.searchResults(this.fileSearch.search(query))
|
|
854
|
+
: this.fileBrowser.list(this.currentDirectory);
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
private isFileSearchActive(query = this.search.getValue()): boolean {
|
|
858
|
+
return escapeTerminalControls(query).trim().length > 0;
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
private cancelFileList(): void {
|
|
862
|
+
if (this.currentDirectory && !this.isFileSearchActive()) {
|
|
863
|
+
this.showParentDirectory();
|
|
864
|
+
return;
|
|
865
|
+
}
|
|
866
|
+
this.finish(this.rootExit("back"));
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
private cancelOpenRequest(): void {
|
|
870
|
+
this.openRequest += 1;
|
|
871
|
+
this.openController?.abort();
|
|
872
|
+
this.openController = undefined;
|
|
873
|
+
this.loading = false;
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
private isCurrentOpenRequest(request: number, controller: AbortController): boolean {
|
|
877
|
+
return (
|
|
878
|
+
!this.finished &&
|
|
879
|
+
!this.disposed &&
|
|
880
|
+
request === this.openRequest &&
|
|
881
|
+
this.openController === controller &&
|
|
882
|
+
!controller.signal.aborted
|
|
883
|
+
);
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
private isCurrentEditRequest(request: number, controller: AbortController, path: string): boolean {
|
|
887
|
+
return (
|
|
888
|
+
!this.finished &&
|
|
889
|
+
!this.disposed &&
|
|
890
|
+
this.mode === "preview" &&
|
|
891
|
+
this.loadedRevision === undefined &&
|
|
892
|
+
this.loadedFile?.path === path &&
|
|
893
|
+
request === this.editRequest &&
|
|
894
|
+
this.editController === controller &&
|
|
895
|
+
!controller.signal.aborted
|
|
896
|
+
);
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
private beginDetailRequest(): { request: number; signal: AbortSignal } {
|
|
900
|
+
this.cancelDetailRequest();
|
|
901
|
+
const request = this.detailRequest;
|
|
902
|
+
const controller = new AbortController();
|
|
903
|
+
this.detailController = controller;
|
|
904
|
+
this.loading = true;
|
|
905
|
+
return { request, signal: controller.signal };
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
private finishDetailRequest(request: number): void {
|
|
909
|
+
if (request === this.detailRequest) {
|
|
910
|
+
this.loading = false;
|
|
911
|
+
this.detailController = undefined;
|
|
912
|
+
}
|
|
913
|
+
if (!this.finished && !this.disposed) this.options.tui.requestRender();
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
private cancelDetailRequest(): void {
|
|
917
|
+
this.detailRequest += 1;
|
|
918
|
+
this.detailController?.abort();
|
|
919
|
+
this.detailController = undefined;
|
|
920
|
+
this.loading = false;
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
private cancelEditRequest(): void {
|
|
924
|
+
this.editRequest += 1;
|
|
925
|
+
this.editController?.abort();
|
|
926
|
+
this.editController = undefined;
|
|
927
|
+
this.loading = false;
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
private movePreviewCursor(next: number): void {
|
|
931
|
+
if (next === this.previewCursor) return;
|
|
932
|
+
this.cancelDetailRequest();
|
|
933
|
+
this.previewCursor = next;
|
|
934
|
+
this.blame = undefined;
|
|
935
|
+
this.error = undefined;
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
private createSelectedQuote(): FileQuote {
|
|
939
|
+
if (!this.loadedFile) throw new Error("No file is open");
|
|
940
|
+
return createPreviewQuote(
|
|
941
|
+
this.loadedFile,
|
|
942
|
+
this.previewAnchor ?? this.previewCursor,
|
|
943
|
+
this.previewCursor,
|
|
944
|
+
this.options.gitContext?.project,
|
|
945
|
+
this.loadedGit,
|
|
946
|
+
this.loadedRevision,
|
|
947
|
+
);
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
private returnToOrigin(): void {
|
|
951
|
+
this.cancelDetailRequest();
|
|
952
|
+
this.cancelOpenRequest();
|
|
953
|
+
this.cancelEditRequest();
|
|
954
|
+
this.mode = this.previewReturnMode;
|
|
955
|
+
this.loadedFile = undefined;
|
|
956
|
+
this.loadedGit = undefined;
|
|
957
|
+
this.loadedRevision = undefined;
|
|
958
|
+
this.previewAnchor = undefined;
|
|
959
|
+
this.blame = undefined;
|
|
960
|
+
this.error = undefined;
|
|
961
|
+
this.search.focused = this.isFocused && this.mode === "files";
|
|
962
|
+
this.contentSearch.focused = this.isFocused && this.mode === "contents";
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
private finish(result: FileQuoteExplorerResult | undefined): void {
|
|
966
|
+
this.finished = true;
|
|
967
|
+
this.contentSearch.dispose();
|
|
968
|
+
this.cancelOpenRequest();
|
|
969
|
+
this.cancelDetailRequest();
|
|
970
|
+
this.cancelEditRequest();
|
|
971
|
+
this.options.done(result);
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
private rootExit(kind: "back" | "close"): FileQuoteExplorerResult | undefined {
|
|
975
|
+
return this.options.rootNavigation ? { kind } : undefined;
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
private keepFileVisible(height: number): void {
|
|
979
|
+
if (this.selectedFileIndex < this.fileScrollOffset) this.fileScrollOffset = this.selectedFileIndex;
|
|
980
|
+
if (this.selectedFileIndex >= this.fileScrollOffset + height) {
|
|
981
|
+
this.fileScrollOffset = this.selectedFileIndex - height + 1;
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
function isAbortError(error: unknown): boolean {
|
|
987
|
+
return error instanceof Error && error.name === "AbortError";
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
function formatError(error: unknown): string {
|
|
991
|
+
return error instanceof Error ? error.message : String(error);
|
|
992
|
+
}
|