@xbzbing/dsh-git-panel 1.5.4 → 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.
@@ -22,7 +22,9 @@ export declare function scrubCredentials(text: string): string;
22
22
  * rule below (git keeps the stash entry on conflict → recoverable).
23
23
  * - index.lock busy (another process holds the lock).
24
24
  * - nothing-to-commit / no changes added (a real commit no-op).
25
- * - not-found (missing ref / tag / stash).
25
+ * - not-found (missing ref / tag / stash, or a checkout target that resolves
26
+ * to neither a ref nor a path).
27
+ * - branch-in-use (the target branch is checked out in another worktree).
26
28
  * - local-changes-block (a dirty-worktree refusal; neutral wording covers both
27
29
  * `git checkout` and `git stash apply`).
28
30
  * - revert-merge (reverting a merge commit without a mainline parent — git
@@ -34,9 +34,16 @@ function planAction(action, unborn) {
34
34
  const commitCmd = ["git", "commit", ...amendFlag, ...msgArgs, "--", ...action.paths];
35
35
  return { argv: [...staged.argv, commitCmd] };
36
36
  }
37
- case "branch-checkout":
37
+ case "branch-checkout": {
38
38
  if (!isSafeBranchName(action.name)) return { error: "invalid-name", message: `unsafe branch name: ${action.name}` };
39
- return { argv: [["git", "checkout", "--end-of-options", action.name]] };
39
+ let checkout = ["git", "checkout", "--end-of-options", action.name];
40
+ if (action.createFrom !== void 0) {
41
+ if (!isSafeBranchName(action.createFrom)) return { error: "invalid-name", message: `unsafe tracking ref: ${action.createFrom}` };
42
+ checkout = ["git", "checkout", "-b", action.name, "--track", "--end-of-options", action.createFrom];
43
+ }
44
+ const msg = action.stashMessage?.trim() ?? "";
45
+ return { argv: msg === "" ? [checkout] : [["git", "stash", "push", "-u", "-m", msg], checkout] };
46
+ }
40
47
  case "fetch":
41
48
  return { argv: [["git", "fetch", "--all", "--prune"]] };
42
49
  case "pull-ff":
@@ -101,9 +108,12 @@ function classifyActionFailure(stdout, stderr, exitCode) {
101
108
  if (/nothing to commit|no changes added/i.test(combined)) {
102
109
  return { code: "git-error", message: err || "nothing to commit" };
103
110
  }
104
- if (/No such ref|not a valid reference|is not a stash|no tag|tag .* not found|unknown revision|bad revision|Could not parse object|ambiguous argument/i.test(combined)) {
111
+ if (/No such ref|not a valid reference|is not a stash|no tag|tag .* not found|unknown revision|bad revision|Could not parse object|ambiguous argument|did not match any file\(s\) known to git/i.test(combined)) {
105
112
  return { code: "not-found", message: err || "not found" };
106
113
  }
114
+ if (/is already used by worktree|already checked out at/i.test(combined)) {
115
+ return { code: "branch-in-use", message: err || "branch is checked out in another worktree" };
116
+ }
107
117
  if (/Authentication failed|could not read (Username|Password)|terminal prompts disabled|Permission denied \(publickey\)|Could not read from remote repository/i.test(combined)) {
108
118
  return { code: "auth-failed", message: err || "authentication failed" };
109
119
  }
@@ -152,6 +162,7 @@ async function runAction(deps, config, request) {
152
162
  }
153
163
  const plan = planAction(request.action, unborn);
154
164
  if ("error" in plan) return { ok: false, error: { code: plan.error, ...plan.message ? { message: plan.message } : {} } };
165
+ const stashing = request.action.kind === "branch-checkout" && (request.action.stashMessage?.trim() ?? "") !== "";
155
166
  let lastOutput = "";
156
167
  for (let step = 0; step < plan.argv.length; step += 1) {
157
168
  const argv = plan.argv[step];
@@ -161,15 +172,23 @@ async function runAction(deps, config, request) {
161
172
  outcome = await runCommand(deps.run, argv, root, "action-retry", deps.signal);
162
173
  }
163
174
  const where = plan.argv.length > 1 ? ` (step ${step + 1}/${plan.argv.length}: ${argv.join(" ")})` : "";
175
+ const stashChangedWorkTree = stashing && step > 0;
164
176
  if ("failure" in outcome) {
165
177
  const message = outcome.failure instanceof Error ? outcome.failure.message : String(outcome.failure);
166
- return { ok: false, error: { code: "git-unavailable", message: message + where } };
178
+ return { ok: false, error: { code: stashChangedWorkTree ? "stash-switch-failed" : "git-unavailable", message: message + where } };
179
+ }
180
+ if (outcome.run.cancelled) {
181
+ return { ok: false, error: stashChangedWorkTree ? { code: "stash-switch-failed", message: "cancelled" + where } : { code: "cancelled" } };
182
+ }
183
+ if (outcome.run.timedOut) {
184
+ return { ok: false, error: stashChangedWorkTree ? { code: "stash-switch-failed", message: "timed out" + where } : { code: "timeout" } };
167
185
  }
168
- if (outcome.run.cancelled) return { ok: false, error: { code: "cancelled" } };
169
- if (outcome.run.timedOut) return { ok: false, error: { code: "timeout" } };
170
186
  lastOutput = outcome.run.stdout || outcome.run.stderr;
171
187
  if (outcome.run.exitCode !== 0) {
172
188
  const failure = classifyActionFailure(outcome.run.stdout, outcome.run.stderr, outcome.run.exitCode ?? -1);
189
+ if (stashChangedWorkTree) {
190
+ return { ok: false, error: { code: "stash-switch-failed", message: failure.message + where } };
191
+ }
173
192
  if (request.action.kind === "revert" && failure.code === "conflict") {
174
193
  let abort = await runCommand(deps.run, ["git", "revert", "--abort"], root, "revert-abort", deps.signal);
175
194
  if (isIndexBusy(abort)) {
@@ -181,8 +200,9 @@ async function runAction(deps, config, request) {
181
200
  }
182
201
  return { ok: false, error: { code: failure.code, message: failure.message + where } };
183
202
  }
184
- if (request.action.kind === "stash-push" && /No local changes to save/i.test(outcome.run.stdout + outcome.run.stderr)) {
185
- return { ok: false, error: { code: "git-error", message: (outcome.run.stdout.trim() || "No local changes to save") + where } };
203
+ if ((request.action.kind === "stash-push" || stashing) && /No local changes to save/i.test(outcome.run.stdout + outcome.run.stderr)) {
204
+ const message = (outcome.run.stdout.trim() || "No local changes to save") + where;
205
+ return { ok: false, error: { code: stashing ? "local-changes-block" : "git-error", message } };
186
206
  }
187
207
  }
188
208
  const snapshot = await snapshotForSession(deps, config, request.sessionId);
@@ -15,9 +15,11 @@ export interface GitPanelConfig {
15
15
  readonly graphStyle: GraphStyle;
16
16
  /** Whether the "AI suggest" button shows in the commit box. */
17
17
  readonly suggestEnabled: boolean;
18
- /** Max diff bytes sent to the model; beyond this the diff is truncated. */
18
+ /** Max diff bytes sent to the model; beyond this the diff is truncated. Range 16–512 KiB. */
19
19
  readonly suggestMaxBytes: number;
20
+ /** Model output token budget (reasoning tokens count against it on thinking models). Range 256–16384. */
20
21
  readonly suggestMaxOutputTokens: number;
22
+ /** Timeout for one suggest call. Range 5–120 s. */
21
23
  readonly suggestTimeoutMs: number;
22
24
  /** Optional provider/model route override; absent → the agent default. */
23
25
  readonly suggestProvider?: string;
@@ -46,6 +48,12 @@ export interface SnapshotDeps {
46
48
  }>;
47
49
  /** Raw bytes (no encoding) — image sides for the image-diff query. */
48
50
  readFile(path: string): Promise<Buffer>;
51
+ /**
52
+ * A symlink's target text, else null. git stores the LINK as the worktree
53
+ * blob (mode 120000), so the diff's new side is the target string, not the
54
+ * file it points at — following the link would describe a different file.
55
+ */
56
+ symlinkTarget(path: string): Promise<string | null>;
49
57
  /** One directory's entries (name + is-directory) — the file browser. */
50
58
  readdir(path: string): Promise<ReadonlyArray<{
51
59
  name: string;
package/lib/host/core.js CHANGED
@@ -2,6 +2,7 @@ import { dirname, join } from "node:path";
2
2
  import { commitFromFields, parseStatus, sumNumstat } from "./parser.js";
3
3
  import { parseRemote } from "./remote.js";
4
4
  import { isSafePath } from "./validate.js";
5
+ import { SUGGEST_LIMITS } from "./types.js";
5
6
  const DEFAULT_CONFIG = {
6
7
  timeoutMs: 8e3,
7
8
  maxBytes: 4 * 1024 * 1024,
@@ -11,13 +12,17 @@ const DEFAULT_CONFIG = {
11
12
  defaultDiffView: "unified",
12
13
  graphStyle: "parallel",
13
14
  suggestEnabled: true,
14
- suggestMaxBytes: 64 * 1024,
15
- suggestMaxOutputTokens: 200,
16
- suggestTimeoutMs: 15e3
15
+ suggestMaxBytes: SUGGEST_LIMITS.maxBytes.default,
16
+ suggestMaxOutputTokens: SUGGEST_LIMITS.maxOutputTokens.default,
17
+ suggestTimeoutMs: SUGGEST_LIMITS.timeoutMs.default
17
18
  };
18
19
  function normalizeConfig(raw) {
19
20
  const c = raw ?? {};
20
21
  const num = (v, d) => typeof v === "number" && Number.isFinite(v) && v >= 1 ? Math.floor(v) : d;
22
+ const clampLimit = (v, limit) => {
23
+ const raw2 = unwrapVolatile(v);
24
+ return typeof raw2 === "number" && Number.isFinite(raw2) ? Math.min(limit.max, Math.max(limit.min, Math.floor(raw2))) : limit.default;
25
+ };
21
26
  const hasProvider = typeof c.suggestProvider === "string" && c.suggestProvider !== "";
22
27
  const hasModel = typeof c.suggestModel === "string" && c.suggestModel !== "";
23
28
  const route = hasProvider && hasModel ? { suggestProvider: c.suggestProvider, suggestModel: c.suggestModel } : {};
@@ -30,9 +35,9 @@ function normalizeConfig(raw) {
30
35
  defaultDiffView: readDiffView(c.defaultDiffView, DEFAULT_CONFIG.defaultDiffView),
31
36
  graphStyle: readGraphStyle(c.graphStyle, DEFAULT_CONFIG.graphStyle),
32
37
  suggestEnabled: readBool(c.suggestEnabled, DEFAULT_CONFIG.suggestEnabled),
33
- suggestMaxBytes: num(c.suggestMaxBytes, DEFAULT_CONFIG.suggestMaxBytes),
34
- suggestMaxOutputTokens: num(c.suggestMaxOutputTokens, DEFAULT_CONFIG.suggestMaxOutputTokens),
35
- suggestTimeoutMs: num(c.suggestTimeoutMs, DEFAULT_CONFIG.suggestTimeoutMs),
38
+ suggestMaxBytes: clampLimit(c.suggestMaxBytes, SUGGEST_LIMITS.maxBytes),
39
+ suggestMaxOutputTokens: clampLimit(c.suggestMaxOutputTokens, SUGGEST_LIMITS.maxOutputTokens),
40
+ suggestTimeoutMs: clampLimit(c.suggestTimeoutMs, SUGGEST_LIMITS.timeoutMs),
36
41
  ...route
37
42
  };
38
43
  }
@@ -22,21 +22,27 @@ export { readVersionInfo, checkLatestVersion, compareVersions, parseRepository }
22
22
  export declare class GitPanelService extends TypertRemoteService {
23
23
  static inject: string[];
24
24
  /**
25
- * Config schema surfaced on the plugin detail page. `showInputPill` and
26
- * `defaultDiffView` are `.volatile()`, so the settings host renders them as
27
- * live-editable controls; the operational limits stay profile-only and out
28
- * of the UI form.
25
+ * Config schema surfaced on the plugin detail page. `showInputPill`,
26
+ * `defaultDiffView`, `graphStyle` and the suggest fields are `.volatile()`,
27
+ * so the settings host renders them as live-editable controls; the
28
+ * operational limits stay profile-only and out of the UI form.
29
29
  */
30
30
  static Config: Schema<Schemastery.ObjectS<NoInfer<{
31
31
  showInputPill: Schema<boolean, boolean, "volatile-defined">;
32
32
  defaultDiffView: Schema<"split" | "unified", "split" | "unified", "volatile-defined">;
33
33
  graphStyle: Schema<"compact" | "parallel", "compact" | "parallel", "volatile-defined">;
34
34
  suggestEnabled: Schema<boolean, boolean, "volatile-defined">;
35
+ suggestMaxBytes: Schema<number, number, "volatile-defined">;
36
+ suggestMaxOutputTokens: Schema<number, number, "volatile-defined">;
37
+ suggestTimeoutMs: Schema<number, number, "volatile-defined">;
35
38
  }>>, Schemastery.ObjectT<NoInfer<{
36
39
  showInputPill: Schema<boolean, boolean, "volatile-defined">;
37
40
  defaultDiffView: Schema<"split" | "unified", "split" | "unified", "volatile-defined">;
38
41
  graphStyle: Schema<"compact" | "parallel", "compact" | "parallel", "volatile-defined">;
39
42
  suggestEnabled: Schema<boolean, boolean, "volatile-defined">;
43
+ suggestMaxBytes: Schema<number, number, "volatile-defined">;
44
+ suggestMaxOutputTokens: Schema<number, number, "volatile-defined">;
45
+ suggestTimeoutMs: Schema<number, number, "volatile-defined">;
40
46
  }>>, "plain">;
41
47
  private readonly deps;
42
48
  private config;
package/lib/host/index.js CHANGED
@@ -49,14 +49,14 @@ var __privateMethod = (obj, member, method) => (__accessCheck(obj, member, "acce
49
49
  var _version_dec, _suggest_dec, _query_dec, _run_dec, _snapshot_dec, _a, _init;
50
50
  import { Remote, TypertRemoteService } from "@deepseek-ai/dsh-typert-protocol";
51
51
  import Schema from "@deepseek-ai/schemastery";
52
- import { readFile, readdir, realpath, rm, stat } from "node:fs/promises";
52
+ import { lstat, readFile, readdir, readlink, realpath, rm, stat } from "node:fs/promises";
53
53
  import { createGitRunner } from "./git.js";
54
- import { normalizeConfig, snapshotForSession } from "./core.js";
54
+ import { normalizeConfig, snapshotForSession, DEFAULT_CONFIG } from "./core.js";
55
55
  import { runAction } from "./actions.js";
56
56
  import { runQuery } from "./queries.js";
57
57
  import { runSuggest } from "./suggest.js";
58
58
  import { checkLatestVersion, readVersionInfo } from "./version.js";
59
- import { normalizeConfig as normalizeConfig2, DEFAULT_CONFIG, snapshotForSession as snapshotForSession2, resolveWorkspace, parsePrimaryRemote } from "./core.js";
59
+ import { normalizeConfig as normalizeConfig2, DEFAULT_CONFIG as DEFAULT_CONFIG2, snapshotForSession as snapshotForSession2, resolveWorkspace, parsePrimaryRemote } from "./core.js";
60
60
  import { createGitRunner as createGitRunner2 } from "./git.js";
61
61
  import { parseStatus, parseGraphLog, parseBranches, parseNameStatus, parseStashList, parseTags, sumNumstat } from "./parser.js";
62
62
  import { parseRemote, classifyHost } from "./remote.js";
@@ -83,6 +83,13 @@ class GitPanelService extends (_a = TypertRemoteService, _snapshot_dec = [Remote
83
83
  realpath,
84
84
  stat: async (p) => stat(p),
85
85
  readFile,
86
+ symlinkTarget: async (p) => {
87
+ try {
88
+ return (await lstat(p)).isSymbolicLink() ? await readlink(p) : null;
89
+ } catch {
90
+ return null;
91
+ }
92
+ },
86
93
  readdir: async (p) => (await readdir(p, { withFileTypes: true })).map((e) => ({ name: e.name, isDirectory: e.isDirectory() })),
87
94
  remove: async (p) => {
88
95
  await rm(p, { force: true });
@@ -157,10 +164,10 @@ __decorateElement(_init, 1, "version", _version_dec, GitPanelService);
157
164
  __decoratorMetadata(_init, GitPanelService);
158
165
  __publicField(GitPanelService, "inject", ["subprocess", "sessions", "sessionPersistence"]);
159
166
  /**
160
- * Config schema surfaced on the plugin detail page. `showInputPill` and
161
- * `defaultDiffView` are `.volatile()`, so the settings host renders them as
162
- * live-editable controls; the operational limits stay profile-only and out
163
- * of the UI form.
167
+ * Config schema surfaced on the plugin detail page. `showInputPill`,
168
+ * `defaultDiffView`, `graphStyle` and the suggest fields are `.volatile()`,
169
+ * so the settings host renders them as live-editable controls; the
170
+ * operational limits stay profile-only and out of the UI form.
164
171
  */
165
172
  __publicField(GitPanelService, "Config", Schema.object({
166
173
  showInputPill: Schema.boolean().default(true).volatile().description("\u663E\u793A\u8F93\u5165\u6846\u7684 Git \u5206\u652F\u6807\u8BB0"),
@@ -172,11 +179,16 @@ __publicField(GitPanelService, "Config", Schema.object({
172
179
  Schema.const("parallel").description("\u5E73\u884C\u7EBF\uFF08VSCode \u98CE\u683C\uFF0C\u5206\u652F\u5404\u5360\u4E00\u5217\uFF09"),
173
180
  Schema.const("compact").description("\u7D27\u51D1\uFF08git log \u98CE\u683C\uFF0C\u5171\u4EAB\u7956\u5148\u5408\u5E76\u4E3A\u4E00\u5217\uFF09")
174
181
  ]).default("parallel").volatile().description("\u63D0\u4EA4\u5386\u53F2\u56FE\u7EBF\u6761\u6837\u5F0F"),
175
- suggestEnabled: Schema.boolean().default(true).volatile().description("\u63D0\u4EA4\u6846\u663E\u793A AI \u751F\u6210\u6309\u94AE")
182
+ suggestEnabled: Schema.boolean().default(true).volatile().description("\u63D0\u4EA4\u6846\u663E\u793A AI \u751F\u6210\u6309\u94AE"),
183
+ // Defaults come from DEFAULT_CONFIG (which itself reads SUGGEST_LIMITS) —
184
+ // a literal here could silently drift from what normalizeConfig enforces.
185
+ suggestMaxBytes: Schema.number().default(DEFAULT_CONFIG.suggestMaxBytes).volatile().description("AI \u751F\u6210\u8F93\u5165\u9884\u7B97\uFF08diff \u5B57\u8282\u4E0A\u9650\uFF0C16\u2013512 KiB\uFF09"),
186
+ suggestMaxOutputTokens: Schema.number().default(DEFAULT_CONFIG.suggestMaxOutputTokens).volatile().description("AI \u751F\u6210\u8F93\u51FA\u4E0A\u9650\uFF08tokens\uFF0C256\u201316384\uFF09"),
187
+ suggestTimeoutMs: Schema.number().default(DEFAULT_CONFIG.suggestTimeoutMs).volatile().description("AI \u751F\u6210\u8D85\u65F6\uFF08\u6BEB\u79D2\uFF0C5\u2013120 \u79D2\uFF09")
176
188
  }));
177
189
  var index_default = GitPanelService;
178
190
  export {
179
- DEFAULT_CONFIG,
191
+ DEFAULT_CONFIG2 as DEFAULT_CONFIG,
180
192
  GitPanelService,
181
193
  checkLatestVersion2 as checkLatestVersion,
182
194
  classifyActionFailure,
@@ -2,7 +2,7 @@ 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
4
  import { commitFromFields, parseBranches, parseGraphLog, parseNameStatus, parseStashList, parseTags, sumNumstat } from "./parser.js";
5
- import { imageMimeFor } from "./types.js";
5
+ import { imageMimeFor, DIFF_CONTEXT_DEFAULT, DIFF_CONTEXT_MAX, DIFF_COUNT_READ_MAX } from "./types.js";
6
6
  const GRAPH_FORMAT = "--format=%H%x1f%h%x1f%P%x1f%an%x1f%aI%x1f%D%x1f%s%x1e";
7
7
  const VISIBLE_REFS = ["--branches", "--remotes", "--tags", "HEAD"];
8
8
  function isHexLike(text) {
@@ -103,7 +103,7 @@ async function queryHistory(deps, root, q) {
103
103
  }
104
104
  async function queryDiff(deps, root, q) {
105
105
  if (!isSafePath(q.path)) return { ok: false, error: { code: "invalid-path", message: q.path } };
106
- const ctx = q.context !== void 0 && Number.isFinite(q.context) ? Math.max(0, Math.min(1e5, Math.floor(q.context))) : 3;
106
+ const ctx = q.context !== void 0 && Number.isFinite(q.context) ? Math.max(0, Math.min(DIFF_CONTEXT_MAX, Math.floor(q.context))) : DIFF_CONTEXT_DEFAULT;
107
107
  const unified = `-U${ctx}`;
108
108
  let args;
109
109
  if (q.base === "staged") {
@@ -118,41 +118,80 @@ async function queryDiff(deps, root, q) {
118
118
  if (!("run" in res)) return { ok: false, error: { code: "git-unavailable" } };
119
119
  if (res.run.cancelled) return { ok: false, error: { code: "cancelled" } };
120
120
  if (res.run.timedOut) return { ok: false, error: { code: "timeout" } };
121
+ let truncated = res.run.stdoutLossy;
121
122
  let text = res.run.stdout;
122
123
  if (q.base === "worktree" && text.trim() === "") {
123
124
  const tracked = await runCommand(deps.run, ["git", "ls-files", "--error-unmatch", "--", q.path], root, "diff-tracked-probe", deps.signal);
124
125
  const isUntracked = !("run" in tracked) || tracked.run.exitCode !== 0;
125
126
  if (isUntracked) {
126
127
  const noIndex = await runCommand(deps.run, ["git", "diff", unified, "--no-index", "--", "/dev/null", q.path], root, "diff-untracked", deps.signal);
127
- if ("run" in noIndex) text = noIndex.run.stdout;
128
+ if ("run" in noIndex) {
129
+ truncated = truncated || noIndex.run.stdoutLossy;
130
+ text = noIndex.run.stdout;
131
+ }
128
132
  }
129
133
  }
130
- return { ok: true, value: { kind: "diff", path: q.path, text } };
134
+ return {
135
+ ok: true,
136
+ value: {
137
+ kind: "diff",
138
+ path: q.path,
139
+ text,
140
+ newLines: await countNewSideLines(deps, root, q, text),
141
+ ...truncated ? { truncated: true } : {}
142
+ }
143
+ };
144
+ }
145
+ async function countNewSideLines(deps, root, q, diffText) {
146
+ if (!diffText.includes("@@")) return 0;
147
+ const side = await readNewSide(deps, root, q, { worktreeMaxBytes: DIFF_COUNT_READ_MAX });
148
+ if (!side.ok || side.text === null) return 0;
149
+ return countLines(side.text);
150
+ }
151
+ function countLines(content) {
152
+ if (content === "") return 0;
153
+ let lines = 1;
154
+ for (let at = content.indexOf("\n"); at !== -1; at = content.indexOf("\n", at + 1)) lines++;
155
+ return content.endsWith("\n") ? lines - 1 : lines;
156
+ }
157
+ function splitLines(content) {
158
+ const all = content.split("\n");
159
+ if (all[all.length - 1] === "") all.pop();
160
+ return all;
131
161
  }
132
162
  async function queryFileLines(deps, root, q) {
133
163
  if (!isSafePath(q.path)) return { ok: false, error: { code: "invalid-path", message: q.path } };
134
164
  const start = Number.isFinite(q.start) ? Math.max(1, Math.floor(q.start)) : 1;
135
165
  const end = Number.isFinite(q.end) ? Math.max(start, Math.floor(q.end)) : start;
136
- let args;
137
- if (q.base === "commit") {
138
- if (!isSafeRev(q.commit)) return { ok: false, error: { code: "invalid-name", message: `unsafe commit: ${q.commit}` } };
139
- args = ["git", "show", "--end-of-options", `${q.commit}:${q.path}`];
140
- } else {
141
- args = ["git", "show", `--end-of-options`, `:${q.path}`];
142
- if (q.base === "worktree") args = ["git", "cat-file", "-p", `:${q.path}`];
143
- }
144
- const res = await runCommand(deps.run, args, root, "file-lines", deps.signal);
145
- if (!("run" in res)) return { ok: false, error: { code: "git-unavailable" } };
146
- if (res.run.cancelled) return { ok: false, error: { code: "cancelled" } };
147
- if (res.run.timedOut) return { ok: false, error: { code: "timeout" } };
166
+ const side = await readNewSide(deps, root, q);
167
+ if (!side.ok) return { ok: false, error: { code: side.code, message: side.message } };
168
+ return sliceLines(q.path, side.text ?? "", start, end);
169
+ }
170
+ const ABSENT_SIDE_RE = /does not exist in|exists on disk, but not in the index|no such path/i;
171
+ async function readNewSide(deps, root, q, opts) {
172
+ if (q.base === "worktree") return { ok: true, text: await readWorktreeSide(deps, root, q.path, opts?.worktreeMaxBytes) };
173
+ if (q.base === "commit" && !isSafeRev(q.commit)) return { ok: false, code: "invalid-name", message: `unsafe commit: ${q.commit}` };
174
+ const spec = q.base === "commit" ? `${q.commit}:${q.path}` : `:${q.path}`;
175
+ const res = await runCommand(deps.run, ["git", "show", "--end-of-options", spec], root, "new-side", deps.signal);
176
+ if (!("run" in res)) return { ok: false, code: "git-unavailable", message: "git unavailable" };
177
+ if (res.run.cancelled) return { ok: false, code: "cancelled", message: "cancelled" };
178
+ if (res.run.timedOut) return { ok: false, code: "timeout", message: "timed out" };
148
179
  if (res.run.exitCode !== 0) {
149
- if (q.base === "worktree") {
150
- const wt = await readWorktreeText(deps, root, q.path);
151
- if (wt !== null) return sliceLines(q.path, wt, start, end);
152
- }
153
- return { ok: false, error: { code: "git-error", message: res.run.stderr.trim() || "no such blob" } };
180
+ return ABSENT_SIDE_RE.test(res.run.stderr) ? { ok: true, text: null } : { ok: false, code: "git-error", message: res.run.stderr.trim() || "git show failed" };
181
+ }
182
+ if (res.run.stdoutLossy) return { ok: false, code: "git-error", message: "git show output exceeded the output cap" };
183
+ return { ok: true, text: res.run.stdout };
184
+ }
185
+ async function readWorktreeSide(deps, root, path, maxBytes) {
186
+ const file = join(root, path);
187
+ const link = await deps.fs.symlinkTarget(file);
188
+ if (link !== null) return link;
189
+ if (maxBytes !== void 0) {
190
+ const size = await deps.fs.stat(file).catch(() => null);
191
+ if (size === null || size.size > maxBytes) return null;
154
192
  }
155
- return sliceLines(q.path, res.run.stdout, start, end);
193
+ const text = await readWorktreeText(deps, root, path);
194
+ return text === null ? null : text.replace(/\r\n/g, "\n");
156
195
  }
157
196
  async function readWorktreeText(deps, root, path) {
158
197
  try {
@@ -167,8 +206,7 @@ async function readWorktreeText(deps, root, path) {
167
206
  }
168
207
  }
169
208
  function sliceLines(path, content, start, end) {
170
- const all = content.split("\n");
171
- if (all[all.length - 1] === "") all.pop();
209
+ const all = splitLines(content);
172
210
  const from = Math.min(start, all.length + 1);
173
211
  const to = Math.min(end, all.length);
174
212
  const lines = from <= to ? all.slice(from - 1, to) : [];
@@ -81,6 +81,7 @@ async function collectDiff(deps, root, hasHead, pathArgs) {
81
81
  const cmds = hasHead ? [["git", "diff", "HEAD", ...pathArgs]] : [["git", "diff", "--cached", ...pathArgs], ["git", "diff", ...pathArgs]];
82
82
  const runs = await Promise.all(cmds.map((argv) => runCommand(deps.run, argv, root, "suggest-diff", deps.signal)));
83
83
  let diff = "";
84
+ let lossy = false;
84
85
  for (const res of runs) {
85
86
  if ("failure" in res) return { ok: false, error: { code: "git-unavailable", message: "diff failed" } };
86
87
  if (res.run.cancelled) return { ok: false, error: { code: "cancelled" } };
@@ -88,9 +89,10 @@ async function collectDiff(deps, root, hasHead, pathArgs) {
88
89
  if (res.run.exitCode !== 0) {
89
90
  return { ok: false, error: { code: "git-error", message: res.run.stderr.trim() || "git diff failed" } };
90
91
  }
92
+ if (res.run.stdoutLossy) lossy = true;
91
93
  diff += res.run.stdout;
92
94
  }
93
- return diff;
95
+ return { diff, lossy };
94
96
  }
95
97
  async function appendUntracked(deps, root, files, diff, budget) {
96
98
  let text = diff;
@@ -106,7 +108,8 @@ async function appendUntracked(deps, root, files, diff, budget) {
106
108
  if ("failure" in res || res.run.cancelled || res.run.timedOut) continue;
107
109
  const chunk = res.run.stdout;
108
110
  if (chunk === "") continue;
109
- if (Buffer.byteLength(chunk, "utf8") > remaining) {
111
+ const lossy = res.run.stdoutLossy;
112
+ if (lossy || Buffer.byteLength(chunk, "utf8") > remaining) {
110
113
  text += truncateUtf8(chunk, remaining) + "\n... [diff truncated]";
111
114
  truncated = true;
112
115
  break;
@@ -144,12 +147,12 @@ async function runSuggest(deps, config, request) {
144
147
  const all = statusRes.run.stdoutLossy ? [] : parseStatus(statusRes.run.stdout);
145
148
  const files = pathSet.size > 0 ? all.filter((c) => pathSet.has(c.path)) : all;
146
149
  const collected = await collectDiff(deps, root, hasHead, pathArgs);
147
- if (typeof collected !== "string") return collected;
148
- if (files.length === 0 && collected.trim() === "") {
150
+ if (!("diff" in collected)) return collected;
151
+ if (files.length === 0 && collected.diff.trim() === "") {
149
152
  return { ok: false, error: { code: "empty-diff", message: "no uncommitted changes to summarize" } };
150
153
  }
151
- let diff = collected;
152
- let truncated = false;
154
+ let diff = collected.diff;
155
+ let truncated = collected.lossy;
153
156
  if (Buffer.byteLength(diff, "utf8") > config.suggestMaxBytes) {
154
157
  diff = truncateUtf8(diff, config.suggestMaxBytes) + "\n... [diff truncated]";
155
158
  truncated = true;
@@ -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
  } | {
package/lib/host/types.js CHANGED
@@ -1,3 +1,12 @@
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
+ };
1
10
  const IMAGE_MIME = {
2
11
  png: "image/png",
3
12
  jpg: "image/jpeg",
@@ -17,6 +26,11 @@ function imageMimeFor(path) {
17
26
  return IMAGE_MIME[path.slice(dot + 1).toLowerCase()] ?? null;
18
27
  }
19
28
  export {
29
+ DIFF_CONTEXT_DEFAULT,
30
+ DIFF_CONTEXT_MAX,
31
+ DIFF_COUNT_READ_MAX,
32
+ DIFF_EXPAND_CONTEXT,
20
33
  IMAGE_MIME,
34
+ SUGGEST_LIMITS,
21
35
  imageMimeFor
22
36
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xbzbing/dsh-git-panel",
3
- "version": "1.5.4",
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",
@@ -43,7 +43,7 @@
43
43
  "build": "node build.mjs",
44
44
  "typecheck": "tsc -p tsconfig.json --noEmit",
45
45
  "test:unit": "node build.mjs && node --test --test-force-exit test/unit/*.test.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",
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",
47
47
  "test": "npm run test:unit && npm run test:e2e",
48
48
  "check": "npm run typecheck && npm run test && git diff --exit-code -- lib/"
49
49
  },