@xbzbing/dsh-git-panel 1.5.3 → 1.6.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/{README_EN.md → README.en.md} +6 -4
- package/README.md +7 -5
- package/lib/client.js +109 -19
- package/lib/host/actions.d.ts +6 -4
- package/lib/host/actions.js +250 -0
- package/lib/host/core.d.ts +12 -4
- package/lib/host/core.js +305 -0
- package/lib/host/git.js +65 -0
- package/lib/host/index.d.ts +20 -14
- package/lib/host/index.js +45 -1686
- package/lib/host/llm-face.js +0 -0
- package/lib/host/parser.d.ts +1 -1
- package/lib/host/parser.js +213 -0
- package/lib/host/queries.d.ts +2 -2
- package/lib/host/queries.js +499 -0
- package/lib/host/remote.d.ts +1 -1
- package/lib/host/remote.js +48 -0
- package/lib/host/suggest.d.ts +2 -2
- package/lib/host/suggest.js +220 -0
- package/lib/host/types.d.ts +73 -3
- package/lib/host/types.js +36 -0
- package/lib/host/validate.js +26 -0
- package/lib/host/version.js +130 -0
- package/package.json +11 -4
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { mapWorkspaceFailure, resolveWorkspace, runCommand } from "./core.js";
|
|
2
|
+
import { isSafePath } from "./validate.js";
|
|
3
|
+
import { parseStatus } from "./parser.js";
|
|
4
|
+
const SYSTEM_PROMPT = [
|
|
5
|
+
"You are an expert at writing concise, accurate git commit messages.",
|
|
6
|
+
"Given a repository\u2019s uncommitted changes, write ONE commit message for them.",
|
|
7
|
+
"The message must start with a short imperative subject line (72 chars max), optionally followed by a blank line and a brief body explaining what changed and why.",
|
|
8
|
+
"Return only the message text: no quotes, code fences, prefixes, bullet lists, or explanations.",
|
|
9
|
+
"Match the language and style of the recent commit message when one is provided; otherwise write in English.",
|
|
10
|
+
"Focus on the most important change; do not mechanically list every file."
|
|
11
|
+
].join("\n");
|
|
12
|
+
const UNTRACKED_FILE_CAP = 16;
|
|
13
|
+
function resolveRoute(config, agentDefaultModel) {
|
|
14
|
+
if (config.suggestProvider !== void 0 && config.suggestModel !== void 0) {
|
|
15
|
+
return { provider: config.suggestProvider, model: config.suggestModel };
|
|
16
|
+
}
|
|
17
|
+
return agentDefaultModel?.currentSelection();
|
|
18
|
+
}
|
|
19
|
+
function withDeadline(timeoutMs, parent) {
|
|
20
|
+
const controller = new AbortController();
|
|
21
|
+
let timedOut = false;
|
|
22
|
+
const timer = setTimeout(() => {
|
|
23
|
+
timedOut = true;
|
|
24
|
+
controller.abort();
|
|
25
|
+
}, timeoutMs);
|
|
26
|
+
const onAbort = () => {
|
|
27
|
+
controller.abort();
|
|
28
|
+
};
|
|
29
|
+
if (parent !== void 0) {
|
|
30
|
+
if (parent.aborted) {
|
|
31
|
+
controller.abort();
|
|
32
|
+
} else parent.addEventListener("abort", onAbort, { once: true });
|
|
33
|
+
}
|
|
34
|
+
return {
|
|
35
|
+
signal: controller.signal,
|
|
36
|
+
expired: () => timedOut,
|
|
37
|
+
dispose: () => {
|
|
38
|
+
clearTimeout(timer);
|
|
39
|
+
parent?.removeEventListener("abort", onAbort);
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
function truncateUtf8(text, maxBytes) {
|
|
44
|
+
if (Buffer.byteLength(text, "utf8") <= maxBytes) return text;
|
|
45
|
+
let lo = 0;
|
|
46
|
+
let hi = text.length;
|
|
47
|
+
while (lo < hi) {
|
|
48
|
+
const mid = lo + hi + 1 >> 1;
|
|
49
|
+
if (Buffer.byteLength(text.slice(0, mid), "utf8") <= maxBytes) lo = mid;
|
|
50
|
+
else hi = mid - 1;
|
|
51
|
+
}
|
|
52
|
+
return text.slice(0, lo);
|
|
53
|
+
}
|
|
54
|
+
function frameChanges(files, diff, recentCommitMessage) {
|
|
55
|
+
return JSON.stringify({
|
|
56
|
+
files,
|
|
57
|
+
diff,
|
|
58
|
+
recentCommitMessage: recentCommitMessage === "" ? null : recentCommitMessage
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
function normalizeMessage(raw) {
|
|
62
|
+
let m = raw.trim();
|
|
63
|
+
if (m.startsWith("```")) {
|
|
64
|
+
const firstNl = m.indexOf("\n");
|
|
65
|
+
if (firstNl !== -1) {
|
|
66
|
+
m = m.slice(firstNl + 1);
|
|
67
|
+
m = m.endsWith("```") ? m.slice(0, -3) : m;
|
|
68
|
+
m = m.trim();
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return m.replace(/\n---+\s*$/, "").trim();
|
|
72
|
+
}
|
|
73
|
+
function finishError(reason) {
|
|
74
|
+
if (reason === void 0 || reason.kind === "stop") return void 0;
|
|
75
|
+
if (reason.kind === "max-tokens") return { ok: false, error: { code: "llm-output", message: "model output hit the token limit" } };
|
|
76
|
+
if (reason.kind === "tool-calls") return { ok: false, error: { code: "llm-output", message: "model requested a tool instead of writing a message" } };
|
|
77
|
+
const failure = "failure" in reason ? reason.failure : void 0;
|
|
78
|
+
return { ok: false, error: { code: "llm-error", message: failure?.message ?? `model stream finished abnormally (${reason.kind})` } };
|
|
79
|
+
}
|
|
80
|
+
async function collectDiff(deps, root, hasHead, pathArgs) {
|
|
81
|
+
const cmds = hasHead ? [["git", "diff", "HEAD", ...pathArgs]] : [["git", "diff", "--cached", ...pathArgs], ["git", "diff", ...pathArgs]];
|
|
82
|
+
const runs = await Promise.all(cmds.map((argv) => runCommand(deps.run, argv, root, "suggest-diff", deps.signal)));
|
|
83
|
+
let diff = "";
|
|
84
|
+
let lossy = false;
|
|
85
|
+
for (const res of runs) {
|
|
86
|
+
if ("failure" in res) return { ok: false, error: { code: "git-unavailable", message: "diff failed" } };
|
|
87
|
+
if (res.run.cancelled) return { ok: false, error: { code: "cancelled" } };
|
|
88
|
+
if (res.run.timedOut) return { ok: false, error: { code: "timeout" } };
|
|
89
|
+
if (res.run.exitCode !== 0) {
|
|
90
|
+
return { ok: false, error: { code: "git-error", message: res.run.stderr.trim() || "git diff failed" } };
|
|
91
|
+
}
|
|
92
|
+
if (res.run.stdoutLossy) lossy = true;
|
|
93
|
+
diff += res.run.stdout;
|
|
94
|
+
}
|
|
95
|
+
return { diff, lossy };
|
|
96
|
+
}
|
|
97
|
+
async function appendUntracked(deps, root, files, diff, budget) {
|
|
98
|
+
let text = diff;
|
|
99
|
+
let truncated = false;
|
|
100
|
+
const candidates = files.filter((c) => c.status === "untracked" && !c.isDirectory && isSafePath(c.path));
|
|
101
|
+
for (const c of candidates.slice(0, UNTRACKED_FILE_CAP)) {
|
|
102
|
+
const remaining = budget - Buffer.byteLength(text, "utf8");
|
|
103
|
+
if (remaining <= 0) {
|
|
104
|
+
truncated = true;
|
|
105
|
+
break;
|
|
106
|
+
}
|
|
107
|
+
const res = await runCommand(deps.run, ["git", "diff", "--no-index", "--", "/dev/null", c.path], root, "suggest-untracked", deps.signal);
|
|
108
|
+
if ("failure" in res || res.run.cancelled || res.run.timedOut) continue;
|
|
109
|
+
const chunk = res.run.stdout;
|
|
110
|
+
if (chunk === "") continue;
|
|
111
|
+
const lossy = res.run.stdoutLossy;
|
|
112
|
+
if (lossy || Buffer.byteLength(chunk, "utf8") > remaining) {
|
|
113
|
+
text += truncateUtf8(chunk, remaining) + "\n... [diff truncated]";
|
|
114
|
+
truncated = true;
|
|
115
|
+
break;
|
|
116
|
+
}
|
|
117
|
+
text += chunk;
|
|
118
|
+
}
|
|
119
|
+
return { diff: text, truncated };
|
|
120
|
+
}
|
|
121
|
+
async function runSuggest(deps, config, request) {
|
|
122
|
+
if (!config.suggestEnabled) {
|
|
123
|
+
return { ok: false, error: { code: "suggest-disabled", message: "AI suggest is turned off in the plugin config" } };
|
|
124
|
+
}
|
|
125
|
+
const workspace = await resolveWorkspace(deps, request.sessionId);
|
|
126
|
+
if (!workspace.ok) return { ok: false, error: mapWorkspaceFailure(workspace.failure) };
|
|
127
|
+
const root = workspace.root;
|
|
128
|
+
const paths = request.paths ?? [];
|
|
129
|
+
for (const path of paths) {
|
|
130
|
+
if (!isSafePath(path)) return { ok: false, error: { code: "invalid-path", message: path } };
|
|
131
|
+
}
|
|
132
|
+
const pathArgs = paths.length > 0 ? ["--", ...paths] : [];
|
|
133
|
+
const pathSet = new Set(paths);
|
|
134
|
+
const [statusRes, headRes, lastRes] = await Promise.all([
|
|
135
|
+
runCommand(deps.run, ["git", "status", "--porcelain=v1", "-z"], root, "suggest-status", deps.signal),
|
|
136
|
+
runCommand(deps.run, ["git", "rev-parse", "--verify", "HEAD"], root, "suggest-head", deps.signal),
|
|
137
|
+
runCommand(deps.run, ["git", "log", "-1", "--format=%B"], root, "suggest-last", deps.signal)
|
|
138
|
+
]);
|
|
139
|
+
if ("failure" in statusRes) return { ok: false, error: { code: "git-unavailable", message: "status failed" } };
|
|
140
|
+
if (statusRes.run.cancelled) return { ok: false, error: { code: "cancelled" } };
|
|
141
|
+
if (statusRes.run.timedOut) return { ok: false, error: { code: "timeout" } };
|
|
142
|
+
if (statusRes.run.exitCode !== 0) {
|
|
143
|
+
return { ok: false, error: { code: "git-error", message: statusRes.run.stderr.trim() || "git status failed" } };
|
|
144
|
+
}
|
|
145
|
+
const hasHead = "run" in headRes && headRes.run.exitCode === 0;
|
|
146
|
+
const recentCommitMessage = "run" in lastRes && lastRes.run.exitCode === 0 ? lastRes.run.stdout.replace(/\n+$/, "") : "";
|
|
147
|
+
const all = statusRes.run.stdoutLossy ? [] : parseStatus(statusRes.run.stdout);
|
|
148
|
+
const files = pathSet.size > 0 ? all.filter((c) => pathSet.has(c.path)) : all;
|
|
149
|
+
const collected = await collectDiff(deps, root, hasHead, pathArgs);
|
|
150
|
+
if (!("diff" in collected)) return collected;
|
|
151
|
+
if (files.length === 0 && collected.diff.trim() === "") {
|
|
152
|
+
return { ok: false, error: { code: "empty-diff", message: "no uncommitted changes to summarize" } };
|
|
153
|
+
}
|
|
154
|
+
let diff = collected.diff;
|
|
155
|
+
let truncated = collected.lossy;
|
|
156
|
+
if (Buffer.byteLength(diff, "utf8") > config.suggestMaxBytes) {
|
|
157
|
+
diff = truncateUtf8(diff, config.suggestMaxBytes) + "\n... [diff truncated]";
|
|
158
|
+
truncated = true;
|
|
159
|
+
}
|
|
160
|
+
const untracked = await appendUntracked(deps, root, files, diff, config.suggestMaxBytes);
|
|
161
|
+
diff = untracked.diff;
|
|
162
|
+
truncated = truncated || untracked.truncated;
|
|
163
|
+
const llm = deps.getLlm?.();
|
|
164
|
+
const route = resolveRoute(config, deps.getAgentDefaultModel?.());
|
|
165
|
+
if (llm === void 0 || route === void 0) {
|
|
166
|
+
return { ok: false, error: { code: "llm-unavailable", message: "no model service or route is configured" } };
|
|
167
|
+
}
|
|
168
|
+
const frame = frameChanges(
|
|
169
|
+
files.map((c) => ({ path: c.path, status: c.status })),
|
|
170
|
+
diff,
|
|
171
|
+
recentCommitMessage
|
|
172
|
+
);
|
|
173
|
+
const deadline = withDeadline(config.suggestTimeoutMs, deps.signal);
|
|
174
|
+
const options = {
|
|
175
|
+
provider: route.provider,
|
|
176
|
+
model: route.model,
|
|
177
|
+
system: SYSTEM_PROMPT,
|
|
178
|
+
messages: [{
|
|
179
|
+
id: "git-panel-suggest",
|
|
180
|
+
role: "user",
|
|
181
|
+
content: [{ type: "text", text: frame }],
|
|
182
|
+
source: { kind: "user" }
|
|
183
|
+
}],
|
|
184
|
+
maxTokens: config.suggestMaxOutputTokens,
|
|
185
|
+
temperature: 0.3,
|
|
186
|
+
signal: deadline.signal
|
|
187
|
+
};
|
|
188
|
+
let output = "";
|
|
189
|
+
let finish;
|
|
190
|
+
try {
|
|
191
|
+
for await (const chunk of llm.stream(options)) {
|
|
192
|
+
if (chunk.type === "text-delta") output += chunk.text;
|
|
193
|
+
else if (chunk.type === "finish") finish = chunk.reason;
|
|
194
|
+
}
|
|
195
|
+
} catch (error) {
|
|
196
|
+
if (deps.signal?.aborted === true) return { ok: false, error: { code: "cancelled" } };
|
|
197
|
+
if (deadline.expired()) return { ok: false, error: { code: "llm-error", message: `model call timed out after ${config.suggestTimeoutMs}ms` } };
|
|
198
|
+
return { ok: false, error: { code: "llm-error", message: error instanceof Error ? error.message : "model stream failed" } };
|
|
199
|
+
} finally {
|
|
200
|
+
deadline.dispose();
|
|
201
|
+
}
|
|
202
|
+
if (deps.signal?.aborted === true) return { ok: false, error: { code: "cancelled" } };
|
|
203
|
+
if (deadline.expired()) return { ok: false, error: { code: "llm-error", message: `model call timed out after ${config.suggestTimeoutMs}ms` } };
|
|
204
|
+
const terminal = finishError(finish);
|
|
205
|
+
if (terminal !== void 0) return terminal;
|
|
206
|
+
const message = normalizeMessage(output);
|
|
207
|
+
if (message === "") return { ok: false, error: { code: "llm-output", message: "model produced no message text" } };
|
|
208
|
+
return {
|
|
209
|
+
ok: true,
|
|
210
|
+
value: {
|
|
211
|
+
message,
|
|
212
|
+
provider: route.provider,
|
|
213
|
+
model: route.model,
|
|
214
|
+
...truncated ? { truncated: true } : {}
|
|
215
|
+
}
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
export {
|
|
219
|
+
runSuggest
|
|
220
|
+
};
|
package/lib/host/types.d.ts
CHANGED
|
@@ -9,6 +9,26 @@ export type DiffViewMode = 'unified' | 'split';
|
|
|
9
9
|
* lane (git log --graph); `parallel` = keep each merge's first parent in its
|
|
10
10
|
* own lane, converging only at the ancestor (VSCode / GUI tools). */
|
|
11
11
|
export type GraphStyle = 'compact' | 'parallel';
|
|
12
|
+
/**
|
|
13
|
+
* Diff context (-U) values shared by both halves. `DEFAULT` matches git's own
|
|
14
|
+
* default and is what the host applies when a request omits `context`.
|
|
15
|
+
* `MAX` is the host's clamp ceiling. `EXPAND` is what the "expand all" button
|
|
16
|
+
* asks for: deliberately bounded, because a -U window wide enough to cover a
|
|
17
|
+
* huge file makes the host emit that whole file as one hunk — megabytes of
|
|
18
|
+
* text, and past the runner's stdout cap a silently truncated diff. Whatever
|
|
19
|
+
* the window does not reach stays a collapsed gap the view reveals on demand.
|
|
20
|
+
*/
|
|
21
|
+
export declare const DIFF_CONTEXT_DEFAULT = 3;
|
|
22
|
+
export declare const DIFF_CONTEXT_MAX = 100000;
|
|
23
|
+
export declare const DIFF_EXPAND_CONTEXT = 2000;
|
|
24
|
+
/**
|
|
25
|
+
* Ceiling for the working-file read taken only to count the diff's new-side
|
|
26
|
+
* lines. That count decides whether hidden context follows the last hunk, so it
|
|
27
|
+
* is read on every diff query — including the changes tab's poll while a diff is
|
|
28
|
+
* open — while `file-lines` (the on-demand expansion) stays uncapped. Above this
|
|
29
|
+
* size the count reports 0 (unknown) and no trailing control is offered.
|
|
30
|
+
*/
|
|
31
|
+
export declare const DIFF_COUNT_READ_MAX: number;
|
|
12
32
|
/** Wire request: the browser sends only session identity, never a path. */
|
|
13
33
|
export interface GitSnapshotRequest {
|
|
14
34
|
readonly sessionId: string;
|
|
@@ -145,6 +165,8 @@ export type GitAction = {
|
|
|
145
165
|
} | {
|
|
146
166
|
readonly kind: 'branch-checkout';
|
|
147
167
|
readonly name: string;
|
|
168
|
+
readonly createFrom?: string;
|
|
169
|
+
readonly stashMessage?: string;
|
|
148
170
|
} | {
|
|
149
171
|
readonly kind: 'fetch';
|
|
150
172
|
} | {
|
|
@@ -188,7 +210,7 @@ export type GitAction = {
|
|
|
188
210
|
/** `git reset` modes, from least to most destructive. Single source so the
|
|
189
211
|
* client dialog and the command builder cannot drift. */
|
|
190
212
|
export type ResetMode = 'soft' | 'mixed' | 'hard';
|
|
191
|
-
export type GitErrorCode = 'cwd-unavailable' | 'not-a-git-repo' | 'git-unavailable' | 'invalid-path' | 'invalid-name' | 'invalid-index' | 'git-error' | 'timeout' | 'cancelled' | 'empty-message' | 'local-changes-block' | 'not-ff' | 'push-rejected' | 'auth-failed' | 'conflict' | 'revert-conflict' | 'revert-stuck' | 'revert-merge' | 'not-found' | 'index-busy' | 'empty-diff' | 'llm-unavailable' | 'llm-error' | 'llm-output' | 'suggest-disabled';
|
|
213
|
+
export type GitErrorCode = 'cwd-unavailable' | 'not-a-git-repo' | 'git-unavailable' | 'invalid-path' | 'invalid-name' | 'invalid-index' | 'git-error' | 'timeout' | 'cancelled' | 'empty-message' | 'local-changes-block' | 'not-ff' | 'push-rejected' | 'auth-failed' | 'conflict' | 'revert-conflict' | 'revert-stuck' | 'revert-merge' | 'not-found' | 'branch-in-use' | 'stash-switch-failed' | 'index-busy' | 'empty-diff' | 'llm-unavailable' | 'llm-error' | 'llm-output' | 'suggest-disabled';
|
|
192
214
|
export type GitActionResult = {
|
|
193
215
|
readonly ok: true;
|
|
194
216
|
readonly snapshot: GitSnapshot;
|
|
@@ -335,10 +357,32 @@ export type GitQueryResult = {
|
|
|
335
357
|
readonly kind: 'history';
|
|
336
358
|
readonly commits: readonly GraphCommit[];
|
|
337
359
|
readonly total: number;
|
|
338
|
-
}
|
|
360
|
+
}
|
|
361
|
+
/**
|
|
362
|
+
* Unified diff text plus the new-side line count. The count is the fact the
|
|
363
|
+
* view needs to decide whether hidden context can follow the LAST hunk: git
|
|
364
|
+
* caps trailing context at -U, so a full trailing run is ambiguous from the
|
|
365
|
+
* text alone (it may be mid-file, or the file may end exactly there), while
|
|
366
|
+
* `newLines` makes it provable.
|
|
367
|
+
*
|
|
368
|
+
* 0 means "no trailing control is possible, or the count is not trustworthy":
|
|
369
|
+
* the side does not exist (a deleted file), the read failed or was truncated,
|
|
370
|
+
* the diff has no hunk at all, or the working file is above
|
|
371
|
+
* DIFF_COUNT_READ_MAX. The view treats all of those the same way — no
|
|
372
|
+
* trailing gap rather than a speculative one.
|
|
373
|
+
*
|
|
374
|
+
* `truncated` marks the one case where the text itself is incomplete: the
|
|
375
|
+
* runner dropped output at its collect/spill ceiling, so `text` is a prefix
|
|
376
|
+
* that still parses like a real diff. It is flagged rather than refused —
|
|
377
|
+
* the partial hunks stay readable — but the view must say so, because line
|
|
378
|
+
* numbers and +/- counts past the cut are simply wrong.
|
|
379
|
+
*/
|
|
380
|
+
| {
|
|
339
381
|
readonly kind: 'diff';
|
|
340
382
|
readonly path: string;
|
|
341
383
|
readonly text: string;
|
|
384
|
+
readonly newLines: number;
|
|
385
|
+
readonly truncated?: true;
|
|
342
386
|
} | {
|
|
343
387
|
readonly kind: 'file-lines';
|
|
344
388
|
readonly path: string;
|
|
@@ -444,6 +488,31 @@ export interface GitQueryRequest {
|
|
|
444
488
|
readonly sessionId: string;
|
|
445
489
|
readonly query: GitQuery;
|
|
446
490
|
}
|
|
491
|
+
/**
|
|
492
|
+
* Suggest-limit ranges and defaults, in host storage units (bytes / tokens /
|
|
493
|
+
* ms) — the single source shared by both plugin halves (the client bundle
|
|
494
|
+
* imports this value, like imageMimeFor below): the host's DEFAULT_CONFIG and
|
|
495
|
+
* normalizeConfig clamp every read into [min, max], and the detail-page form
|
|
496
|
+
* derives its display units from it (the /1024 and /1000 divisions are exact
|
|
497
|
+
* and guarded by test).
|
|
498
|
+
*/
|
|
499
|
+
export declare const SUGGEST_LIMITS: {
|
|
500
|
+
readonly maxBytes: {
|
|
501
|
+
readonly min: number;
|
|
502
|
+
readonly max: number;
|
|
503
|
+
readonly default: number;
|
|
504
|
+
};
|
|
505
|
+
readonly maxOutputTokens: {
|
|
506
|
+
readonly min: 256;
|
|
507
|
+
readonly max: 16384;
|
|
508
|
+
readonly default: 4096;
|
|
509
|
+
};
|
|
510
|
+
readonly timeoutMs: {
|
|
511
|
+
readonly min: 5000;
|
|
512
|
+
readonly max: 120000;
|
|
513
|
+
readonly default: 15000;
|
|
514
|
+
};
|
|
515
|
+
};
|
|
447
516
|
/**
|
|
448
517
|
* Wire request for the suggest endpoint: ask the configured model to write a
|
|
449
518
|
* commit message from the repository's uncommitted changes. `paths` restricts
|
|
@@ -462,7 +531,8 @@ export type GitSuggestResult = {
|
|
|
462
531
|
/** Actual provider/model route used (display only). */
|
|
463
532
|
readonly provider?: string;
|
|
464
533
|
readonly model?: string;
|
|
465
|
-
/** The diff
|
|
534
|
+
/** The diff reached the model truncated: over the input cap, or cut
|
|
535
|
+
* by the per-command runner stdout cap before it. */
|
|
466
536
|
readonly truncated?: boolean;
|
|
467
537
|
};
|
|
468
538
|
} | {
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
const DIFF_CONTEXT_DEFAULT = 3;
|
|
2
|
+
const DIFF_CONTEXT_MAX = 1e5;
|
|
3
|
+
const DIFF_EXPAND_CONTEXT = 2e3;
|
|
4
|
+
const DIFF_COUNT_READ_MAX = 8 * 1024 * 1024;
|
|
5
|
+
const SUGGEST_LIMITS = {
|
|
6
|
+
maxBytes: { min: 16 * 1024, max: 512 * 1024, default: 128 * 1024 },
|
|
7
|
+
maxOutputTokens: { min: 256, max: 16384, default: 4096 },
|
|
8
|
+
timeoutMs: { min: 5e3, max: 12e4, default: 15e3 }
|
|
9
|
+
};
|
|
10
|
+
const IMAGE_MIME = {
|
|
11
|
+
png: "image/png",
|
|
12
|
+
jpg: "image/jpeg",
|
|
13
|
+
jpeg: "image/jpeg",
|
|
14
|
+
gif: "image/gif",
|
|
15
|
+
webp: "image/webp",
|
|
16
|
+
avif: "image/avif",
|
|
17
|
+
bmp: "image/bmp",
|
|
18
|
+
svg: "image/svg+xml",
|
|
19
|
+
ico: "image/x-icon",
|
|
20
|
+
tif: "image/tiff",
|
|
21
|
+
tiff: "image/tiff"
|
|
22
|
+
};
|
|
23
|
+
function imageMimeFor(path) {
|
|
24
|
+
const dot = path.lastIndexOf(".");
|
|
25
|
+
if (dot < 0) return null;
|
|
26
|
+
return IMAGE_MIME[path.slice(dot + 1).toLowerCase()] ?? null;
|
|
27
|
+
}
|
|
28
|
+
export {
|
|
29
|
+
DIFF_CONTEXT_DEFAULT,
|
|
30
|
+
DIFF_CONTEXT_MAX,
|
|
31
|
+
DIFF_COUNT_READ_MAX,
|
|
32
|
+
DIFF_EXPAND_CONTEXT,
|
|
33
|
+
IMAGE_MIME,
|
|
34
|
+
SUGGEST_LIMITS,
|
|
35
|
+
imageMimeFor
|
|
36
|
+
};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { isAbsolute, normalize } from "node:path";
|
|
2
|
+
function isSafePath(path) {
|
|
3
|
+
if (path === "" || isAbsolute(path)) return false;
|
|
4
|
+
const norm = normalize(path);
|
|
5
|
+
if (norm === ".." || norm.startsWith("../") || norm.startsWith("..\\")) return false;
|
|
6
|
+
return true;
|
|
7
|
+
}
|
|
8
|
+
const REV_META = /[\x00-\x20\x7f~^:?*[\\]/;
|
|
9
|
+
function isSafeRev(input) {
|
|
10
|
+
if (input === "" || input.startsWith("-")) return false;
|
|
11
|
+
if (REV_META.test(input)) return false;
|
|
12
|
+
if (input.includes("..") || input.includes("@{")) return false;
|
|
13
|
+
return true;
|
|
14
|
+
}
|
|
15
|
+
function isSafeBranchName(name) {
|
|
16
|
+
return isSafeRev(name) && !name.startsWith("/");
|
|
17
|
+
}
|
|
18
|
+
function isSafeRemoteName(name) {
|
|
19
|
+
return /^[A-Za-z0-9._][A-Za-z0-9._/-]*$/.test(name) && name.length <= 128;
|
|
20
|
+
}
|
|
21
|
+
export {
|
|
22
|
+
isSafeBranchName,
|
|
23
|
+
isSafePath,
|
|
24
|
+
isSafeRemoteName,
|
|
25
|
+
isSafeRev
|
|
26
|
+
};
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
function manifestPath() {
|
|
5
|
+
return join(dirname(fileURLToPath(import.meta.url)), "..", "..", "package.json");
|
|
6
|
+
}
|
|
7
|
+
function stringField(value) {
|
|
8
|
+
return typeof value === "string" && value !== "" ? value : void 0;
|
|
9
|
+
}
|
|
10
|
+
function parseRepository(repository) {
|
|
11
|
+
const raw = typeof repository === "string" ? repository : typeof repository === "object" && repository !== null ? stringField(repository.url) : void 0;
|
|
12
|
+
if (raw === void 0) return void 0;
|
|
13
|
+
const fromHost = raw.match(/github\.com[/:]([^/]+)\/([^/]+?)(?:\.git)?(?:$|[/#?])/i);
|
|
14
|
+
if (fromHost !== null) return { owner: fromHost[1], repo: fromHost[2] };
|
|
15
|
+
const shorthand = raw.match(/^(?:github:)?([\w.-]+)\/([\w.-]+?)(?:\.git)?$/i);
|
|
16
|
+
if (shorthand !== null) return { owner: shorthand[1], repo: shorthand[2] };
|
|
17
|
+
return void 0;
|
|
18
|
+
}
|
|
19
|
+
function compareVersions(a, b) {
|
|
20
|
+
const split = (value) => {
|
|
21
|
+
const trimmed = value.trim().replace(/^v/i, "");
|
|
22
|
+
const [core, ...rest] = trimmed.split("-");
|
|
23
|
+
const preRaw = rest.join("-");
|
|
24
|
+
return {
|
|
25
|
+
core: core.split(".").map((part) => Number.parseInt(part, 10)).filter((part) => Number.isFinite(part)),
|
|
26
|
+
pre: preRaw === "" ? [] : preRaw.split(".")
|
|
27
|
+
};
|
|
28
|
+
};
|
|
29
|
+
const left = split(a);
|
|
30
|
+
const right = split(b);
|
|
31
|
+
const length = Math.max(left.core.length, right.core.length);
|
|
32
|
+
for (let index = 0; index < length; index += 1) {
|
|
33
|
+
const l = left.core[index] ?? 0;
|
|
34
|
+
const r = right.core[index] ?? 0;
|
|
35
|
+
if (l > r) return 1;
|
|
36
|
+
if (l < r) return -1;
|
|
37
|
+
}
|
|
38
|
+
if (left.pre.length === 0 && right.pre.length === 0) return 0;
|
|
39
|
+
if (left.pre.length === 0) return 1;
|
|
40
|
+
if (right.pre.length === 0) return -1;
|
|
41
|
+
const preLen = Math.max(left.pre.length, right.pre.length);
|
|
42
|
+
for (let i = 0; i < preLen; i += 1) {
|
|
43
|
+
const lp = left.pre[i];
|
|
44
|
+
const rp = right.pre[i];
|
|
45
|
+
if (lp === void 0) return -1;
|
|
46
|
+
if (rp === void 0) return 1;
|
|
47
|
+
const ln = Number.parseInt(lp, 10);
|
|
48
|
+
const rn = Number.parseInt(rp, 10);
|
|
49
|
+
const lNum = Number.isFinite(ln) && String(ln) === lp;
|
|
50
|
+
const rNum = Number.isFinite(rn) && String(rn) === rp;
|
|
51
|
+
if (lNum && rNum) {
|
|
52
|
+
if (ln !== rn) return ln > rn ? 1 : -1;
|
|
53
|
+
} else if (lp !== rp) return lp > rp ? 1 : -1;
|
|
54
|
+
}
|
|
55
|
+
return 0;
|
|
56
|
+
}
|
|
57
|
+
let manifestCache;
|
|
58
|
+
async function readManifest(path) {
|
|
59
|
+
const raw = await readFile(path, "utf8");
|
|
60
|
+
const parsed = JSON.parse(raw);
|
|
61
|
+
if (typeof parsed !== "object" || parsed === null) throw new Error("package.json is not an object");
|
|
62
|
+
return parsed;
|
|
63
|
+
}
|
|
64
|
+
async function loadManifest(path) {
|
|
65
|
+
if (path !== void 0) return readManifest(path);
|
|
66
|
+
if (manifestCache === void 0) manifestCache = await readManifest(manifestPath());
|
|
67
|
+
return manifestCache;
|
|
68
|
+
}
|
|
69
|
+
async function readVersionInfo(options = {}) {
|
|
70
|
+
const manifest = await loadManifest(options.manifestPath);
|
|
71
|
+
const current = stringField(manifest.version) ?? "0.0.0";
|
|
72
|
+
const repo = parseRepository(manifest.repository);
|
|
73
|
+
const repositoryUrl = repo === void 0 ? void 0 : `https://github.com/${repo.owner}/${repo.repo}`;
|
|
74
|
+
return {
|
|
75
|
+
current,
|
|
76
|
+
...repositoryUrl ? { repositoryUrl } : {},
|
|
77
|
+
updateAvailable: false,
|
|
78
|
+
checkedRemote: false
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
function safeReleaseUrl(url) {
|
|
82
|
+
if (url === void 0) return void 0;
|
|
83
|
+
return /^https:\/\/github\.com\//i.test(url) ? url : void 0;
|
|
84
|
+
}
|
|
85
|
+
const REMOTE_CACHE_MS = 10 * 60 * 1e3;
|
|
86
|
+
let remoteCache;
|
|
87
|
+
async function checkLatestVersion(options = {}) {
|
|
88
|
+
if (options.manifestPath === void 0 && options.fetchFn === void 0 && remoteCache !== void 0 && Date.now() - remoteCache.at < REMOTE_CACHE_MS) {
|
|
89
|
+
return remoteCache.info;
|
|
90
|
+
}
|
|
91
|
+
const base = await readVersionInfo({ manifestPath: options.manifestPath });
|
|
92
|
+
const repo = parseRepository((await loadManifest(options.manifestPath)).repository);
|
|
93
|
+
if (repo === void 0) {
|
|
94
|
+
return { ...base, checkedRemote: false, error: "repository is not configured" };
|
|
95
|
+
}
|
|
96
|
+
const fetchFn = options.fetchFn ?? fetch;
|
|
97
|
+
try {
|
|
98
|
+
const response = await fetchFn(`https://api.github.com/repos/${repo.owner}/${repo.repo}/releases/latest`, {
|
|
99
|
+
headers: { accept: "application/vnd.github+json", "user-agent": "dsh-git-panel" },
|
|
100
|
+
signal: AbortSignal.timeout(5e3)
|
|
101
|
+
});
|
|
102
|
+
if (!response.ok) {
|
|
103
|
+
return { ...base, checkedRemote: true, error: `GitHub responded with ${response.status}` };
|
|
104
|
+
}
|
|
105
|
+
const body = await response.json();
|
|
106
|
+
const tag = typeof body === "object" && body !== null ? stringField(body.tag_name) : void 0;
|
|
107
|
+
const htmlUrl = typeof body === "object" && body !== null ? stringField(body.html_url) : void 0;
|
|
108
|
+
if (tag === void 0) {
|
|
109
|
+
return { ...base, checkedRemote: true, error: "GitHub response did not include a release tag" };
|
|
110
|
+
}
|
|
111
|
+
const latest = tag.replace(/^v/i, "");
|
|
112
|
+
const info = {
|
|
113
|
+
...base,
|
|
114
|
+
checkedRemote: true,
|
|
115
|
+
latest,
|
|
116
|
+
updateAvailable: compareVersions(latest, base.current) > 0,
|
|
117
|
+
releaseUrl: safeReleaseUrl(htmlUrl) ?? (base.repositoryUrl !== void 0 ? `${base.repositoryUrl}/releases/latest` : void 0)
|
|
118
|
+
};
|
|
119
|
+
if (options.manifestPath === void 0 && options.fetchFn === void 0) remoteCache = { at: Date.now(), info };
|
|
120
|
+
return info;
|
|
121
|
+
} catch (error) {
|
|
122
|
+
return { ...base, checkedRemote: true, error: error instanceof Error ? error.message : "Unable to reach GitHub" };
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
export {
|
|
126
|
+
checkLatestVersion,
|
|
127
|
+
compareVersions,
|
|
128
|
+
parseRepository,
|
|
129
|
+
readVersionInfo
|
|
130
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xbzbing/dsh-git-panel",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.6.0",
|
|
4
4
|
"description": "DeepSeek Harness (dsh) plugin: an IDE-style Git panel in the Web GUI — branch/history overview, uncommitted changes with commit & amend, diff stats, and a zsh-style branch marker in the input bar.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -25,7 +25,8 @@
|
|
|
25
25
|
"./client": {
|
|
26
26
|
"default": "./lib/client.js"
|
|
27
27
|
},
|
|
28
|
-
"./locale
|
|
28
|
+
"./locale/zh.json": "./locale/zh.json",
|
|
29
|
+
"./locale/en.json": "./locale/en.json",
|
|
29
30
|
"./package.json": "./package.json"
|
|
30
31
|
},
|
|
31
32
|
"files": [
|
|
@@ -35,14 +36,14 @@
|
|
|
35
36
|
"icon.svg",
|
|
36
37
|
"cordis.patch.yml",
|
|
37
38
|
"README.md",
|
|
38
|
-
"
|
|
39
|
+
"README.en.md",
|
|
39
40
|
"LICENSE"
|
|
40
41
|
],
|
|
41
42
|
"scripts": {
|
|
42
43
|
"build": "node build.mjs",
|
|
43
44
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
44
45
|
"test:unit": "node build.mjs && node --test --test-force-exit test/unit/*.test.mjs",
|
|
45
|
-
"test:e2e": "node build.mjs && node test/e2e/setup.mjs && node test/e2e/run.mjs && node test/e2e/run2.mjs && node test/e2e/run3.mjs",
|
|
46
|
+
"test:e2e": "node build.mjs && node test/e2e/setup.mjs && node test/e2e/run.mjs && node test/e2e/run2.mjs && node test/e2e/run3.mjs && node test/e2e/run4.mjs && node test/e2e/run5.mjs",
|
|
46
47
|
"test": "npm run test:unit && npm run test:e2e",
|
|
47
48
|
"check": "npm run typecheck && npm run test && git diff --exit-code -- lib/"
|
|
48
49
|
},
|
|
@@ -58,6 +59,12 @@
|
|
|
58
59
|
"@deepseek-ai/dsh-client-ui-conversation",
|
|
59
60
|
"@deepseek-ai/dsh-client-ui-primitives"
|
|
60
61
|
]
|
|
62
|
+
},
|
|
63
|
+
"compatibility": {
|
|
64
|
+
"dsh": ">=0.1.7-rc.2",
|
|
65
|
+
"dshReleases": {
|
|
66
|
+
"0.2.1-alpha.2": "compatible"
|
|
67
|
+
}
|
|
61
68
|
}
|
|
62
69
|
},
|
|
63
70
|
"keywords": [
|