@xbzbing/dsh-git-panel 1.6.2 → 1.8.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 +31 -22
- package/README.md +31 -22
- package/lib/client.js +219 -69
- package/lib/client.mermaid.js +4338 -0
- package/lib/host/actions.js +49 -5
- package/lib/host/core.d.ts +13 -5
- package/lib/host/core.js +33 -8
- package/lib/host/fs-deps.d.ts +2 -0
- package/lib/host/fs-deps.js +25 -0
- package/lib/host/history-topology.d.ts +25 -0
- package/lib/host/history-topology.js +55 -0
- package/lib/host/index.d.ts +8 -2
- package/lib/host/index.js +32 -20
- package/lib/host/llm-face.d.ts +21 -0
- package/lib/host/queries.js +88 -23
- package/lib/host/snapshot-fingerprint.d.ts +3 -0
- package/lib/host/snapshot-fingerprint.js +12 -0
- package/lib/host/suggest.d.ts +13 -0
- package/lib/host/suggest.js +49 -9
- package/lib/host/types.d.ts +85 -4
- package/lib/host/types.js +13 -2
- package/package.json +20 -6
package/lib/host/queries.js
CHANGED
|
@@ -1,15 +1,21 @@
|
|
|
1
1
|
import { join, sep } from "node:path";
|
|
2
2
|
import { mapWorkspaceFailure, resolveBrowseRoot, resolveWorkspace, runCommand, snapshotForSession } from "./core.js";
|
|
3
3
|
import { isSafePath, isSafeRev } from "./validate.js";
|
|
4
|
+
import { walkHistoryTopology } from "./history-topology.js";
|
|
4
5
|
import { commitFromFields, parseBranches, parseGraphLog, parseNameStatus, parseStashList, parseTags, sumNumstat } from "./parser.js";
|
|
5
|
-
import { imageMimeFor, DIFF_CONTEXT_DEFAULT, DIFF_CONTEXT_MAX, DIFF_COUNT_READ_MAX } from "./types.js";
|
|
6
|
+
import { imageMimeFor, isHistoryHashSearch, isHistoryFiltered, DIFF_CONTEXT_DEFAULT, DIFF_CONTEXT_MAX, DIFF_COUNT_READ_MAX, FILE_COPY_MAX_BYTES } from "./types.js";
|
|
6
7
|
const GRAPH_FORMAT = "--format=%H%x1f%h%x1f%P%x1f%an%x1f%aI%x1f%D%x1f%s%x1e";
|
|
8
|
+
const FIRST_PARENT_DIFF = "--diff-merges=first-parent";
|
|
7
9
|
const VISIBLE_REFS = ["--branches", "--remotes", "--tags", "HEAD"];
|
|
8
|
-
function isHexLike(text) {
|
|
9
|
-
return /^[0-9a-fA-F]{7,40}$/.test(text.trim());
|
|
10
|
-
}
|
|
11
10
|
async function runQuery(deps, config, request) {
|
|
12
11
|
const q = request.query;
|
|
12
|
+
if (q.kind === "model-catalog") {
|
|
13
|
+
try {
|
|
14
|
+
return await queryModelCatalog(deps);
|
|
15
|
+
} catch (error) {
|
|
16
|
+
return { ok: false, error: { code: "git-error", message: error instanceof Error ? error.message : String(error) } };
|
|
17
|
+
}
|
|
18
|
+
}
|
|
13
19
|
if (q.kind === "dir-list" || q.kind === "file-content") {
|
|
14
20
|
const browse = await resolveBrowseRoot(deps, request.sessionId);
|
|
15
21
|
if (!browse.ok) return { ok: false, error: browse.error };
|
|
@@ -61,13 +67,19 @@ async function queryHistory(deps, root, q) {
|
|
|
61
67
|
const skip = Number.isFinite(q.skip) ? Math.max(0, Math.trunc(q.skip)) : 0;
|
|
62
68
|
const args = ["git", "log", "--topo-order", "--decorate=full", GRAPH_FORMAT, `--max-count=${limit}`, `--skip=${skip}`];
|
|
63
69
|
const search = q.search?.trim() ?? "";
|
|
64
|
-
const hexJump =
|
|
70
|
+
const hexJump = isHistoryHashSearch(search);
|
|
71
|
+
const filtered = isHistoryFiltered(q);
|
|
72
|
+
const author = q.author ?? "";
|
|
73
|
+
const since = q.since ?? "";
|
|
74
|
+
const searchFilter = search !== "";
|
|
75
|
+
const authorFilter = author !== "";
|
|
76
|
+
const sinceFilter = since !== "";
|
|
65
77
|
const countArgs = ["git", "rev-list", "--count"];
|
|
66
78
|
if (!hexJump) {
|
|
67
79
|
const filters = [];
|
|
68
|
-
if (
|
|
69
|
-
if (
|
|
70
|
-
if (
|
|
80
|
+
if (searchFilter) filters.push("-i", "-E", `--grep=${search}`);
|
|
81
|
+
if (authorFilter) filters.push(`--author=${author}`);
|
|
82
|
+
if (sinceFilter) filters.push(`--since=${since}`);
|
|
71
83
|
if (q.ref !== void 0 && q.ref !== "") {
|
|
72
84
|
if (!isSafeRev(q.ref)) return { ok: false, error: { code: "invalid-name", message: `unsafe ref: ${q.ref}` } };
|
|
73
85
|
filters.push("--end-of-options", q.ref);
|
|
@@ -89,7 +101,7 @@ async function queryHistory(deps, root, q) {
|
|
|
89
101
|
if (res.run.exitCode !== 0) {
|
|
90
102
|
const stderr = res.run.stderr.trim();
|
|
91
103
|
if (hexJump || /unknown revision|bad revision|does not have any commits|ambiguous argument/i.test(stderr)) {
|
|
92
|
-
return { ok: true, value: { kind: "history", commits: [], total: 0 } };
|
|
104
|
+
return { ok: true, value: { kind: "history", commits: [], total: 0, ...filtered ? { topology: [] } : {} } };
|
|
93
105
|
}
|
|
94
106
|
return { ok: false, error: { code: "git-error", message: stderr || `git exited ${res.run.exitCode}` } };
|
|
95
107
|
}
|
|
@@ -99,7 +111,8 @@ async function queryHistory(deps, root, q) {
|
|
|
99
111
|
const n = Number(countRes.run.stdout.trim());
|
|
100
112
|
if (Number.isFinite(n)) total = n;
|
|
101
113
|
}
|
|
102
|
-
|
|
114
|
+
const topology = filtered ? commits.length === 0 ? [] : await walkHistoryTopology(deps.run, root, commits.map((c) => c.hash), deps.signal) : void 0;
|
|
115
|
+
return { ok: true, value: { kind: "history", commits, total, ...topology !== void 0 ? { topology } : {} } };
|
|
103
116
|
}
|
|
104
117
|
async function queryDiff(deps, root, q) {
|
|
105
118
|
if (!isSafePath(q.path)) return { ok: false, error: { code: "invalid-path", message: q.path } };
|
|
@@ -110,7 +123,7 @@ async function queryDiff(deps, root, q) {
|
|
|
110
123
|
args = ["git", "diff", unified, "--cached", "--", q.path];
|
|
111
124
|
} else if (q.base === "commit") {
|
|
112
125
|
if (!isSafeRev(q.commit)) return { ok: false, error: { code: "invalid-name", message: `unsafe commit: ${q.commit}` } };
|
|
113
|
-
args = ["git", "show", unified, "--end-of-options", q.commit, "--", q.path];
|
|
126
|
+
args = ["git", "show", unified, FIRST_PARENT_DIFF, "--end-of-options", q.commit, "--", q.path];
|
|
114
127
|
} else {
|
|
115
128
|
args = ["git", "diff", unified, "--", q.path];
|
|
116
129
|
}
|
|
@@ -301,7 +314,8 @@ async function queryDirList(deps, root, q, isGitRepo) {
|
|
|
301
314
|
if (rel !== "" && !isSafePath(rel) || hasGitSegment(rel)) return { ok: false, error: { code: "invalid-path", message: rel } };
|
|
302
315
|
const dir = rel === "" ? root : join(root, rel);
|
|
303
316
|
const inside = await isInsideRoot(deps, root, dir, true);
|
|
304
|
-
if (
|
|
317
|
+
if (inside === "missing") return { ok: false, error: { code: "file-not-found", message: rel } };
|
|
318
|
+
if (inside === "outside") return { ok: false, error: { code: "invalid-path", message: rel } };
|
|
305
319
|
let raw;
|
|
306
320
|
try {
|
|
307
321
|
raw = await deps.fs.readdir(dir);
|
|
@@ -352,15 +366,16 @@ async function queryFileContent(deps, config, root, q) {
|
|
|
352
366
|
if (!isSafePath(q.path) || hasGitSegment(q.path)) return { ok: false, error: { code: "invalid-path", message: q.path } };
|
|
353
367
|
const file = join(root, q.path);
|
|
354
368
|
const inside = await isInsideRoot(deps, root, file, true);
|
|
355
|
-
if (
|
|
356
|
-
|
|
369
|
+
if (inside === "missing") return { ok: false, error: { code: "file-not-found", message: q.path } };
|
|
370
|
+
if (inside === "outside") return { ok: false, error: { code: "invalid-path", message: q.path } };
|
|
357
371
|
let info;
|
|
358
372
|
try {
|
|
359
373
|
info = await deps.fs.stat(file);
|
|
360
374
|
} catch (error) {
|
|
361
|
-
return { ok: false, error: { code: "git-error", message: error instanceof Error ? error.message : "stat failed" } };
|
|
375
|
+
return { ok: false, error: { code: isMissingPath(error) ? "file-not-found" : "git-error", message: error instanceof Error ? error.message : "stat failed" } };
|
|
362
376
|
}
|
|
363
377
|
const mime = imageMimeFor(q.path);
|
|
378
|
+
const cap = mime !== null ? config.maxBytes : Math.max(config.maxBytes, FILE_COPY_MAX_BYTES);
|
|
364
379
|
const statFields = { size: info.size, mtimeMs: Math.round(info.mtimeMs) };
|
|
365
380
|
const tooLarge = { ok: true, value: { kind: "file-content", path: q.path, variant: mime !== null ? "image" : "text", tooLarge: true, ...statFields } };
|
|
366
381
|
if (info.size > cap) return tooLarge;
|
|
@@ -368,7 +383,7 @@ async function queryFileContent(deps, config, root, q) {
|
|
|
368
383
|
try {
|
|
369
384
|
buf = await deps.fs.readFile(file);
|
|
370
385
|
} catch (error) {
|
|
371
|
-
return { ok: false, error: { code: "git-error", message: error instanceof Error ? error.message : "read failed" } };
|
|
386
|
+
return { ok: false, error: { code: isMissingPath(error) ? "file-not-found" : "git-error", message: error instanceof Error ? error.message : "read failed" } };
|
|
372
387
|
}
|
|
373
388
|
if (buf.length > cap) return tooLarge;
|
|
374
389
|
if (mime !== null) {
|
|
@@ -377,19 +392,22 @@ async function queryFileContent(deps, config, root, q) {
|
|
|
377
392
|
if (isBinaryBuffer(buf)) return { ok: true, value: { kind: "file-content", path: q.path, variant: "binary", ...statFields } };
|
|
378
393
|
const content = buf.toString("utf8");
|
|
379
394
|
const lines = content === "" ? 0 : content.split("\n").length - (content.endsWith("\n") ? 1 : 0);
|
|
380
|
-
return { ok: true, value: { kind: "file-content", path: q.path, variant: "text", content, lines, ...statFields } };
|
|
395
|
+
return { ok: true, value: { kind: "file-content", path: q.path, variant: "text", content, lines, absolutePath: file, ...statFields } };
|
|
381
396
|
}
|
|
382
397
|
function hasGitSegment(path) {
|
|
383
398
|
return path.split(/[\\/]/).includes(".git");
|
|
384
399
|
}
|
|
400
|
+
function isMissingPath(error) {
|
|
401
|
+
return error?.code === "ENOENT";
|
|
402
|
+
}
|
|
385
403
|
async function isInsideRoot(deps, root, path, hideGit = false) {
|
|
386
404
|
try {
|
|
387
405
|
const real = await deps.fs.realpath(path);
|
|
388
406
|
const rootReal = await deps.fs.realpath(root);
|
|
389
|
-
if (real !== rootReal && !real.startsWith(rootReal + sep)) return
|
|
390
|
-
return !hideGit || !hasGitSegment(real.slice(rootReal.length + 1));
|
|
391
|
-
} catch {
|
|
392
|
-
return
|
|
407
|
+
if (real !== rootReal && !real.startsWith(rootReal + sep)) return "outside";
|
|
408
|
+
return !hideGit || !hasGitSegment(real.slice(rootReal.length + 1)) ? "inside" : "outside";
|
|
409
|
+
} catch (error) {
|
|
410
|
+
return isMissingPath(error) ? "missing" : "outside";
|
|
393
411
|
}
|
|
394
412
|
}
|
|
395
413
|
function isBinaryBuffer(buf) {
|
|
@@ -402,8 +420,8 @@ async function queryShow(deps, root, ref) {
|
|
|
402
420
|
const metaFormat = "--format=%H%x1f%h%x1f%s%x1f%an%x1f%aI%x1f%b";
|
|
403
421
|
const [metaRes, statRes, numstatRes] = await Promise.all([
|
|
404
422
|
runCommand(deps.run, ["git", "show", "-s", metaFormat, "--end-of-options", ref], root, "show-meta", deps.signal),
|
|
405
|
-
runCommand(deps.run, ["git", "show", "--name-status", "-z", "--format=", "--end-of-options", ref], root, "show-stat", deps.signal),
|
|
406
|
-
runCommand(deps.run, ["git", "show", "--numstat", "--format=", "--end-of-options", ref], root, "show-numstat", deps.signal)
|
|
423
|
+
runCommand(deps.run, ["git", "show", FIRST_PARENT_DIFF, "--name-status", "-z", "--format=", "--end-of-options", ref], root, "show-stat", deps.signal),
|
|
424
|
+
runCommand(deps.run, ["git", "show", FIRST_PARENT_DIFF, "--numstat", "--format=", "--end-of-options", ref], root, "show-numstat", deps.signal)
|
|
407
425
|
]);
|
|
408
426
|
if (!("run" in metaRes)) return { ok: false, error: { code: "git-unavailable" } };
|
|
409
427
|
if (metaRes.run.cancelled) return { ok: false, error: { code: "cancelled" } };
|
|
@@ -495,6 +513,53 @@ async function queryWorktreeStats(deps, config, sessionId) {
|
|
|
495
513
|
if (!snapshot.ok) return { ok: false, error: { code: "git-error", message: "snapshot failed" } };
|
|
496
514
|
return { ok: true, value: { kind: "worktree-stats", stats: snapshot.value.stats } };
|
|
497
515
|
}
|
|
516
|
+
async function queryModelCatalog(deps) {
|
|
517
|
+
const llm = deps.getLlm?.();
|
|
518
|
+
const empty = { kind: "model-catalog", groups: [], failures: [] };
|
|
519
|
+
if (llm === void 0 || typeof llm.listModels !== "function") {
|
|
520
|
+
return { ok: true, value: empty };
|
|
521
|
+
}
|
|
522
|
+
const listModels = llm.listModels.bind(llm);
|
|
523
|
+
const resolveModelInfo = typeof llm.resolveModelInfo === "function" ? llm.resolveModelInfo.bind(llm) : void 0;
|
|
524
|
+
const providers = llm.listProviders?.() ?? [];
|
|
525
|
+
const results = await Promise.all(providers.map(async (provider) => {
|
|
526
|
+
const name = provider.name ?? provider.id;
|
|
527
|
+
let listed;
|
|
528
|
+
try {
|
|
529
|
+
listed = await listModels(provider.id);
|
|
530
|
+
} catch (error) {
|
|
531
|
+
return { id: provider.id, name, message: error instanceof Error ? error.message : String(error) };
|
|
532
|
+
}
|
|
533
|
+
const models = await Promise.all(listed.map(async (model) => {
|
|
534
|
+
const entry = { id: model.id, name: model.name ?? model.id };
|
|
535
|
+
if (resolveModelInfo === void 0) return entry;
|
|
536
|
+
try {
|
|
537
|
+
const info = await resolveModelInfo(provider.id, model.id, deps.signal);
|
|
538
|
+
const reasoning = info.reasoning;
|
|
539
|
+
if (reasoning !== void 0 && reasoning.efforts.length > 0) {
|
|
540
|
+
return {
|
|
541
|
+
...entry,
|
|
542
|
+
reasoning: {
|
|
543
|
+
efforts: reasoning.efforts.map((effort) => ({ id: effort.id, name: effort.name }))
|
|
544
|
+
}
|
|
545
|
+
};
|
|
546
|
+
}
|
|
547
|
+
} catch {
|
|
548
|
+
}
|
|
549
|
+
return entry;
|
|
550
|
+
}));
|
|
551
|
+
if (models.length === 0) return null;
|
|
552
|
+
return { id: provider.id, name, models };
|
|
553
|
+
}));
|
|
554
|
+
const groups = [];
|
|
555
|
+
const failures = [];
|
|
556
|
+
for (const one of results) {
|
|
557
|
+
if (one === null) continue;
|
|
558
|
+
if ("models" in one) groups.push(one);
|
|
559
|
+
else failures.push(one);
|
|
560
|
+
}
|
|
561
|
+
return { ok: true, value: { kind: "model-catalog", groups, failures } };
|
|
562
|
+
}
|
|
498
563
|
export {
|
|
499
564
|
runQuery
|
|
500
565
|
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
function snapshotFingerprint(snapshot) {
|
|
3
|
+
const { checkedAt: _checkedAt, stateFingerprint: _stateFingerprint, ...state } = snapshot;
|
|
4
|
+
const encoded = JSON.stringify(state, (_key, value) => {
|
|
5
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) return value;
|
|
6
|
+
return Object.fromEntries(Object.entries(value).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0));
|
|
7
|
+
});
|
|
8
|
+
return createHash("sha256").update(encoded).digest("hex");
|
|
9
|
+
}
|
|
10
|
+
export {
|
|
11
|
+
snapshotFingerprint
|
|
12
|
+
};
|
package/lib/host/suggest.d.ts
CHANGED
|
@@ -19,5 +19,18 @@ export declare function parseStyleHistory(stdout: string, ownEmail: string | und
|
|
|
19
19
|
* neutral fallback.
|
|
20
20
|
*/
|
|
21
21
|
export declare function resolveSuggestLanguage(setting: SuggestLanguage, samples: readonly string[]): ResolvedLanguage;
|
|
22
|
+
/** A diff line longer than this is replaced by a one-line marker before the
|
|
23
|
+
* model-input budget is applied. Build output is minified into single lines (a
|
|
24
|
+
* bundled `lib/client.js` line can be hundreds of KB), so shipping it verbatim
|
|
25
|
+
* spends the whole `suggestMaxBytes` budget on one line and cuts off the hunks
|
|
26
|
+
* that actually describe the change. Line count and the diff prefix are kept,
|
|
27
|
+
* so `@@` headers still describe the same regions. */
|
|
28
|
+
export declare const LONG_LINE_MAX_CHARS = 400;
|
|
29
|
+
/** Fold over-long lines into `<prefix>… [long line: N chars omitted]`.
|
|
30
|
+
* `N` counts UTF-16 code units (what `String.length` reports), not bytes. */
|
|
31
|
+
export declare function compactLongLines(text: string, maxChars?: number): {
|
|
32
|
+
text: string;
|
|
33
|
+
folded: number;
|
|
34
|
+
};
|
|
22
35
|
/** Generate a commit message for the session's uncommitted changes. */
|
|
23
36
|
export declare function runSuggest(deps: SnapshotDeps, config: GitPanelConfig, request: GitSuggestRequest): Promise<GitSuggestResult>;
|
package/lib/host/suggest.js
CHANGED
|
@@ -59,9 +59,21 @@ function systemPrompt(language) {
|
|
|
59
59
|
].join("\n");
|
|
60
60
|
}
|
|
61
61
|
const UNTRACKED_FILE_CAP = 16;
|
|
62
|
-
|
|
62
|
+
const PROMPT_FILE_CAP = 200;
|
|
63
|
+
function resolveRoute(config, sessionModel, agentDefaultModel) {
|
|
63
64
|
if (config.suggestProvider !== void 0 && config.suggestModel !== void 0) {
|
|
64
|
-
return {
|
|
65
|
+
return {
|
|
66
|
+
provider: config.suggestProvider,
|
|
67
|
+
model: config.suggestModel,
|
|
68
|
+
...config.suggestReasoningEffort !== void 0 ? { reasoningEffort: config.suggestReasoningEffort } : {}
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
if (sessionModel !== void 0) {
|
|
72
|
+
return {
|
|
73
|
+
provider: sessionModel.provider,
|
|
74
|
+
model: sessionModel.model,
|
|
75
|
+
...sessionModel.reasoningEffort !== void 0 ? { reasoningEffort: sessionModel.reasoningEffort } : {}
|
|
76
|
+
};
|
|
65
77
|
}
|
|
66
78
|
return agentDefaultModel?.currentSelection();
|
|
67
79
|
}
|
|
@@ -100,9 +112,23 @@ function truncateUtf8(text, maxBytes) {
|
|
|
100
112
|
}
|
|
101
113
|
return text.slice(0, lo);
|
|
102
114
|
}
|
|
103
|
-
|
|
115
|
+
const LONG_LINE_MAX_CHARS = 400;
|
|
116
|
+
const STRUCTURAL_LINE = /^(?:@@|diff --git |index |--- (?:a\/|\/dev\/null)|\+\+\+ (?:b\/|\/dev\/null)|new file mode |deleted file mode |old mode |new mode |similarity index |dissimilarity index |rename from |rename to |copy from |copy to |Binary files |GIT binary patch|literal |delta |\\ )/;
|
|
117
|
+
function compactLongLines(text, maxChars = LONG_LINE_MAX_CHARS) {
|
|
118
|
+
if (maxChars <= 0) return { text, folded: 0 };
|
|
119
|
+
let folded = 0;
|
|
120
|
+
const lines = text.split("\n").map((line) => {
|
|
121
|
+
if (line.length <= maxChars || STRUCTURAL_LINE.test(line)) return line;
|
|
122
|
+
folded += 1;
|
|
123
|
+
const prefix = line[0] === "+" || line[0] === "-" || line[0] === " " || line[0] === "\\" ? line[0] : "";
|
|
124
|
+
return `${prefix}\u2026 [long line: ${line.length} chars omitted]`;
|
|
125
|
+
});
|
|
126
|
+
return { text: lines.join("\n"), folded };
|
|
127
|
+
}
|
|
128
|
+
function frameChanges(files, filesOmitted, diff, styleSamples) {
|
|
104
129
|
return JSON.stringify({
|
|
105
130
|
files,
|
|
131
|
+
...filesOmitted > 0 ? { filesOmitted } : {},
|
|
106
132
|
diff,
|
|
107
133
|
commitStyleSamples: styleSamples.length === 0 ? null : styleSamples
|
|
108
134
|
});
|
|
@@ -160,7 +186,7 @@ async function appendUntracked(deps, root, files, diff, budget) {
|
|
|
160
186
|
}
|
|
161
187
|
const res = await runCommand(deps.run, ["git", "diff", "--no-index", "--", "/dev/null", c.path], root, "suggest-untracked", deps.signal);
|
|
162
188
|
if ("failure" in res || res.run.cancelled || res.run.timedOut) continue;
|
|
163
|
-
const chunk = res.run.stdout;
|
|
189
|
+
const chunk = compactLongLines(res.run.stdout).text;
|
|
164
190
|
if (chunk === "") continue;
|
|
165
191
|
const lossy = res.run.stdoutLossy;
|
|
166
192
|
if (lossy || Buffer.byteLength(chunk, "utf8") > remaining) {
|
|
@@ -186,7 +212,7 @@ async function runSuggest(deps, config, request) {
|
|
|
186
212
|
const pathArgs = paths.length > 0 ? ["--", ...paths] : [];
|
|
187
213
|
const pathSet = new Set(paths);
|
|
188
214
|
const [statusRes, headRes, logRes, emailRes] = await Promise.all([
|
|
189
|
-
runCommand(deps.run, ["git", "status", "--porcelain=v1", "-z"], root, "suggest-status", deps.signal),
|
|
215
|
+
runCommand(deps.run, ["git", "status", "--porcelain=v1", "-z", "--untracked-files=all"], root, "suggest-status", deps.signal),
|
|
190
216
|
runCommand(deps.run, ["git", "rev-parse", "--verify", "HEAD"], root, "suggest-head", deps.signal),
|
|
191
217
|
runCommand(deps.run, ["git", "log", `-n${HISTORY_SCAN}`, "-z", "--format=%ae%s"], root, "suggest-log", deps.signal),
|
|
192
218
|
runCommand(deps.run, ["git", "config", "user.email"], root, "suggest-email", deps.signal)
|
|
@@ -201,13 +227,19 @@ async function runSuggest(deps, config, request) {
|
|
|
201
227
|
const styleSamples = "run" in logRes && logRes.run.exitCode === 0 ? parseStyleHistory(logRes.run.stdout, "run" in emailRes && emailRes.run.exitCode === 0 ? emailRes.run.stdout.trim() : void 0) : [];
|
|
202
228
|
const language = resolveSuggestLanguage(config.suggestLanguage, styleSamples);
|
|
203
229
|
const all = statusRes.run.stdoutLossy ? [] : parseStatus(statusRes.run.stdout);
|
|
204
|
-
const files = pathSet.size > 0 ? all.filter((c) =>
|
|
230
|
+
const files = pathSet.size > 0 ? all.filter((c) => {
|
|
231
|
+
if (pathSet.has(c.path)) return true;
|
|
232
|
+
for (let slash = c.path.indexOf("/"); slash !== -1; slash = c.path.indexOf("/", slash + 1)) {
|
|
233
|
+
if (pathSet.has(c.path.slice(0, slash))) return true;
|
|
234
|
+
}
|
|
235
|
+
return false;
|
|
236
|
+
}) : all;
|
|
205
237
|
const collected = await collectDiff(deps, root, hasHead, pathArgs);
|
|
206
238
|
if (!("diff" in collected)) return collected;
|
|
207
239
|
if (files.length === 0 && collected.diff.trim() === "") {
|
|
208
240
|
return { ok: false, error: { code: "empty-diff", message: "no uncommitted changes to summarize" } };
|
|
209
241
|
}
|
|
210
|
-
let diff = collected.diff;
|
|
242
|
+
let diff = compactLongLines(collected.diff).text;
|
|
211
243
|
let truncated = collected.lossy;
|
|
212
244
|
if (Buffer.byteLength(diff, "utf8") > config.suggestMaxBytes) {
|
|
213
245
|
diff = truncateUtf8(diff, config.suggestMaxBytes) + "\n... [diff truncated]";
|
|
@@ -217,12 +249,13 @@ async function runSuggest(deps, config, request) {
|
|
|
217
249
|
diff = untracked.diff;
|
|
218
250
|
truncated = truncated || untracked.truncated;
|
|
219
251
|
const llm = deps.getLlm?.();
|
|
220
|
-
const route = resolveRoute(config, deps.getAgentDefaultModel?.());
|
|
252
|
+
const route = resolveRoute(config, deps.getSessionModel?.(request.sessionId), deps.getAgentDefaultModel?.());
|
|
221
253
|
if (llm === void 0 || route === void 0) {
|
|
222
254
|
return { ok: false, error: { code: "llm-unavailable", message: "no model service or route is configured" } };
|
|
223
255
|
}
|
|
224
256
|
const frame = frameChanges(
|
|
225
|
-
files.map((c) => ({ path: c.path, status: c.status })),
|
|
257
|
+
files.slice(0, PROMPT_FILE_CAP).map((c) => ({ path: c.path, status: c.status })),
|
|
258
|
+
Math.max(0, files.length - PROMPT_FILE_CAP),
|
|
226
259
|
diff,
|
|
227
260
|
styleSamples
|
|
228
261
|
);
|
|
@@ -230,6 +263,10 @@ async function runSuggest(deps, config, request) {
|
|
|
230
263
|
const options = {
|
|
231
264
|
provider: route.provider,
|
|
232
265
|
model: route.model,
|
|
266
|
+
// Catalog effort ids come from the same adapter surface GenerateOptions
|
|
267
|
+
// accepts; the brand is compile-time only (the runtime factory lives in
|
|
268
|
+
// @deepseek-ai/dsh-llm, which is externalized), so a cast is the boundary.
|
|
269
|
+
...route.reasoningEffort !== void 0 ? { reasoningEffort: route.reasoningEffort } : {},
|
|
233
270
|
system: systemPrompt(language),
|
|
234
271
|
messages: [{
|
|
235
272
|
id: "git-panel-suggest",
|
|
@@ -270,11 +307,14 @@ async function runSuggest(deps, config, request) {
|
|
|
270
307
|
message,
|
|
271
308
|
provider: route.provider,
|
|
272
309
|
model: route.model,
|
|
310
|
+
...route.reasoningEffort !== void 0 ? { reasoningEffort: route.reasoningEffort } : {},
|
|
273
311
|
...truncated ? { truncated: true } : {}
|
|
274
312
|
}
|
|
275
313
|
};
|
|
276
314
|
}
|
|
277
315
|
export {
|
|
316
|
+
LONG_LINE_MAX_CHARS,
|
|
317
|
+
compactLongLines,
|
|
278
318
|
isCjkText,
|
|
279
319
|
parseStyleHistory,
|
|
280
320
|
resolveSuggestLanguage,
|
package/lib/host/types.d.ts
CHANGED
|
@@ -72,6 +72,9 @@ export interface GitRemote {
|
|
|
72
72
|
/** Known-host classification, for the status-bar icon. */
|
|
73
73
|
readonly hostKind: RemoteHostKind;
|
|
74
74
|
}
|
|
75
|
+
/** Why the change list is incomplete: capped at `maxChanges`, or the status
|
|
76
|
+
* stream itself was clipped (a lossy read drops the head). */
|
|
77
|
+
export type TruncationReason = 'cap' | 'lossy';
|
|
75
78
|
/** Immutable snapshot of one repository's status at `checkedAt`. */
|
|
76
79
|
export interface GitSnapshot {
|
|
77
80
|
/** Realpath of the repository root (work tree top). */
|
|
@@ -97,8 +100,14 @@ export interface GitSnapshot {
|
|
|
97
100
|
readonly changes: readonly GitChange[];
|
|
98
101
|
/** Working-tree statistics for the changes-page header (single source). */
|
|
99
102
|
readonly stats: WorktreeStats;
|
|
100
|
-
/** True when the change list
|
|
103
|
+
/** True when the change list is incomplete (capped or clipped). */
|
|
101
104
|
readonly truncated: boolean;
|
|
105
|
+
/** Which limit produced `truncated`; absent on older hosts. */
|
|
106
|
+
readonly truncatedReason?: TruncationReason;
|
|
107
|
+
/** True when untracked files fell back to git's collapsed `?? dir/` entries
|
|
108
|
+
* because expanding them would overflow `maxChanges` (or the status stream).
|
|
109
|
+
* Older hosts never set it. */
|
|
110
|
+
readonly untrackedCollapsed?: boolean;
|
|
102
111
|
/** Polling interval the client should use after this snapshot (0 = off). */
|
|
103
112
|
readonly refreshIntervalMs: number;
|
|
104
113
|
/** Whether the input-bar git marker pill should render (user preference). */
|
|
@@ -109,6 +118,9 @@ export interface GitSnapshot {
|
|
|
109
118
|
readonly graphStyle: GraphStyle;
|
|
110
119
|
/** Whether the commit box shows the AI-suggest button (user preference). */
|
|
111
120
|
readonly suggestEnabled: boolean;
|
|
121
|
+
/** SHA-256 of the final business snapshot, excluding checkedAt and itself.
|
|
122
|
+
* Optional for compatibility with hosts that do not provide fingerprints. */
|
|
123
|
+
readonly stateFingerprint?: string;
|
|
112
124
|
/** Epoch millis of the snapshot. */
|
|
113
125
|
readonly checkedAt: number;
|
|
114
126
|
}
|
|
@@ -119,9 +131,15 @@ export interface GitCommit {
|
|
|
119
131
|
readonly author: string;
|
|
120
132
|
readonly dateIso: string;
|
|
121
133
|
}
|
|
122
|
-
/**
|
|
123
|
-
|
|
134
|
+
/** One node of the commit graph's raw ancestry: a full hash and its raw parent
|
|
135
|
+
* hashes. `parents` is empty for a true root — and equally for a shallow
|
|
136
|
+
* boundary, which git cannot tell apart from a root. */
|
|
137
|
+
export interface GitGraphNode {
|
|
138
|
+
readonly hash: string;
|
|
124
139
|
readonly parents: readonly string[];
|
|
140
|
+
}
|
|
141
|
+
/** A commit with parent links and ref decorations (graph rendering). */
|
|
142
|
+
export interface GraphCommit extends GitCommit, GitGraphNode {
|
|
125
143
|
readonly refs: readonly GitRef[];
|
|
126
144
|
}
|
|
127
145
|
export interface GitRef {
|
|
@@ -210,7 +228,7 @@ export type GitAction = {
|
|
|
210
228
|
/** `git reset` modes, from least to most destructive. Single source so the
|
|
211
229
|
* client dialog and the command builder cannot drift. */
|
|
212
230
|
export type ResetMode = 'soft' | 'mixed' | 'hard';
|
|
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';
|
|
231
|
+
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' | 'file-not-found' | 'branch-in-use' | 'stash-switch-failed' | 'index-busy' | 'nested-repo' | 'nested-repo-partial' | 'empty-diff' | 'llm-unavailable' | 'llm-error' | 'llm-output' | 'suggest-disabled';
|
|
214
232
|
export type GitActionResult = {
|
|
215
233
|
readonly ok: true;
|
|
216
234
|
readonly snapshot: GitSnapshot;
|
|
@@ -226,6 +244,16 @@ export interface GitActionRequest {
|
|
|
226
244
|
readonly sessionId: string;
|
|
227
245
|
readonly action: GitAction;
|
|
228
246
|
}
|
|
247
|
+
/**
|
|
248
|
+
* True when a history `search` is a commit-hash prefix jump rather than a
|
|
249
|
+
* message regex: 7–40 hex chars after trimming.
|
|
250
|
+
*/
|
|
251
|
+
export declare function isHistoryHashSearch(search: string): boolean;
|
|
252
|
+
/** Whether Git's effective content filters hide commits; hash jumps ignore them.
|
|
253
|
+
* Shared with clients receiving history from a host that omits topology. */
|
|
254
|
+
export declare function isHistoryFiltered(query: Pick<Extract<GitQuery, {
|
|
255
|
+
kind: 'history';
|
|
256
|
+
}>, 'search' | 'author' | 'since'>): boolean;
|
|
229
257
|
export type GitQuery = {
|
|
230
258
|
readonly kind: 'history';
|
|
231
259
|
readonly limit: number;
|
|
@@ -296,6 +324,8 @@ export type GitQuery = {
|
|
|
296
324
|
readonly kind: 'pull-preview';
|
|
297
325
|
} | {
|
|
298
326
|
readonly kind: 'push-preview';
|
|
327
|
+
} | {
|
|
328
|
+
readonly kind: 'model-catalog';
|
|
299
329
|
};
|
|
300
330
|
/** One entry in a `dir-list` result. */
|
|
301
331
|
export interface DirEntry {
|
|
@@ -330,6 +360,33 @@ export interface StashEntry {
|
|
|
330
360
|
/** Human relative time (e.g. "2 hours ago"). */
|
|
331
361
|
readonly relTime: string;
|
|
332
362
|
}
|
|
363
|
+
/** Selectable reasoning effort for one exact model route (ids go straight
|
|
364
|
+
* into `GenerateOptions.reasoningEffort`). */
|
|
365
|
+
export interface ModelCatalogEffort {
|
|
366
|
+
readonly id: string;
|
|
367
|
+
readonly name: string;
|
|
368
|
+
}
|
|
369
|
+
/** One model inside a provider group of the host-level catalog. */
|
|
370
|
+
export interface ModelCatalogModel {
|
|
371
|
+
readonly id: string;
|
|
372
|
+
readonly name: string;
|
|
373
|
+
/** Adapter-advertised efforts; absent when the model exposes none. */
|
|
374
|
+
readonly reasoning?: {
|
|
375
|
+
readonly efforts: readonly ModelCatalogEffort[];
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
/** One provider and its successfully listed models. */
|
|
379
|
+
export interface ModelCatalogGroup {
|
|
380
|
+
readonly id: string;
|
|
381
|
+
readonly name: string;
|
|
382
|
+
readonly models: readonly ModelCatalogModel[];
|
|
383
|
+
}
|
|
384
|
+
/** One provider / model whose catalog lookup failed in isolation. */
|
|
385
|
+
export interface ModelCatalogFailure {
|
|
386
|
+
readonly id: string;
|
|
387
|
+
readonly name: string;
|
|
388
|
+
readonly message: string;
|
|
389
|
+
}
|
|
333
390
|
/** Working-tree statistics for the changes page header. */
|
|
334
391
|
export interface WorktreeStats {
|
|
335
392
|
/** Distinct changed-file paths (staged ∪ modified ∪ untracked). */
|
|
@@ -357,6 +414,11 @@ export type GitQueryResult = {
|
|
|
357
414
|
readonly kind: 'history';
|
|
358
415
|
readonly commits: readonly GraphCommit[];
|
|
359
416
|
readonly total: number;
|
|
417
|
+
/** Raw ancestry for a filtered page: the page's commits plus the ancestors
|
|
418
|
+
* an effective search/author/since filter hid from the page itself. Absent
|
|
419
|
+
* when no effective filter is active (or on a hash jump); `[]` means the
|
|
420
|
+
* bounded auxiliary walk failed, so the graph is incomplete. */
|
|
421
|
+
readonly topology?: readonly GitGraphNode[];
|
|
360
422
|
}
|
|
361
423
|
/**
|
|
362
424
|
* Unified diff text plus the new-side line count. The count is the fact the
|
|
@@ -459,6 +521,10 @@ export type GitQueryResult = {
|
|
|
459
521
|
readonly files: number;
|
|
460
522
|
readonly insertions: number;
|
|
461
523
|
readonly deletions: number;
|
|
524
|
+
} | {
|
|
525
|
+
readonly kind: 'model-catalog';
|
|
526
|
+
readonly groups: readonly ModelCatalogGroup[];
|
|
527
|
+
readonly failures: readonly ModelCatalogFailure[];
|
|
462
528
|
} | {
|
|
463
529
|
readonly kind: 'dir-list';
|
|
464
530
|
readonly path: string;
|
|
@@ -472,6 +538,11 @@ export type GitQueryResult = {
|
|
|
472
538
|
readonly content?: string;
|
|
473
539
|
/** text variant: total line count. */
|
|
474
540
|
readonly lines?: number;
|
|
541
|
+
/** text variant: absolute path of this file — the rendered-Markdown
|
|
542
|
+
* image resolver needs the document's own location to resolve relative
|
|
543
|
+
* destinations onto the authenticated `api/file` route. Absent on older
|
|
544
|
+
* hosts; the client then keeps the alt-text fallback. */
|
|
545
|
+
readonly absolutePath?: string;
|
|
475
546
|
/** image variant: a `data:<mime>;base64,…` URL. */
|
|
476
547
|
readonly dataUrl?: string;
|
|
477
548
|
/** The file exceeded the byte cap, so no content is returned. */
|
|
@@ -481,6 +552,14 @@ export type GitQueryResult = {
|
|
|
481
552
|
/** Modification time (epoch ms) from the same stat (all variants). */
|
|
482
553
|
readonly mtimeMs?: number;
|
|
483
554
|
};
|
|
555
|
+
/**
|
|
556
|
+
* Byte envelope of the file browser's "copy file content" action: a text
|
|
557
|
+
* document is copyable strictly below this size. Shared by both halves —
|
|
558
|
+
* the host floors its text read here (so every copyable document can
|
|
559
|
+
* actually load) and the client hides the copy button at/above it, keeping
|
|
560
|
+
* one boundary for load and copy.
|
|
561
|
+
*/
|
|
562
|
+
export declare const FILE_COPY_MAX_BYTES: number;
|
|
484
563
|
export type GitQueryResponse = {
|
|
485
564
|
readonly ok: true;
|
|
486
565
|
readonly value: GitQueryResult;
|
|
@@ -550,6 +629,8 @@ export type GitSuggestResult = {
|
|
|
550
629
|
/** Actual provider/model route used (display only). */
|
|
551
630
|
readonly provider?: string;
|
|
552
631
|
readonly model?: string;
|
|
632
|
+
/** Effort actually sent; absent when the request omitted it. */
|
|
633
|
+
readonly reasoningEffort?: string;
|
|
553
634
|
/** The diff reached the model truncated: over the input cap, or cut
|
|
554
635
|
* by the per-command runner stdout cap before it. */
|
|
555
636
|
readonly truncated?: boolean;
|
package/lib/host/types.js
CHANGED
|
@@ -2,8 +2,16 @@ const DIFF_CONTEXT_DEFAULT = 3;
|
|
|
2
2
|
const DIFF_CONTEXT_MAX = 1e5;
|
|
3
3
|
const DIFF_EXPAND_CONTEXT = 2e3;
|
|
4
4
|
const DIFF_COUNT_READ_MAX = 8 * 1024 * 1024;
|
|
5
|
+
function isHistoryHashSearch(search) {
|
|
6
|
+
return /^[0-9a-fA-F]{7,40}$/.test(search.trim());
|
|
7
|
+
}
|
|
8
|
+
function isHistoryFiltered(query) {
|
|
9
|
+
const search = query.search?.trim() ?? "";
|
|
10
|
+
return !isHistoryHashSearch(search) && (search !== "" || (query.author ?? "") !== "" || (query.since ?? "") !== "");
|
|
11
|
+
}
|
|
12
|
+
const FILE_COPY_MAX_BYTES = 10 * 1024 * 1024;
|
|
5
13
|
const SUGGEST_LIMITS = {
|
|
6
|
-
maxBytes: { min: 16 * 1024, max: 512 * 1024, default:
|
|
14
|
+
maxBytes: { min: 16 * 1024, max: 512 * 1024, default: 200 * 1024 },
|
|
7
15
|
maxOutputTokens: { min: 256, max: 16384, default: 4096 },
|
|
8
16
|
timeoutMs: { min: 5e3, max: 12e4, default: 15e3 }
|
|
9
17
|
};
|
|
@@ -32,9 +40,12 @@ export {
|
|
|
32
40
|
DIFF_CONTEXT_MAX,
|
|
33
41
|
DIFF_COUNT_READ_MAX,
|
|
34
42
|
DIFF_EXPAND_CONTEXT,
|
|
43
|
+
FILE_COPY_MAX_BYTES,
|
|
35
44
|
IMAGE_MIME,
|
|
36
45
|
SUGGEST_LANGUAGES,
|
|
37
46
|
SUGGEST_LANGUAGE_DEFAULT,
|
|
38
47
|
SUGGEST_LIMITS,
|
|
39
|
-
imageMimeFor
|
|
48
|
+
imageMimeFor,
|
|
49
|
+
isHistoryFiltered,
|
|
50
|
+
isHistoryHashSearch
|
|
40
51
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xbzbing/dsh-git-panel",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.8.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",
|
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
},
|
|
32
32
|
"files": [
|
|
33
33
|
"lib/client.js",
|
|
34
|
+
"lib/client.mermaid.js",
|
|
34
35
|
"lib/host",
|
|
35
36
|
"locale/*.json",
|
|
36
37
|
"icon.svg",
|
|
@@ -41,11 +42,12 @@
|
|
|
41
42
|
],
|
|
42
43
|
"scripts": {
|
|
43
44
|
"build": "node build.mjs",
|
|
45
|
+
"lint": "eslint .",
|
|
44
46
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
45
|
-
"test:unit": "node build.mjs && node --test
|
|
46
|
-
"test:e2e": "node build.mjs && node test/e2e/setup.mjs && node test/e2e/
|
|
47
|
+
"test:unit": "node build.mjs && node --test test/unit/*.test.mjs",
|
|
48
|
+
"test:e2e": "node build.mjs && node test/e2e/setup.mjs && node test/e2e/all.mjs",
|
|
47
49
|
"test": "npm run test:unit && npm run test:e2e",
|
|
48
|
-
"check": "npm run
|
|
50
|
+
"check": "npm run lint && npm run typecheck && npm run test"
|
|
49
51
|
},
|
|
50
52
|
"dsh": {
|
|
51
53
|
"bundle": {
|
|
@@ -63,7 +65,10 @@
|
|
|
63
65
|
"compatibility": {
|
|
64
66
|
"dsh": ">=0.1.7-rc.2",
|
|
65
67
|
"dshReleases": {
|
|
66
|
-
"0.
|
|
68
|
+
"0.1.7-rc.2": "compatible",
|
|
69
|
+
"0.2.0-rc.2": "compatible",
|
|
70
|
+
"0.2.1-rc.1": "compatible",
|
|
71
|
+
"0.2.1-rc.2": "compatible"
|
|
67
72
|
}
|
|
68
73
|
}
|
|
69
74
|
},
|
|
@@ -84,14 +89,23 @@
|
|
|
84
89
|
"@deepseek-ai/dsh-typert-protocol": ">=0.1.2-rc.1",
|
|
85
90
|
"@deepseek-ai/schemastery": ">=3.18.4"
|
|
86
91
|
},
|
|
92
|
+
"peerDependenciesMeta": {
|
|
93
|
+
"@deepseek-ai/dsh": {
|
|
94
|
+
"optional": true
|
|
95
|
+
}
|
|
96
|
+
},
|
|
87
97
|
"devDependencies": {
|
|
88
98
|
"@types/node": "22.10.5",
|
|
89
99
|
"@types/react": "18.3.18",
|
|
90
100
|
"@types/react-dom": "18.3.5",
|
|
91
101
|
"esbuild": "0.24.2",
|
|
102
|
+
"eslint": "9.39.5",
|
|
103
|
+
"eslint-plugin-react-hooks": "7.1.1",
|
|
104
|
+
"mermaid": "12.2.0",
|
|
92
105
|
"playwright-core": "1.63.0",
|
|
93
106
|
"react": "18.3.1",
|
|
94
107
|
"react-dom": "18.3.1",
|
|
95
|
-
"typescript": "5.7.3"
|
|
108
|
+
"typescript": "5.7.3",
|
|
109
|
+
"typescript-eslint": "8.71.2"
|
|
96
110
|
}
|
|
97
111
|
}
|