@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.
@@ -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
+ };
@@ -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 exceeded the model input cap and was truncated. */
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.5.3",
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/*.json": "./locale/*.json",
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
- "README_EN.md",
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": [