@diegopetrucci/pi-annotate-git-diff 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/.pi-fleet-tested-version +1 -0
- package/README.md +43 -0
- package/clipboard.ts +143 -0
- package/git.ts +943 -0
- package/glimpseui.d.ts +89 -0
- package/index.ts +412 -0
- package/package.json +50 -0
- package/prompt.ts +65 -0
- package/quiet-glimpse.ts +156 -0
- package/types.ts +202 -0
- package/ui.ts +71 -0
- package/watch.ts +104 -0
- package/web/app.js +2381 -0
- package/web/index.html +913 -0
package/quiet-glimpse.ts
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/// <reference path="./glimpseui.d.ts" />
|
|
2
|
+
|
|
3
|
+
import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process";
|
|
4
|
+
import { EventEmitter } from "node:events";
|
|
5
|
+
import { existsSync } from "node:fs";
|
|
6
|
+
import { createInterface } from "node:readline";
|
|
7
|
+
import type { GlimpseOpenOptions } from "glimpseui";
|
|
8
|
+
|
|
9
|
+
interface NativeHostInfo {
|
|
10
|
+
path: string;
|
|
11
|
+
extraArgs?: string[];
|
|
12
|
+
buildHint?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface GlimpseProtocolMessage {
|
|
16
|
+
type?: string;
|
|
17
|
+
data?: unknown;
|
|
18
|
+
screen?: unknown;
|
|
19
|
+
screens?: unknown;
|
|
20
|
+
appearance?: unknown;
|
|
21
|
+
cursor?: unknown;
|
|
22
|
+
cursorTip?: unknown;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface QuietGlimpseWindow {
|
|
26
|
+
on(event: "message", listener: (data: unknown) => void): this;
|
|
27
|
+
on(event: "closed", listener: () => void): this;
|
|
28
|
+
on(event: "error", listener: (error: Error) => void): this;
|
|
29
|
+
removeListener(event: "message", listener: (data: unknown) => void): this;
|
|
30
|
+
removeListener(event: "closed", listener: () => void): this;
|
|
31
|
+
removeListener(event: "error", listener: (error: Error) => void): this;
|
|
32
|
+
send(js: string): void;
|
|
33
|
+
close(): void;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
class QuietGlimpseWindowImpl extends EventEmitter implements QuietGlimpseWindow {
|
|
37
|
+
#proc: ChildProcessWithoutNullStreams;
|
|
38
|
+
#closed = false;
|
|
39
|
+
#pendingHTML: string | null;
|
|
40
|
+
#stderr = "";
|
|
41
|
+
|
|
42
|
+
constructor(proc: ChildProcessWithoutNullStreams, initialHTML: string) {
|
|
43
|
+
super();
|
|
44
|
+
this.#proc = proc;
|
|
45
|
+
this.#pendingHTML = initialHTML;
|
|
46
|
+
|
|
47
|
+
proc.stdin.on("error", () => {});
|
|
48
|
+
proc.stderr.on("data", (chunk) => {
|
|
49
|
+
this.#stderr += chunk.toString();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const rl = createInterface({ input: proc.stdout, crlfDelay: Infinity });
|
|
53
|
+
rl.on("line", (line) => {
|
|
54
|
+
let message: GlimpseProtocolMessage;
|
|
55
|
+
try {
|
|
56
|
+
message = JSON.parse(line) as GlimpseProtocolMessage;
|
|
57
|
+
} catch {
|
|
58
|
+
this.emit("error", new Error(`Malformed glimpse protocol line: ${line}`));
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
switch (message.type) {
|
|
63
|
+
case "ready":
|
|
64
|
+
if (this.#pendingHTML != null) {
|
|
65
|
+
this.setHTML(this.#pendingHTML);
|
|
66
|
+
this.#pendingHTML = null;
|
|
67
|
+
}
|
|
68
|
+
break;
|
|
69
|
+
case "message":
|
|
70
|
+
this.emit("message", message.data);
|
|
71
|
+
break;
|
|
72
|
+
case "closed":
|
|
73
|
+
this.#markClosed();
|
|
74
|
+
break;
|
|
75
|
+
default:
|
|
76
|
+
break;
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
proc.on("error", (error) => this.emit("error", error));
|
|
81
|
+
proc.on("exit", (code, signal) => {
|
|
82
|
+
const stderr = this.#stderr.trim();
|
|
83
|
+
const failed = signal != null || (code != null && code !== 0);
|
|
84
|
+
if (!this.#closed && failed) {
|
|
85
|
+
const message = stderr || `Glimpse process exited abnormally (code: ${code}, signal: ${signal}).`;
|
|
86
|
+
this.emit("error", new Error(message));
|
|
87
|
+
}
|
|
88
|
+
this.#markClosed();
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
send(js: string): void {
|
|
93
|
+
this.#write({ type: "eval", js });
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
close(): void {
|
|
97
|
+
this.#write({ type: "close" });
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
#markClosed(): void {
|
|
101
|
+
if (this.#closed) return;
|
|
102
|
+
this.#closed = true;
|
|
103
|
+
this.emit("closed");
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
#write(obj: Record<string, unknown>): void {
|
|
107
|
+
if (this.#closed) return;
|
|
108
|
+
this.#proc.stdin.write(`${JSON.stringify(obj)}\n`);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
setHTML(html: string): void {
|
|
112
|
+
this.#write({ type: "html", html: Buffer.from(html).toString("base64") });
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function getNativeHostInfo(): Promise<NativeHostInfo> {
|
|
117
|
+
const glimpseModule = (await import("glimpseui")) as unknown as { getNativeHostInfo: () => NativeHostInfo };
|
|
118
|
+
return glimpseModule.getNativeHostInfo();
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export async function openQuietGlimpse(html: string, options: GlimpseOpenOptions = {}): Promise<QuietGlimpseWindow> {
|
|
122
|
+
const host = await getNativeHostInfo();
|
|
123
|
+
if (!existsSync(host.path)) {
|
|
124
|
+
const hint = host.buildHint ? ` ${host.buildHint}` : "";
|
|
125
|
+
throw new Error(`Glimpse host not found at '${host.path}'.${hint}`);
|
|
126
|
+
}
|
|
127
|
+
const args: string[] = [];
|
|
128
|
+
|
|
129
|
+
if (options.width != null) args.push("--width", String(options.width));
|
|
130
|
+
if (options.height != null) args.push("--height", String(options.height));
|
|
131
|
+
if (options.title != null) args.push("--title", options.title);
|
|
132
|
+
if (options.frameless) args.push("--frameless");
|
|
133
|
+
if (options.floating) args.push("--floating");
|
|
134
|
+
if (options.transparent) args.push("--transparent");
|
|
135
|
+
if (options.clickThrough) args.push("--click-through");
|
|
136
|
+
if (options.hidden) args.push("--hidden");
|
|
137
|
+
if (options.autoClose) args.push("--auto-close");
|
|
138
|
+
if (options.x != null) args.push(`--x=${options.x}`);
|
|
139
|
+
if (options.y != null) args.push(`--y=${options.y}`);
|
|
140
|
+
if (options.cursorOffset?.x != null) args.push(`--cursor-offset-x=${options.cursorOffset.x}`);
|
|
141
|
+
if (options.cursorOffset?.y != null) args.push(`--cursor-offset-y=${options.cursorOffset.y}`);
|
|
142
|
+
if (options.cursorAnchor != null) args.push("--cursor-anchor", options.cursorAnchor);
|
|
143
|
+
if (options.followMode != null) args.push("--follow-mode", options.followMode);
|
|
144
|
+
if (options.followCursor) args.push("--follow-cursor");
|
|
145
|
+
|
|
146
|
+
const proc = spawn(host.path, [...(host.extraArgs ?? []), ...args], {
|
|
147
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
148
|
+
windowsHide: process.platform === "win32",
|
|
149
|
+
env: {
|
|
150
|
+
...process.env,
|
|
151
|
+
OS_ACTIVITY_MODE: process.env.OS_ACTIVITY_MODE ?? "disable",
|
|
152
|
+
},
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
return new QuietGlimpseWindowImpl(proc, html);
|
|
156
|
+
}
|
package/types.ts
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
export type ReviewScope = "branch" | "commits" | "all";
|
|
2
|
+
|
|
3
|
+
export type ChangeStatus = "modified" | "added" | "deleted" | "renamed";
|
|
4
|
+
|
|
5
|
+
export type ReviewFileKind = "text" | "binary" | "image";
|
|
6
|
+
|
|
7
|
+
export type ReviewCommitKind = "commit" | "working-tree";
|
|
8
|
+
|
|
9
|
+
export interface ReviewFileComparison {
|
|
10
|
+
status: ChangeStatus;
|
|
11
|
+
oldPath: string | null;
|
|
12
|
+
newPath: string | null;
|
|
13
|
+
displayPath: string;
|
|
14
|
+
hasOriginal: boolean;
|
|
15
|
+
hasModified: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface ReviewFile {
|
|
19
|
+
id: string;
|
|
20
|
+
path: string;
|
|
21
|
+
worktreeStatus: ChangeStatus | null;
|
|
22
|
+
hasWorkingTreeFile: boolean;
|
|
23
|
+
/** True when the file is touched by the current branch diff (merge-base/base ref vs HEAD). */
|
|
24
|
+
inGitDiff: boolean;
|
|
25
|
+
/** Diff comparison to render for the file in the current scope.
|
|
26
|
+
* - branch scope: merge-base/base ref vs HEAD
|
|
27
|
+
* - commits scope: commit vs commit^ (populated when loaded via request-commit) */
|
|
28
|
+
gitDiff: ReviewFileComparison | null;
|
|
29
|
+
kind: ReviewFileKind;
|
|
30
|
+
mimeType: string | null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface ReviewCommitInfo {
|
|
34
|
+
sha: string;
|
|
35
|
+
shortSha: string;
|
|
36
|
+
subject: string;
|
|
37
|
+
authorName: string;
|
|
38
|
+
authorDate: string;
|
|
39
|
+
kind: ReviewCommitKind;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface ReviewFileContents {
|
|
43
|
+
originalContent: string;
|
|
44
|
+
modifiedContent: string;
|
|
45
|
+
kind: ReviewFileKind;
|
|
46
|
+
mimeType: string | null;
|
|
47
|
+
originalExists: boolean;
|
|
48
|
+
modifiedExists: boolean;
|
|
49
|
+
originalPreviewUrl: string | null;
|
|
50
|
+
modifiedPreviewUrl: string | null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export type CommentSide = "original" | "modified" | "file";
|
|
54
|
+
|
|
55
|
+
export interface DiffReviewComment {
|
|
56
|
+
id: string;
|
|
57
|
+
fileId: string;
|
|
58
|
+
scope: ReviewScope;
|
|
59
|
+
/** Commit SHA when scope === "commits". */
|
|
60
|
+
commitSha?: string | null;
|
|
61
|
+
/** Short commit identifier to render in the prompt (e.g. first 7 chars of SHA). */
|
|
62
|
+
commitShort?: string | null;
|
|
63
|
+
commitKind?: ReviewCommitKind | null;
|
|
64
|
+
side: CommentSide;
|
|
65
|
+
startLine: number | null;
|
|
66
|
+
endLine: number | null;
|
|
67
|
+
body: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface ReviewSubmitPayload {
|
|
71
|
+
type: "submit";
|
|
72
|
+
overallComment: string;
|
|
73
|
+
comments: DiffReviewComment[];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface ReviewCancelPayload {
|
|
77
|
+
type: "cancel";
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface ReviewRequestFilePayload {
|
|
81
|
+
type: "request-file";
|
|
82
|
+
requestId: string;
|
|
83
|
+
fileId: string;
|
|
84
|
+
scope: ReviewScope;
|
|
85
|
+
commitSha?: string | null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface ReviewRequestCommitPayload {
|
|
89
|
+
type: "request-commit";
|
|
90
|
+
requestId: string;
|
|
91
|
+
sha: string;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface ReviewRequestReviewDataPayload {
|
|
95
|
+
type: "request-review-data";
|
|
96
|
+
requestId: string;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export interface ReviewClipboardReadPayload {
|
|
100
|
+
type: "clipboard-read";
|
|
101
|
+
requestId: string;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export interface ReviewClipboardWritePayload {
|
|
105
|
+
type: "clipboard-write";
|
|
106
|
+
text: string;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export type ReviewWindowMessage =
|
|
110
|
+
| ReviewSubmitPayload
|
|
111
|
+
| ReviewCancelPayload
|
|
112
|
+
| ReviewRequestFilePayload
|
|
113
|
+
| ReviewRequestCommitPayload
|
|
114
|
+
| ReviewRequestReviewDataPayload
|
|
115
|
+
| ReviewClipboardReadPayload
|
|
116
|
+
| ReviewClipboardWritePayload;
|
|
117
|
+
|
|
118
|
+
export interface ReviewFileDataMessage {
|
|
119
|
+
type: "file-data";
|
|
120
|
+
requestId: string;
|
|
121
|
+
fileId: string;
|
|
122
|
+
scope: ReviewScope;
|
|
123
|
+
commitSha?: string | null;
|
|
124
|
+
originalContent: string;
|
|
125
|
+
modifiedContent: string;
|
|
126
|
+
kind: ReviewFileKind;
|
|
127
|
+
mimeType: string | null;
|
|
128
|
+
originalExists: boolean;
|
|
129
|
+
modifiedExists: boolean;
|
|
130
|
+
originalPreviewUrl: string | null;
|
|
131
|
+
modifiedPreviewUrl: string | null;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export interface ReviewFileErrorMessage {
|
|
135
|
+
type: "file-error";
|
|
136
|
+
requestId: string;
|
|
137
|
+
fileId: string;
|
|
138
|
+
scope: ReviewScope;
|
|
139
|
+
commitSha?: string | null;
|
|
140
|
+
message: string;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export interface ReviewCommitDataMessage {
|
|
144
|
+
type: "commit-data";
|
|
145
|
+
requestId: string;
|
|
146
|
+
sha: string;
|
|
147
|
+
files: ReviewFile[];
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export interface ReviewCommitErrorMessage {
|
|
151
|
+
type: "commit-error";
|
|
152
|
+
requestId: string;
|
|
153
|
+
sha: string;
|
|
154
|
+
message: string;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export interface ReviewReviewDataMessage {
|
|
158
|
+
type: "review-data";
|
|
159
|
+
requestId: string;
|
|
160
|
+
files: ReviewFile[];
|
|
161
|
+
commits: ReviewCommitInfo[];
|
|
162
|
+
branchBaseRef: string | null;
|
|
163
|
+
branchMergeBaseSha: string | null;
|
|
164
|
+
repositoryHasHead: boolean;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export interface ReviewReviewDataErrorMessage {
|
|
168
|
+
type: "review-data-error";
|
|
169
|
+
requestId: string;
|
|
170
|
+
message: string;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export interface ReviewClipboardDataMessage {
|
|
174
|
+
type: "clipboard-data";
|
|
175
|
+
requestId: string;
|
|
176
|
+
text: string;
|
|
177
|
+
message?: string;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export interface ReviewWorkingTreeChangedMessage {
|
|
181
|
+
type: "working-tree-changed";
|
|
182
|
+
changedAt: number;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export type ReviewHostMessage =
|
|
186
|
+
| ReviewFileDataMessage
|
|
187
|
+
| ReviewFileErrorMessage
|
|
188
|
+
| ReviewCommitDataMessage
|
|
189
|
+
| ReviewCommitErrorMessage
|
|
190
|
+
| ReviewReviewDataMessage
|
|
191
|
+
| ReviewReviewDataErrorMessage
|
|
192
|
+
| ReviewClipboardDataMessage
|
|
193
|
+
| ReviewWorkingTreeChangedMessage;
|
|
194
|
+
|
|
195
|
+
export interface ReviewWindowData {
|
|
196
|
+
repoRoot: string;
|
|
197
|
+
files: ReviewFile[];
|
|
198
|
+
commits: ReviewCommitInfo[];
|
|
199
|
+
branchBaseRef: string | null;
|
|
200
|
+
branchMergeBaseSha: string | null;
|
|
201
|
+
repositoryHasHead: boolean;
|
|
202
|
+
}
|
package/ui.ts
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { pathToFileURL } from "node:url";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import type { ReviewWindowData } from "./types.js";
|
|
7
|
+
|
|
8
|
+
const require = createRequire(import.meta.url);
|
|
9
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
10
|
+
const webDir = join(__dirname, "web");
|
|
11
|
+
|
|
12
|
+
function escapeForInlineScript(value: string): string {
|
|
13
|
+
return value.replace(/</g, "\\u003c").replace(/>/g, "\\u003e").replace(/&/g, "\\u0026");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function escapeInlineScriptSource(value: string): string {
|
|
17
|
+
return value.replace(/<\/(script)/gi, "<\\/$1");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface ReviewUiAssets {
|
|
21
|
+
tailwindBrowserJs: string;
|
|
22
|
+
monacoLoaderJs: string;
|
|
23
|
+
monacoVsBaseUrl: string;
|
|
24
|
+
bootstrapError: string | null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function safeReadResolvedAsset(specifier: string): string {
|
|
28
|
+
return readFileSync(require.resolve(specifier), "utf8");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function resolveReviewUiAssets(): ReviewUiAssets {
|
|
32
|
+
try {
|
|
33
|
+
const tailwindBrowserJs = safeReadResolvedAsset("@tailwindcss/browser");
|
|
34
|
+
const monacoLoaderPath = join(dirname(require.resolve("monaco-editor/package.json")), "min", "vs", "loader.js");
|
|
35
|
+
const monacoLoaderJs = readFileSync(monacoLoaderPath, "utf8");
|
|
36
|
+
const monacoVsBaseUrl = pathToFileURL(dirname(monacoLoaderPath)).href;
|
|
37
|
+
return {
|
|
38
|
+
tailwindBrowserJs,
|
|
39
|
+
monacoLoaderJs,
|
|
40
|
+
monacoVsBaseUrl,
|
|
41
|
+
bootstrapError: null,
|
|
42
|
+
};
|
|
43
|
+
} catch (error) {
|
|
44
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
45
|
+
return {
|
|
46
|
+
tailwindBrowserJs: "",
|
|
47
|
+
monacoLoaderJs: "",
|
|
48
|
+
monacoVsBaseUrl: "",
|
|
49
|
+
bootstrapError: `Unable to load packaged review UI assets: ${message}`,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function buildReviewHtml(data: ReviewWindowData): string {
|
|
55
|
+
const templateHtml = readFileSync(join(webDir, "index.html"), "utf8");
|
|
56
|
+
const appJs = escapeInlineScriptSource(readFileSync(join(webDir, "app.js"), "utf8"));
|
|
57
|
+
const assets = resolveReviewUiAssets();
|
|
58
|
+
const payload = escapeForInlineScript(JSON.stringify(data));
|
|
59
|
+
const assetConfig = escapeForInlineScript(
|
|
60
|
+
JSON.stringify({
|
|
61
|
+
monacoVsBaseUrl: assets.monacoVsBaseUrl,
|
|
62
|
+
bootstrapError: assets.bootstrapError,
|
|
63
|
+
}),
|
|
64
|
+
);
|
|
65
|
+
return templateHtml
|
|
66
|
+
.replace('"__INLINE_DATA__"', payload)
|
|
67
|
+
.replace("__INLINE_ASSET_CONFIG__", assetConfig)
|
|
68
|
+
.replace("__INLINE_TAILWIND_JS__", escapeInlineScriptSource(assets.tailwindBrowserJs))
|
|
69
|
+
.replace("__INLINE_MONACO_LOADER_JS__", escapeInlineScriptSource(assets.monacoLoaderJs))
|
|
70
|
+
.replace("__INLINE_JS__", appJs);
|
|
71
|
+
}
|
package/watch.ts
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { type FSWatcher, watch } from "node:fs";
|
|
2
|
+
|
|
3
|
+
export interface RepoChangeWatcher {
|
|
4
|
+
dispose(): void;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
interface RepoChangeWatcherOptions {
|
|
8
|
+
debounceMs?: number;
|
|
9
|
+
onError?: (error: Error) => void;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const DEFAULT_DEBOUNCE_MS = 2000;
|
|
13
|
+
|
|
14
|
+
const ignoredPathSegments = new Set([
|
|
15
|
+
".cache",
|
|
16
|
+
".git",
|
|
17
|
+
".hg",
|
|
18
|
+
".next",
|
|
19
|
+
".nuxt",
|
|
20
|
+
".svn",
|
|
21
|
+
".turbo",
|
|
22
|
+
"build",
|
|
23
|
+
"coverage",
|
|
24
|
+
"dist",
|
|
25
|
+
"node_modules",
|
|
26
|
+
"out",
|
|
27
|
+
"target",
|
|
28
|
+
"tmp",
|
|
29
|
+
]);
|
|
30
|
+
|
|
31
|
+
const ignoredFileNames = new Set([".DS_Store"]);
|
|
32
|
+
|
|
33
|
+
function normalizeWatchPath(path: string): string {
|
|
34
|
+
return path.replace(/\\/g, "/").replace(/^\/+/, "");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function isIgnoredWatchPath(path: string | Buffer | null | undefined): boolean {
|
|
38
|
+
if (path == null) return false;
|
|
39
|
+
const normalized = normalizeWatchPath(path.toString());
|
|
40
|
+
if (normalized.length === 0) return false;
|
|
41
|
+
|
|
42
|
+
const segments = normalized.split("/").filter((segment) => segment.length > 0);
|
|
43
|
+
if (segments.some((segment) => ignoredPathSegments.has(segment))) return true;
|
|
44
|
+
|
|
45
|
+
const fileName = segments.at(-1) ?? normalized;
|
|
46
|
+
if (ignoredFileNames.has(fileName)) return true;
|
|
47
|
+
if (fileName.endsWith("~") || fileName.endsWith(".swp") || fileName.endsWith(".tmp")) return true;
|
|
48
|
+
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function createRepoChangeWatcher(
|
|
53
|
+
repoRoot: string,
|
|
54
|
+
onChange: () => void,
|
|
55
|
+
options: RepoChangeWatcherOptions = {},
|
|
56
|
+
): RepoChangeWatcher {
|
|
57
|
+
const debounceMs = options.debounceMs ?? DEFAULT_DEBOUNCE_MS;
|
|
58
|
+
let disposed = false;
|
|
59
|
+
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
60
|
+
let watcher: FSWatcher | null = null;
|
|
61
|
+
|
|
62
|
+
const clearPending = (): void => {
|
|
63
|
+
if (timer == null) return;
|
|
64
|
+
clearTimeout(timer);
|
|
65
|
+
timer = null;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const scheduleChange = (path: string | Buffer | null): void => {
|
|
69
|
+
if (disposed || isIgnoredWatchPath(path)) return;
|
|
70
|
+
clearPending();
|
|
71
|
+
timer = setTimeout(() => {
|
|
72
|
+
timer = null;
|
|
73
|
+
if (!disposed) onChange();
|
|
74
|
+
}, debounceMs);
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
try {
|
|
78
|
+
watcher = watch(repoRoot, { recursive: true }, (_eventType, fileName) => {
|
|
79
|
+
scheduleChange(fileName);
|
|
80
|
+
});
|
|
81
|
+
watcher.on("error", (error) => {
|
|
82
|
+
if (!disposed) options.onError?.(error);
|
|
83
|
+
});
|
|
84
|
+
} catch (error) {
|
|
85
|
+
options.onError?.(error instanceof Error ? error : new Error(String(error)));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
dispose() {
|
|
90
|
+
disposed = true;
|
|
91
|
+
clearPending();
|
|
92
|
+
try {
|
|
93
|
+
watcher?.close();
|
|
94
|
+
} catch {
|
|
95
|
+
// Ignore watcher shutdown races.
|
|
96
|
+
}
|
|
97
|
+
watcher = null;
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export const __testing = {
|
|
103
|
+
isIgnoredWatchPath,
|
|
104
|
+
};
|