@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,485 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { realpath } from "node:fs/promises";
|
|
3
|
+
import { isAbsolute, relative, resolve, sep } from "node:path";
|
|
4
|
+
import { promisify } from "node:util";
|
|
5
|
+
|
|
6
|
+
const execFileAsync = promisify(execFile);
|
|
7
|
+
const GIT_TIMEOUT_MS = 5_000;
|
|
8
|
+
const GIT_MAX_BUFFER = 1_100_000;
|
|
9
|
+
const MAX_HISTORY_ENTRIES = 20;
|
|
10
|
+
|
|
11
|
+
export interface GitProjectInfo {
|
|
12
|
+
repositoryRoot: string;
|
|
13
|
+
projectPrefix: string;
|
|
14
|
+
branch: string;
|
|
15
|
+
head: string;
|
|
16
|
+
dirty: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface GitFileStatus {
|
|
20
|
+
code: string;
|
|
21
|
+
label: string;
|
|
22
|
+
staged: boolean;
|
|
23
|
+
unstaged: boolean;
|
|
24
|
+
untracked: boolean;
|
|
25
|
+
ignored: boolean;
|
|
26
|
+
conflicted: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface GitDiffHunk {
|
|
30
|
+
header: string;
|
|
31
|
+
oldStart: number;
|
|
32
|
+
oldCount: number;
|
|
33
|
+
newStart: number;
|
|
34
|
+
newCount: number;
|
|
35
|
+
lines: string[];
|
|
36
|
+
changedLines: number[];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface GitFileContext {
|
|
40
|
+
status: GitFileStatus | undefined;
|
|
41
|
+
blob: string | undefined;
|
|
42
|
+
hunks: GitDiffHunk[];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface GitBlameInfo {
|
|
46
|
+
commit: string;
|
|
47
|
+
author: string;
|
|
48
|
+
authorTime: number | undefined;
|
|
49
|
+
summary: string;
|
|
50
|
+
committed: boolean;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface GitHistoryEntry {
|
|
54
|
+
commit: string;
|
|
55
|
+
author: string;
|
|
56
|
+
authorTime: number;
|
|
57
|
+
summary: string;
|
|
58
|
+
path: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface GitRevisionFile {
|
|
62
|
+
path: string;
|
|
63
|
+
lines: string[];
|
|
64
|
+
revision: string;
|
|
65
|
+
commit: string;
|
|
66
|
+
blob: string | undefined;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export class GitContext {
|
|
70
|
+
private readonly statusMap: Map<string, GitFileStatus>;
|
|
71
|
+
private readonly work = new Set<Promise<GitResult>>();
|
|
72
|
+
|
|
73
|
+
constructor(
|
|
74
|
+
readonly projectRoot: string,
|
|
75
|
+
readonly project: GitProjectInfo,
|
|
76
|
+
statuses: ReadonlyMap<string, GitFileStatus>,
|
|
77
|
+
) {
|
|
78
|
+
this.statusMap = new Map(statuses);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
get statuses(): ReadonlyMap<string, GitFileStatus> {
|
|
82
|
+
return this.statusMap;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async getFileContext(projectPath: string, signal?: AbortSignal): Promise<GitFileContext> {
|
|
86
|
+
this.assertProjectPath(projectPath);
|
|
87
|
+
const [blobResult, diffResult] = await Promise.all([
|
|
88
|
+
this.run(["rev-parse", "--verify", `HEAD:${this.repositoryPath(projectPath)}`], true, signal),
|
|
89
|
+
this.run(["diff", "--no-ext-diff", "--no-textconv", "--unified=3", "HEAD", "--", projectPath], true, signal),
|
|
90
|
+
]);
|
|
91
|
+
signal?.throwIfAborted();
|
|
92
|
+
return {
|
|
93
|
+
status: this.statusMap.get(projectPath),
|
|
94
|
+
blob: blobResult.ok ? blobResult.stdout.trim() || undefined : undefined,
|
|
95
|
+
hunks: diffResult.ok ? parseUnifiedDiff(diffResult.stdout) : [],
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async refreshFileContext(projectPath: string, signal?: AbortSignal): Promise<GitFileContext> {
|
|
100
|
+
const [fileContext, statusResult] = await Promise.all([
|
|
101
|
+
this.getFileContext(projectPath, signal),
|
|
102
|
+
this.run(
|
|
103
|
+
[
|
|
104
|
+
"-c",
|
|
105
|
+
"status.relativePaths=true",
|
|
106
|
+
"status",
|
|
107
|
+
"--porcelain=v1",
|
|
108
|
+
"-z",
|
|
109
|
+
"--untracked-files=all",
|
|
110
|
+
"--ignored=traditional",
|
|
111
|
+
"--",
|
|
112
|
+
".",
|
|
113
|
+
],
|
|
114
|
+
true,
|
|
115
|
+
signal,
|
|
116
|
+
),
|
|
117
|
+
]);
|
|
118
|
+
signal?.throwIfAborted();
|
|
119
|
+
if (!statusResult.ok) return fileContext;
|
|
120
|
+
const statuses = parseStatuses(statusResult.stdout, this.project.projectPrefix);
|
|
121
|
+
this.statusMap.clear();
|
|
122
|
+
for (const [path, status] of statuses) this.statusMap.set(path, status);
|
|
123
|
+
this.project.dirty = [...statuses.values()].some((status) => !status.ignored);
|
|
124
|
+
return { ...fileContext, status: this.statusMap.get(projectPath) };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async getBlame(
|
|
128
|
+
projectPath: string,
|
|
129
|
+
line: number,
|
|
130
|
+
revision?: string,
|
|
131
|
+
signal?: AbortSignal,
|
|
132
|
+
): Promise<GitBlameInfo | undefined> {
|
|
133
|
+
this.assertProjectPath(projectPath);
|
|
134
|
+
if (!Number.isSafeInteger(line) || line < 1) throw new Error("Blame line must be positive");
|
|
135
|
+
if (revision && !/^[0-9a-f]{40}$/i.test(revision)) throw new Error("Invalid blame revision");
|
|
136
|
+
const result = await this.run(
|
|
137
|
+
[
|
|
138
|
+
"blame",
|
|
139
|
+
"--no-textconv",
|
|
140
|
+
"--line-porcelain",
|
|
141
|
+
"-L",
|
|
142
|
+
`${line},${line}`,
|
|
143
|
+
...(revision ? [revision] : []),
|
|
144
|
+
"--",
|
|
145
|
+
projectPath,
|
|
146
|
+
],
|
|
147
|
+
true,
|
|
148
|
+
signal,
|
|
149
|
+
);
|
|
150
|
+
signal?.throwIfAborted();
|
|
151
|
+
if (!result.ok) return undefined;
|
|
152
|
+
return parseBlame(result.stdout);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async getHistory(projectPath: string, signal?: AbortSignal): Promise<GitHistoryEntry[]> {
|
|
156
|
+
this.assertProjectPath(projectPath);
|
|
157
|
+
const result = await this.run(
|
|
158
|
+
[
|
|
159
|
+
"log",
|
|
160
|
+
`--max-count=${MAX_HISTORY_ENTRIES}`,
|
|
161
|
+
"--format=%x1e%H%x1f%an%x1f%at%x1f%s",
|
|
162
|
+
"--name-only",
|
|
163
|
+
"-z",
|
|
164
|
+
"--follow",
|
|
165
|
+
"--",
|
|
166
|
+
projectPath,
|
|
167
|
+
],
|
|
168
|
+
true,
|
|
169
|
+
signal,
|
|
170
|
+
);
|
|
171
|
+
signal?.throwIfAborted();
|
|
172
|
+
return result.ok ? parseHistory(result.stdout) : [];
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async loadRevision(
|
|
176
|
+
projectPath: string,
|
|
177
|
+
revision: string,
|
|
178
|
+
historicalPath?: string,
|
|
179
|
+
signal?: AbortSignal,
|
|
180
|
+
): Promise<GitRevisionFile> {
|
|
181
|
+
this.assertProjectPath(projectPath);
|
|
182
|
+
const normalizedRevision = revision.trim();
|
|
183
|
+
if (!normalizedRevision || /[\0\r\n]/.test(normalizedRevision)) {
|
|
184
|
+
throw new Error("Revision is required");
|
|
185
|
+
}
|
|
186
|
+
const resolved = await this.run(
|
|
187
|
+
["rev-parse", "--verify", "--end-of-options", `${normalizedRevision}^{commit}`],
|
|
188
|
+
true,
|
|
189
|
+
signal,
|
|
190
|
+
);
|
|
191
|
+
if (!resolved.ok || !/^[0-9a-f]{40}$/i.test(resolved.stdout.trim())) {
|
|
192
|
+
throw new Error(`Unknown Git revision: ${normalizedRevision}`);
|
|
193
|
+
}
|
|
194
|
+
signal?.throwIfAborted();
|
|
195
|
+
const commit = resolved.stdout.trim().toLowerCase();
|
|
196
|
+
const repositoryPath = historicalPath
|
|
197
|
+
? this.assertRepositoryPath(historicalPath)
|
|
198
|
+
: this.repositoryPath(projectPath);
|
|
199
|
+
const [contentsResult, blobResult] = await Promise.all([
|
|
200
|
+
this.run(["show", `${commit}:${repositoryPath}`], true, signal),
|
|
201
|
+
this.run(["rev-parse", "--verify", `${commit}:${repositoryPath}`], true, signal),
|
|
202
|
+
]);
|
|
203
|
+
signal?.throwIfAborted();
|
|
204
|
+
if (!contentsResult.ok) {
|
|
205
|
+
throw new Error(`${projectPath} does not exist at ${normalizedRevision}`);
|
|
206
|
+
}
|
|
207
|
+
if (Buffer.byteLength(contentsResult.stdout, "utf8") > 1_000_000) {
|
|
208
|
+
throw new Error(`${projectPath} exceeds 1000000 bytes at ${normalizedRevision}`);
|
|
209
|
+
}
|
|
210
|
+
if (contentsResult.stdout.includes("\0")) {
|
|
211
|
+
throw new Error(`${projectPath} appears to be binary at ${normalizedRevision}`);
|
|
212
|
+
}
|
|
213
|
+
return {
|
|
214
|
+
path: historicalPath ?? projectPath,
|
|
215
|
+
lines: normalizeLines(contentsResult.stdout),
|
|
216
|
+
revision: normalizedRevision,
|
|
217
|
+
commit,
|
|
218
|
+
blob: blobResult.ok ? blobResult.stdout.trim() || undefined : undefined,
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Drain processes after the owning explorer cancels its request controllers. */
|
|
223
|
+
async whenIdle(): Promise<void> {
|
|
224
|
+
await Promise.allSettled([...this.work]);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
private run(args: string[], allowFailure = false, signal?: AbortSignal): Promise<GitResult> {
|
|
228
|
+
const work = runGit(this.projectRoot, args, allowFailure, signal);
|
|
229
|
+
this.work.add(work);
|
|
230
|
+
void work.then(
|
|
231
|
+
() => this.work.delete(work),
|
|
232
|
+
() => this.work.delete(work),
|
|
233
|
+
);
|
|
234
|
+
return work;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
private repositoryPath(projectPath: string): string {
|
|
238
|
+
return `${this.project.projectPrefix}${projectPath}`.replaceAll("\\", "/");
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
private assertRepositoryPath(repositoryPath: string): string {
|
|
242
|
+
const normalized = repositoryPath.replaceAll("\\", "/");
|
|
243
|
+
if (!normalized || isAbsolute(normalized) || normalized.includes("\0")) {
|
|
244
|
+
throw new Error("Invalid historical Git path");
|
|
245
|
+
}
|
|
246
|
+
const candidate = resolve(this.project.repositoryRoot, normalized);
|
|
247
|
+
const result = relative(this.project.repositoryRoot, candidate);
|
|
248
|
+
if (result === ".." || result.startsWith(`..${sep}`)) {
|
|
249
|
+
throw new Error("Historical Git path is outside the repository");
|
|
250
|
+
}
|
|
251
|
+
return normalized;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
private assertProjectPath(projectPath: string): void {
|
|
255
|
+
if (!projectPath || isAbsolute(projectPath) || projectPath.includes("\0")) {
|
|
256
|
+
throw new Error("Invalid project path");
|
|
257
|
+
}
|
|
258
|
+
const candidate = resolve(this.projectRoot, projectPath);
|
|
259
|
+
const result = relative(this.projectRoot, candidate);
|
|
260
|
+
if (result === ".." || result.startsWith(`..${sep}`)) {
|
|
261
|
+
throw new Error("Git path is outside the project");
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export async function createGitContext(root: string, signal?: AbortSignal): Promise<GitContext | undefined> {
|
|
267
|
+
signal?.throwIfAborted();
|
|
268
|
+
const projectRoot = await realpath(root);
|
|
269
|
+
signal?.throwIfAborted();
|
|
270
|
+
const repositoryResult = await runGit(projectRoot, ["rev-parse", "--show-toplevel"], true, signal);
|
|
271
|
+
if (!repositoryResult.ok) return undefined;
|
|
272
|
+
const repositoryRoot = stripLineEnding(repositoryResult.stdout);
|
|
273
|
+
if (!repositoryRoot) return undefined;
|
|
274
|
+
const [prefixResult, headResult, branchResult, statusResult] = await settleGitReads([
|
|
275
|
+
runGit(projectRoot, ["rev-parse", "--show-prefix"], true, signal),
|
|
276
|
+
runGit(projectRoot, ["rev-parse", "--verify", "HEAD"], true, signal),
|
|
277
|
+
runGit(projectRoot, ["symbolic-ref", "--quiet", "--short", "HEAD"], true, signal),
|
|
278
|
+
runGit(
|
|
279
|
+
projectRoot,
|
|
280
|
+
[
|
|
281
|
+
"-c",
|
|
282
|
+
"status.relativePaths=true",
|
|
283
|
+
"status",
|
|
284
|
+
"--porcelain=v1",
|
|
285
|
+
"-z",
|
|
286
|
+
"--untracked-files=all",
|
|
287
|
+
"--ignored=traditional",
|
|
288
|
+
"--",
|
|
289
|
+
".",
|
|
290
|
+
],
|
|
291
|
+
true,
|
|
292
|
+
signal,
|
|
293
|
+
),
|
|
294
|
+
]);
|
|
295
|
+
signal?.throwIfAborted();
|
|
296
|
+
const head = headResult.ok ? headResult.stdout.trim().toLowerCase() : "unborn";
|
|
297
|
+
const branch = branchResult.ok ? branchResult.stdout.trim() : head === "unborn" ? "unborn" : head.slice(0, 12);
|
|
298
|
+
const projectPrefix = prefixResult.ok ? stripLineEnding(prefixResult.stdout) : "";
|
|
299
|
+
const statuses = statusResult.ok ? parseStatuses(statusResult.stdout, projectPrefix) : new Map();
|
|
300
|
+
return new GitContext(
|
|
301
|
+
projectRoot,
|
|
302
|
+
{
|
|
303
|
+
repositoryRoot,
|
|
304
|
+
projectPrefix,
|
|
305
|
+
branch,
|
|
306
|
+
head,
|
|
307
|
+
dirty: [...statuses.values()].some((status) => !status.ignored),
|
|
308
|
+
},
|
|
309
|
+
statuses,
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
interface GitResult {
|
|
314
|
+
ok: boolean;
|
|
315
|
+
stdout: string;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
async function runGit(cwd: string, args: string[], allowFailure: boolean, signal?: AbortSignal): Promise<GitResult> {
|
|
319
|
+
try {
|
|
320
|
+
const task = execFileAsync("git", ["--no-pager", "-c", "core.pager=cat", "-c", "core.fsmonitor=false", ...args], {
|
|
321
|
+
cwd,
|
|
322
|
+
encoding: "utf8",
|
|
323
|
+
timeout: GIT_TIMEOUT_MS,
|
|
324
|
+
maxBuffer: GIT_MAX_BUFFER,
|
|
325
|
+
signal,
|
|
326
|
+
env: {
|
|
327
|
+
...process.env,
|
|
328
|
+
GIT_OPTIONAL_LOCKS: "0",
|
|
329
|
+
GIT_PAGER: "cat",
|
|
330
|
+
PAGER: "cat",
|
|
331
|
+
},
|
|
332
|
+
});
|
|
333
|
+
const closed = new Promise<void>((resolve) => {
|
|
334
|
+
task.child.once("close", () => resolve());
|
|
335
|
+
});
|
|
336
|
+
let stdout: string;
|
|
337
|
+
try {
|
|
338
|
+
({ stdout } = await task);
|
|
339
|
+
} finally {
|
|
340
|
+
await closed;
|
|
341
|
+
}
|
|
342
|
+
return { ok: true, stdout };
|
|
343
|
+
} catch (error: unknown) {
|
|
344
|
+
if (signal?.aborted) throw error;
|
|
345
|
+
if (allowFailure) return { ok: false, stdout: "" };
|
|
346
|
+
throw error;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
async function settleGitReads(reads: Promise<GitResult>[]): Promise<GitResult[]> {
|
|
351
|
+
const results = await Promise.allSettled(reads);
|
|
352
|
+
return results.map((result) => {
|
|
353
|
+
if (result.status === "rejected") throw result.reason;
|
|
354
|
+
return result.value;
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function parseStatuses(output: string, projectPrefix: string): Map<string, GitFileStatus> {
|
|
359
|
+
const statuses = new Map<string, GitFileStatus>();
|
|
360
|
+
const records = output.split("\0");
|
|
361
|
+
for (let index = 0; index < records.length; index += 1) {
|
|
362
|
+
const record = records[index];
|
|
363
|
+
if (!record || record.length < 4) continue;
|
|
364
|
+
const code = record.slice(0, 2);
|
|
365
|
+
const repositoryPath = record.slice(3).replaceAll("\\", "/");
|
|
366
|
+
const projectPath = projectPrefix
|
|
367
|
+
? repositoryPath.startsWith(projectPrefix)
|
|
368
|
+
? repositoryPath.slice(projectPrefix.length)
|
|
369
|
+
: undefined
|
|
370
|
+
: repositoryPath;
|
|
371
|
+
if (projectPath) statuses.set(projectPath, statusFromCode(code));
|
|
372
|
+
if (code.includes("R") || code.includes("C")) index += 1;
|
|
373
|
+
}
|
|
374
|
+
return new Map([...statuses].sort(([left], [right]) => compareStrings(left, right)));
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function statusFromCode(code: string): GitFileStatus {
|
|
378
|
+
const conflicted = /^(DD|AU|UD|UA|DU|AA|UU)$/.test(code);
|
|
379
|
+
const untracked = code === "??";
|
|
380
|
+
const ignored = code === "!!";
|
|
381
|
+
const staged = !untracked && !ignored && code[0] !== " ";
|
|
382
|
+
const unstaged = !untracked && !ignored && code[1] !== " ";
|
|
383
|
+
let label: string;
|
|
384
|
+
if (conflicted) label = "conflicted";
|
|
385
|
+
else if (untracked) label = "untracked";
|
|
386
|
+
else if (ignored) label = "ignored";
|
|
387
|
+
else {
|
|
388
|
+
const character = unstaged ? code[1] : code[0];
|
|
389
|
+
const action =
|
|
390
|
+
character === "A"
|
|
391
|
+
? "added"
|
|
392
|
+
: character === "D"
|
|
393
|
+
? "deleted"
|
|
394
|
+
: character === "R"
|
|
395
|
+
? "renamed"
|
|
396
|
+
: character === "C"
|
|
397
|
+
? "copied"
|
|
398
|
+
: character === "T"
|
|
399
|
+
? "type changed"
|
|
400
|
+
: "modified";
|
|
401
|
+
const location = staged && unstaged ? "staged + unstaged" : staged ? "staged" : "unstaged";
|
|
402
|
+
label = `${action} (${location})`;
|
|
403
|
+
}
|
|
404
|
+
return { code, label, staged, unstaged, untracked, ignored, conflicted };
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function parseUnifiedDiff(output: string): GitDiffHunk[] {
|
|
408
|
+
const hunks: GitDiffHunk[] = [];
|
|
409
|
+
let current: GitDiffHunk | undefined;
|
|
410
|
+
let newLine = 0;
|
|
411
|
+
for (const line of output.replaceAll("\r\n", "\n").split("\n")) {
|
|
412
|
+
const match = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(.*)$/.exec(line);
|
|
413
|
+
if (match) {
|
|
414
|
+
current = {
|
|
415
|
+
header: line,
|
|
416
|
+
oldStart: Number(match[1]),
|
|
417
|
+
oldCount: Number(match[2] ?? 1),
|
|
418
|
+
newStart: Number(match[3]),
|
|
419
|
+
newCount: Number(match[4] ?? 1),
|
|
420
|
+
lines: [line],
|
|
421
|
+
changedLines: [],
|
|
422
|
+
};
|
|
423
|
+
newLine = current.newStart;
|
|
424
|
+
hunks.push(current);
|
|
425
|
+
continue;
|
|
426
|
+
}
|
|
427
|
+
if (!current) continue;
|
|
428
|
+
current.lines.push(line);
|
|
429
|
+
if (line.startsWith("+") && !line.startsWith("+++")) {
|
|
430
|
+
current.changedLines.push(newLine);
|
|
431
|
+
newLine += 1;
|
|
432
|
+
} else if (!line.startsWith("-") && !line.startsWith("\\")) {
|
|
433
|
+
newLine += 1;
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
for (const hunk of hunks) {
|
|
437
|
+
while (hunk.lines.at(-1) === "") hunk.lines.pop();
|
|
438
|
+
}
|
|
439
|
+
return hunks;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
function parseBlame(output: string): GitBlameInfo | undefined {
|
|
443
|
+
const lines = output.split("\n");
|
|
444
|
+
const commit = lines[0]?.split(" ")[0];
|
|
445
|
+
if (!commit) return undefined;
|
|
446
|
+
const field = (name: string) => lines.find((line) => line.startsWith(`${name} `))?.slice(name.length + 1);
|
|
447
|
+
const authorTime = Number(field("author-time"));
|
|
448
|
+
return {
|
|
449
|
+
commit,
|
|
450
|
+
author: field("author") ?? "Unknown",
|
|
451
|
+
authorTime: Number.isFinite(authorTime) ? authorTime : undefined,
|
|
452
|
+
summary: field("summary") ?? "",
|
|
453
|
+
committed: !/^0+$/.test(commit),
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function parseHistory(output: string): GitHistoryEntry[] {
|
|
458
|
+
return output
|
|
459
|
+
.split("\x1e")
|
|
460
|
+
.filter(Boolean)
|
|
461
|
+
.flatMap((record) => {
|
|
462
|
+
const [metadata = "", ...pathRecords] = record.split("\0");
|
|
463
|
+
const [commit, author, authorTime, ...summaryParts] = metadata.replace(/^\n+/, "").split("\x1f");
|
|
464
|
+
const path = pathRecords.map((value) => value.replace(/^\n+/, "")).find(Boolean);
|
|
465
|
+
const time = Number(authorTime);
|
|
466
|
+
return commit && author && path && Number.isFinite(time)
|
|
467
|
+
? [{ commit, author, authorTime: time, summary: summaryParts.join("\x1f"), path }]
|
|
468
|
+
: [];
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
function compareStrings(left: string, right: string): number {
|
|
473
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function stripLineEnding(value: string): string {
|
|
477
|
+
return value.endsWith("\r\n") ? value.slice(0, -2) : value.endsWith("\n") ? value.slice(0, -1) : value;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
function normalizeLines(contents: string): string[] {
|
|
481
|
+
if (contents === "") return [];
|
|
482
|
+
const lines = contents.replaceAll("\r\n", "\n").replaceAll("\r", "\n").split("\n");
|
|
483
|
+
if (lines.at(-1) === "") lines.pop();
|
|
484
|
+
return lines;
|
|
485
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default } from "./files.js";
|