@klarkxy/dsh-git-commit 0.2.0 → 0.2.1

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.md CHANGED
@@ -1,5 +1,8 @@
1
1
  # @klarkxy/dsh-git-commit
2
2
 
3
+ <!-- release-state-note -->
4
+ The [repository registry](../../README.md#plugin-lifecycle) tracks Draft → Testing → Published, moving forward only. Fixes, testing and version updates keep published packages Published; each update still passes release checks. First publication explicitly selects Testing packages and records Published only after npm archive verification. See the [release guide](../../RELEASING.md).
5
+
3
6
  Commit workspace changes to git without leaving the chat. Every conversation
4
7
  thread gets a **Commit** action in its header: click it, check the branch, the
5
8
  file count and the model that will be used, then confirm to commit every
@@ -69,6 +72,16 @@ nothing.
69
72
  The button is disabled while the session is running. The host checks again
70
73
  before committing, and stops the run if the workspace changes during planning.
71
74
 
75
+ If a later group fails or the run is cancelled, completed commits stay in place.
76
+ The panel shows those commits, the remaining groups and the stopping reason,
77
+ then refreshes repository status. A commit whose hash could not be read is
78
+ marked explicitly. It never rolls back completed commits or retries them
79
+ automatically. Unloading the plugin cancels in-flight planning and rejects new
80
+ runs while existing cleanup finishes.
81
+ If a rejected Git command leaves HEAD changed or unreadable, its group's outcome
82
+ is marked uncertain without attributing a commit to the plugin. Check Git log
83
+ and status before acting on that group; it is separate from groups not executed.
84
+
72
85
  ## Notes
73
86
 
74
87
  - Each selected file is committed from its current worktree content. Pre-staged
@@ -1,5 +1,8 @@
1
1
  # @klarkxy/dsh-git-commit
2
2
 
3
+ <!-- release-state-note -->
4
+ 插件生命周期见[仓库状态清单](../../../README.zh-CN.md#插件生命周期):草稿 → 测试中 → 已发布,只向前推进。已发布插件的修复、测试和升版保持已发布;每次更新仍须通过发布检查。首次发布须明确选择测试中的包,并在 npm 归档验证成功后登记。详见[发布规则](../../../RELEASING.zh-CN.md)。
5
+
3
6
  在对话里直接把工作区修改提交到 git。每个对话线程的右上角都有一个 **提交** 按钮:
4
7
  点一下打开弹层,确认分支、文件数量和将使用的模型,再点一下即可把会话工作目录
5
8
  所在仓库的全部未提交改动分次提交。
@@ -52,6 +55,13 @@ dsh plugin --profile web remove @klarkxy/dsh-git-commit
52
55
 
53
56
  会话运行期间按钮不可用。宿主在提交前还会再检查一次,规划期间工作区发生变化时会中止本次提交。
54
57
 
58
+ 后续分组失败或执行被取消时,已经完成的提交仍然保留。面板显示这些提交、剩余
59
+ 分组和停止原因,再刷新仓库状态;已创建但未能读回哈希的提交会明确标注。
60
+ 插件不会回滚已完成的提交,也不会自动重试。卸载插件会取消正在进行的规划,
61
+ 拒绝新的执行,并等待已有操作完成清理。
62
+ 如果 Git 命令报错后 HEAD 已变化或无法读回,对应分组会标为“结果不确定”,不擅自
63
+ 归属某个提交。请先检查 Git 日志和状态;这一组与尚未执行的分组分开显示。
64
+
55
65
  ## 说明
56
66
 
57
67
  - 每个选中文件按当前工作区内容提交。属于其他分组的已暂存修改会保留到对应分组提交。
@@ -381,7 +381,7 @@ ${scope} {
381
381
  border: 0.5px solid var(--dsw-alias-border-l3);
382
382
  border-radius: var(--dsw-radius-md);
383
383
  background: var(--dsw-alias-bg-layer-1);
384
- font-family: var(--ds-font-family-code, ui-monospace, 'Cascadia Mono', Consolas, monospace);
384
+ font-family: var(--ds-font-family-code);
385
385
  font-size: 12px;
386
386
  line-height: 20px;
387
387
  color: var(--dsw-alias-label-secondary);
@@ -457,6 +457,162 @@ ${scope} {
457
457
  `.replace(/^& (\.dsh-ui-[a-z0-9-]+)(?![+>~])/gm, (_match, className) => `&${className}, & ${className}`);
458
458
  }
459
459
  //#endregion
460
+ //#region ../dsh-plugin-kit/src/contracts.ts
461
+ /**
462
+ * Remote contribution for model-settings change notifications.
463
+ * Provided by `@klarkxy/dsh-model-hub` (`ModelSettingsChangesController`);
464
+ * subscribers degrade silently when that package is not installed.
465
+ * The stream carries invalidations only — `ready` is never a settings snapshot.
466
+ */
467
+ const MODEL_SETTINGS_CHANGES_REMOTE = {
468
+ package: "@klarkxy/dsh-model-hub",
469
+ descriptors: [{
470
+ id: "modelSettingsChanges/watch",
471
+ service: "modelSettingsChangesController",
472
+ namespace: "modelSettingsChanges",
473
+ method: "watch",
474
+ mode: "stream",
475
+ invocation: { kind: "direct" },
476
+ parameters: [],
477
+ cancellation: { parameter: "signal" }
478
+ }]
479
+ };
480
+ //#endregion
481
+ //#region ../dsh-plugin-kit/src/model-settings-client.ts
482
+ /** This stream carries invalidations only; ready is never a settings snapshot. */
483
+ function parseModelSettingsChangeFrame(value) {
484
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("Invalid model settings change frame");
485
+ const frame = value;
486
+ if (frame.kind === "ready" && Object.keys(frame).length === 1) return { kind: "ready" };
487
+ if (frame.kind === "changed" && Object.keys(frame).length === 3 && typeof frame.ns === "string" && frame.ns.length > 0 && typeof frame.revision === "number" && Number.isSafeInteger(frame.revision) && frame.revision >= 0) return {
488
+ kind: "changed",
489
+ ns: frame.ns,
490
+ revision: frame.revision
491
+ };
492
+ throw new Error("Invalid model settings change frame");
493
+ }
494
+ MODEL_SETTINGS_CHANGES_REMOTE.package, MODEL_SETTINGS_CHANGES_REMOTE.descriptors.map((descriptor) => ({
495
+ ...descriptor,
496
+ result: {
497
+ mode: "strict",
498
+ typeSymbol: "ModelSettings.ChangeFrame",
499
+ create: () => ({ parse: parseModelSettingsChangeFrame })
500
+ }
501
+ }));
502
+ /** Native RemoteStream owns carrier reconnects; no timers or browser transport.
503
+ * A factory shares one logical stream while at least one subscriber remains. */
504
+ function createModelSettingsSubscriber(ctx, namespace) {
505
+ const listeners = /* @__PURE__ */ new Set();
506
+ let injection;
507
+ let current;
508
+ let lifecycle = 0;
509
+ let failureMessage;
510
+ const report = (message) => {
511
+ failureMessage = message;
512
+ for (const listener of [...listeners]) try {
513
+ listener.error?.(message);
514
+ } catch {}
515
+ };
516
+ const invalidate = () => {
517
+ for (const listener of [...listeners]) {
518
+ if (!listeners.has(listener)) continue;
519
+ try {
520
+ listener.change();
521
+ } catch (error) {
522
+ try {
523
+ listener.error?.(error instanceof Error ? error.message : String(error));
524
+ } catch {}
525
+ }
526
+ }
527
+ };
528
+ const start = () => {
529
+ const life = ++lifecycle;
530
+ injection = ctx.inject(["remote", "remote.modelSettingsChanges"], (injected) => {
531
+ if (life !== lifecycle || !listeners.size) return;
532
+ const scope = injected;
533
+ const stream = scope.remote.$stream({
534
+ name: "Model settings sync",
535
+ open: (signal) => scope.remote.modelSettingsChanges.watch(signal),
536
+ ended: (accepted) => /* @__PURE__ */ new Error(`Model settings sync stream ended ${accepted ? "after" : "before"} ready; refresh or reopen the page`)
537
+ });
538
+ const session = {
539
+ stream,
540
+ active: true
541
+ };
542
+ current = session;
543
+ scope.effect(() => () => {
544
+ session.active = false;
545
+ if (current === session) {
546
+ current = void 0;
547
+ if (life === lifecycle && listeners.size) report("Model settings sync service unavailable");
548
+ }
549
+ return stream.dispose();
550
+ });
551
+ (async () => {
552
+ let generation;
553
+ let ready = false;
554
+ try {
555
+ for await (const item of stream) {
556
+ if (!session.active || life !== lifecycle || !listeners.size) break;
557
+ if (item.signal.aborted) continue;
558
+ if (generation !== item.generation) {
559
+ generation = item.generation;
560
+ ready = false;
561
+ }
562
+ const frame = parseModelSettingsChangeFrame(item.value);
563
+ if (frame.kind === "ready") {
564
+ ready = true;
565
+ failureMessage = void 0;
566
+ item.accept();
567
+ invalidate();
568
+ } else {
569
+ if (!ready) throw new Error("Model settings sync change arrived before ready");
570
+ if (namespace === "*" || frame.ns === namespace) invalidate();
571
+ }
572
+ }
573
+ } catch (error) {
574
+ if (session.active && life === lifecycle) report(error instanceof Error ? error.message : String(error));
575
+ } finally {
576
+ if (current === session) current = void 0;
577
+ session.active = false;
578
+ await stream.dispose().catch(() => {});
579
+ }
580
+ })();
581
+ });
582
+ queueMicrotask(() => {
583
+ if (life === lifecycle && listeners.size && !current) report("Model settings sync service unavailable");
584
+ });
585
+ };
586
+ return Object.freeze({ subscribe(onChange, onError) {
587
+ const listener = {
588
+ change: onChange,
589
+ error: onError
590
+ };
591
+ listeners.add(listener);
592
+ if (!injection) start();
593
+ else if (failureMessage) queueMicrotask(() => {
594
+ if (listeners.has(listener) && failureMessage) try {
595
+ listener.error?.(failureMessage);
596
+ } catch {}
597
+ });
598
+ return () => {
599
+ if (!listeners.delete(listener) || listeners.size) return;
600
+ lifecycle++;
601
+ if (current) {
602
+ current.active = false;
603
+ current.stream.dispose().catch(() => {});
604
+ }
605
+ current = void 0;
606
+ failureMessage = void 0;
607
+ const previous = injection;
608
+ injection = void 0;
609
+ try {
610
+ Promise.resolve(previous?.dispose()).catch(() => {});
611
+ } catch {}
612
+ };
613
+ } });
614
+ }
615
+ //#endregion
460
616
  //#region src/contracts.ts
461
617
  const PLUGIN_NAME = "@klarkxy/dsh-git-commit";
462
618
  const RPC_CHANNEL = "/dsh-git-commit";
@@ -487,6 +643,12 @@ const zh = {
487
643
  loading: "正在读取仓库状态…",
488
644
  running: "正在整理修改并分次提交…",
489
645
  done: "已创建提交",
646
+ partial: "本次提交未全部完成;以下提交已保留",
647
+ stopped: "本次操作未确认完成",
648
+ uncertain: "以下提交组的结果不确定。请检查 Git 日志和状态,确认后再操作。",
649
+ hashUnavailable: "提交已创建,哈希未读取",
650
+ remaining: "尚未提交:{n} 组",
651
+ cancelled: "提交操作已取消",
490
652
  fallback: "模型不可用,已合并为单个提交",
491
653
  noWorkspace: "工作区没有修改",
492
654
  unavailable: "不可用",
@@ -497,6 +659,12 @@ const zh = {
497
659
  sessionRunning: "会话正在运行,提交已暂停",
498
660
  refresh: "刷新",
499
661
  failed: "操作失败",
662
+ disabled: "插件已停用",
663
+ busy: "已有提交操作正在进行",
664
+ stale: "设置已更新,请刷新后重试",
665
+ workspaceChanged: "工作区或分支已变更,请刷新后重试提交",
666
+ settingsUnavailable: "无法读取提交设置,请重新加载插件后重试",
667
+ invalidRequest: "提交请求无效,请刷新后重试",
500
668
  planUnavailable: "模型没有给出可用的提交方案,已取消本次提交,工作区没有被改动。换一个模型重试,或在插件设置里开启兜底提交。",
501
669
  close: "关闭"
502
670
  };
@@ -515,6 +683,12 @@ const en = {
515
683
  loading: "Reading repository status…",
516
684
  running: "Organizing changes into commits…",
517
685
  done: "Commits created",
686
+ partial: "This run stopped before completion; these commits were kept",
687
+ stopped: "This run did not finish with a confirmed result",
688
+ uncertain: "The outcome of these groups is uncertain. Check Git log and status before taking further action.",
689
+ hashUnavailable: "Commit created; hash unavailable",
690
+ remaining: "Groups not committed: {n}",
691
+ cancelled: "Commit run cancelled",
518
692
  fallback: "Model unavailable; used a single commit",
519
693
  noWorkspace: "No workspace changes",
520
694
  unavailable: "Unavailable",
@@ -525,23 +699,31 @@ const en = {
525
699
  sessionRunning: "Session is running; commits paused",
526
700
  refresh: "Refresh",
527
701
  failed: "Operation failed",
702
+ disabled: "Plugin disabled",
703
+ busy: "A commit run is already in progress",
704
+ stale: "Settings changed; refresh and retry",
705
+ workspaceChanged: "The workspace or branch changed; refresh and retry the commit",
706
+ settingsUnavailable: "Could not read commit settings; reload the plugin and retry",
707
+ invalidRequest: "Invalid commit request; refresh and retry",
528
708
  planUnavailable: "The model returned no usable commit plan, so the run was cancelled and your workspace is untouched. Try another model, or turn on the fallback in plugin settings.",
529
709
  close: "Close"
530
710
  };
531
711
  /** A failed RPC keeps its code so the panel can explain a rejection in its own words. */
532
712
  var RpcError = class extends Error {
533
713
  code;
534
- constructor(code, message) {
714
+ partial;
715
+ constructor(code, message, partial) {
535
716
  super(message);
536
717
  this.name = "RpcError";
537
718
  this.code = code;
719
+ this.partial = partial;
538
720
  }
539
721
  };
540
- async function rpc(call, endpoint, payload = {}, fallback = "Operation failed") {
722
+ async function rpc(call, endpoint, payload = {}, fallback = "") {
541
723
  const result = await call(endpoint, payload);
542
724
  if (!result || result.ok !== true) {
543
725
  const failed = result && "error" in result ? result.error : void 0;
544
- throw new RpcError(failed?.code ?? "unknown", failed?.message ? failed.message : fallback);
726
+ throw new RpcError(failed?.code ?? "unknown", failed?.message ? failed.message : fallback, failed && "partial" in failed ? failed.partial : void 0);
545
727
  }
546
728
  return result.value;
547
729
  }
@@ -556,7 +738,25 @@ function reasonText(t, status) {
556
738
  }
557
739
  /** A refusal the plugin can name gets its own sentence; the rest stay verbatim. */
558
740
  function errorText(t, error) {
559
- return error.code === "plan-unavailable" ? t("planUnavailable") : error.message;
741
+ const key = {
742
+ "plan-unavailable": "planUnavailable",
743
+ "no-session": "reasonNoSession",
744
+ "no-cwd": "reasonNoCwd",
745
+ "no-git": "reasonNoGit",
746
+ "not-a-repo": "reasonNoRepo",
747
+ "no-changes": "noWorkspace",
748
+ "session-running": "sessionRunning",
749
+ "session-changed": "workspaceChanged",
750
+ "workspace-changed": "workspaceChanged",
751
+ busy: "busy",
752
+ stale: "stale",
753
+ disposed: "disabled",
754
+ cancelled: "cancelled",
755
+ "settings-unavailable": "settingsUnavailable",
756
+ "bad-request": "invalidRequest",
757
+ "not-found": "invalidRequest"
758
+ }[error.code];
759
+ return key ? t(key) : error.message || t("failed");
560
760
  }
561
761
  function panelError(reason) {
562
762
  return reason instanceof RpcError ? {
@@ -655,7 +855,7 @@ function GitCommitAction({ sessionId, useSession, call, t }) {
655
855
  setLoading(true);
656
856
  setError(null);
657
857
  try {
658
- const next = await rpc(call, "status", { sessionId }, t("failed"));
858
+ const next = await rpc(call, "status", { sessionId });
659
859
  if (generationRef.current !== generation) return;
660
860
  setStatus(next);
661
861
  } catch (reason) {
@@ -665,11 +865,7 @@ function GitCommitAction({ sessionId, useSession, call, t }) {
665
865
  } finally {
666
866
  if (generationRef.current === generation) setLoading(false);
667
867
  }
668
- }, [
669
- call,
670
- sessionId,
671
- t
672
- ]);
868
+ }, [call, sessionId]);
673
869
  (0, react.useEffect)(() => {
674
870
  if (!open) return;
675
871
  loadStatus();
@@ -692,23 +888,23 @@ function GitCommitAction({ sessionId, useSession, call, t }) {
692
888
  setResult(null);
693
889
  setError(null);
694
890
  try {
695
- const value = await rpc(call, "commit", { sessionId }, t("failed"));
891
+ const value = await rpc(call, "commit", { sessionId });
696
892
  if (generationRef.current !== generation) return;
697
893
  setResult(value);
698
- const next = await rpc(call, "status", { sessionId }).catch(() => void 0);
699
- if (generationRef.current !== generation) return;
700
- if (next) setStatus(next);
701
894
  } catch (reason) {
702
895
  if (generationRef.current !== generation) return;
896
+ if (reason instanceof RpcError && reason.partial) setResult(reason.partial);
703
897
  setError(panelError(reason));
704
898
  } finally {
705
- if (generationRef.current === generation) setRunning(false);
899
+ if (generationRef.current === generation) {
900
+ const next = await rpc(call, "status", { sessionId }).catch(() => void 0);
901
+ if (generationRef.current === generation) {
902
+ setStatus(next);
903
+ setRunning(false);
904
+ }
905
+ }
706
906
  }
707
- }, [
708
- call,
709
- sessionId,
710
- t
711
- ]);
907
+ }, [call, sessionId]);
712
908
  const available = status?.available === true;
713
909
  const workspaceCount = status?.workspace.files ?? 0;
714
910
  const canCommit = available && !running && !sessionRunning && workspaceCount > 0;
@@ -856,23 +1052,48 @@ function GitCommitAction({ sessionId, useSession, call, t }) {
856
1052
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("p", {
857
1053
  className: "dsh-ui-hint dsh-ui-wrap",
858
1054
  children: [
859
- t("done"),
1055
+ t("failure" in result ? result.commits.length ? "partial" : "stopped" : "done"),
860
1056
  " · ",
861
1057
  result.branch
862
1058
  ]
863
1059
  }),
864
1060
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("ul", {
865
1061
  className: "gcm-commits",
866
- children: result.commits.map((commit) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("li", { children: [
867
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("code", { children: commit.hash }),
1062
+ children: result.commits.map((commit, index) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("li", { children: [
1063
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("code", { children: commit.hash || t("hashUnavailable") }),
868
1064
  " ",
869
- commit.message
870
- ] }, commit.hash || commit.message))
1065
+ commit.message,
1066
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1067
+ className: "dsh-ui-hint",
1068
+ children: commit.files.join(", ")
1069
+ })
1070
+ ] }, `${index}:${commit.hash}`))
871
1071
  }),
872
1072
  result.fallback && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
873
1073
  className: "dsh-ui-hint",
874
1074
  children: t("fallback")
875
- })
1075
+ }),
1076
+ "uncertain" in result && result.uncertain?.length ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1077
+ className: "dsh-ui-hint",
1078
+ role: "status",
1079
+ children: t("uncertain")
1080
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("ul", {
1081
+ className: "gcm-commits",
1082
+ children: result.uncertain.map((group, index) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("li", { children: [group.message, /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1083
+ className: "dsh-ui-hint",
1084
+ children: group.files.join(", ")
1085
+ })] }, index))
1086
+ })] }) : null,
1087
+ "remaining" in result && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1088
+ className: "dsh-ui-hint",
1089
+ children: t("remaining").replace("{n}", String(result.remaining.length))
1090
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("ul", {
1091
+ className: "gcm-commits",
1092
+ children: result.remaining.map((group, index) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("li", { children: [group.message, /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1093
+ className: "dsh-ui-hint",
1094
+ children: group.files.join(", ")
1095
+ })] }, index))
1096
+ })] })
876
1097
  ]
877
1098
  })
878
1099
  ]
@@ -899,10 +1120,10 @@ const clientCss = `${officialUiCss("gcm-root")}
899
1120
  .gcm-settings { display: flex; flex-direction: column; gap: 12px; min-width: 0; }
900
1121
  .gcm-settings-status { display: flex; flex-direction: column; gap: 4px; }
901
1122
  `;
902
- /** Host UI language, following live switches; falls back to zh when the service lacks a snapshot. */
1123
+ /** Host UI language, following live switches with the host's English fallback. */
903
1124
  function useHostLocale(client) {
904
1125
  const subscribe = (0, react.useCallback)((listener) => client.locale.subscribe?.(listener) ?? (() => {}), [client]);
905
- const snapshot = (0, react.useCallback)(() => client.locale.getSnapshot?.().active ?? "zh", [client]);
1126
+ const snapshot = (0, react.useCallback)(() => client.locale.getSnapshot?.().active ?? "en", [client]);
906
1127
  return (0, react.useSyncExternalStore)(subscribe, snapshot, snapshot);
907
1128
  }
908
1129
  const settingsZh = {
@@ -917,7 +1138,9 @@ const settingsZh = {
917
1138
  saved: "已保存。",
918
1139
  stale: "设置已更新,请刷新后重试。",
919
1140
  failed: "无法读取提交设置。",
920
- retry: "重试"
1141
+ retry: "重试",
1142
+ refresh: "刷新设置",
1143
+ syncUnavailable: "自动刷新不可用,请手动刷新设置。"
921
1144
  };
922
1145
  const settingsEn = {
923
1146
  pageTitle: "Git Commit",
@@ -931,7 +1154,9 @@ const settingsEn = {
931
1154
  saved: "Saved.",
932
1155
  stale: "Settings changed; refresh and retry.",
933
1156
  failed: "Could not load commit settings.",
934
- retry: "Retry"
1157
+ retry: "Retry",
1158
+ refresh: "Refresh settings",
1159
+ syncUnavailable: "Automatic refresh unavailable; refresh settings manually."
935
1160
  };
936
1161
  function settingsCopy(locale) {
937
1162
  return String(locale).startsWith("zh") ? settingsZh : settingsEn;
@@ -943,23 +1168,77 @@ function GitCommitSettingsPanel(props) {
943
1168
  const [settings, setSettings] = (0, react.useState)(void 0);
944
1169
  const [choices, setChoices] = (0, react.useState)([]);
945
1170
  const [error, setError] = (0, react.useState)("");
946
- const [note, setNote] = (0, react.useState)("");
1171
+ const [note, setNote] = (0, react.useState)(false);
947
1172
  const [busy, setBusy] = (0, react.useState)(false);
1173
+ const [syncError, setSyncError] = (0, react.useState)("");
1174
+ const operation = (0, react.useRef)({
1175
+ epoch: 0,
1176
+ active: false,
1177
+ reading: false,
1178
+ writing: false,
1179
+ pending: false
1180
+ });
1181
+ const refreshRef = (0, react.useRef)(() => {});
948
1182
  const call = (0, react.useCallback)((endpoint, payload = {}) => props.client.connection.rpc.call(RPC_CHANNEL, endpoint, payload), [props.client]);
949
- (0, react.useEffect)(() => {
950
- let live = true;
1183
+ const refresh = () => {
1184
+ const state = operation.current;
1185
+ if (!state.active) return;
1186
+ if (state.reading || state.writing) {
1187
+ state.pending = true;
1188
+ return;
1189
+ }
1190
+ state.pending = false;
1191
+ state.reading = true;
1192
+ setBusy(true);
1193
+ const epoch = state.epoch;
951
1194
  rpc(call, "settings", {}, text.failed).then((next) => {
952
- if (live) {
1195
+ if (state.active && state.epoch === epoch && !state.pending) {
953
1196
  setSettings(next);
954
1197
  setError("");
955
1198
  }
956
1199
  }).catch(() => {
957
- if (live) setError(text.failed);
1200
+ if (state.active && state.epoch === epoch && !state.pending) setError("failed");
1201
+ }).finally(() => {
1202
+ if (!state.active || state.epoch !== epoch) return;
1203
+ state.reading = false;
1204
+ setBusy(false);
1205
+ if (state.pending) refreshRef.current();
1206
+ });
1207
+ };
1208
+ refreshRef.current = refresh;
1209
+ (0, react.useEffect)(() => {
1210
+ const state = operation.current;
1211
+ state.epoch++;
1212
+ state.active = true;
1213
+ state.reading = false;
1214
+ state.writing = false;
1215
+ state.pending = false;
1216
+ setBusy(false);
1217
+ setSettings(void 0);
1218
+ setError("");
1219
+ setNote(false);
1220
+ refreshRef.current();
1221
+ return () => {
1222
+ state.active = false;
1223
+ state.epoch++;
1224
+ };
1225
+ }, [call]);
1226
+ (0, react.useEffect)(() => {
1227
+ setSyncError("");
1228
+ let live = true;
1229
+ const stop = props.subscribeSettings?.(() => {
1230
+ if (live) {
1231
+ setSyncError("");
1232
+ refreshRef.current();
1233
+ }
1234
+ }, (message) => {
1235
+ if (live) setSyncError(message);
958
1236
  });
959
1237
  return () => {
960
1238
  live = false;
1239
+ stop?.();
961
1240
  };
962
- }, [call, text.failed]);
1241
+ }, [props.subscribeSettings, props.client]);
963
1242
  const boundProvider = settings?.model.provider ?? "";
964
1243
  const boundModel = settings?.model.model ?? "";
965
1244
  (0, react.useEffect)(() => {
@@ -982,9 +1261,12 @@ function GitCommitSettingsPanel(props) {
982
1261
  boundModel
983
1262
  ]);
984
1263
  const save = (0, react.useCallback)((patch) => {
985
- if (!settings) return;
1264
+ const state = operation.current;
1265
+ if (!settings || !state.active || state.writing || state.reading) return;
1266
+ state.writing = true;
1267
+ const epoch = state.epoch;
986
1268
  setBusy(true);
987
- setNote("");
1269
+ setNote(false);
988
1270
  setError("");
989
1271
  rpc(call, "settings.update", {
990
1272
  settings: {
@@ -993,12 +1275,17 @@ function GitCommitSettingsPanel(props) {
993
1275
  },
994
1276
  expectedRevision: settings.revision
995
1277
  }, text.stale).then((next) => {
996
- setSettings(next);
997
- setNote(text.saved);
998
- setBusy(false);
1278
+ if (state.active && state.epoch === epoch) {
1279
+ setSettings(next);
1280
+ setNote(true);
1281
+ }
999
1282
  }).catch(() => {
1000
- setError(text.stale);
1283
+ if (state.active && state.epoch === epoch) setError("stale");
1284
+ }).finally(() => {
1285
+ if (!state.active || state.epoch !== epoch) return;
1286
+ state.writing = false;
1001
1287
  setBusy(false);
1288
+ if (state.pending) refreshRef.current();
1002
1289
  });
1003
1290
  }, [
1004
1291
  call,
@@ -1006,17 +1293,31 @@ function GitCommitSettingsPanel(props) {
1006
1293
  text.saved,
1007
1294
  text.stale
1008
1295
  ]);
1009
- if (!settings) return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("section", {
1296
+ if (!settings) return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
1010
1297
  className: "gcm-root gcm-settings",
1011
- children: error ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1012
- role: "alert",
1013
- className: "dsh-ui-error",
1014
- children: error
1015
- }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1016
- role: "status",
1017
- className: "dsh-ui-hint",
1018
- children: text.loading
1019
- })
1298
+ children: [
1299
+ error ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1300
+ role: "alert",
1301
+ className: "dsh-ui-error",
1302
+ children: text[error]
1303
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1304
+ role: "status",
1305
+ className: "dsh-ui-hint",
1306
+ children: text.loading
1307
+ }),
1308
+ syncError && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1309
+ role: "status",
1310
+ className: "dsh-ui-hint",
1311
+ children: text.syncUnavailable
1312
+ }),
1313
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
1314
+ variant: "ghost",
1315
+ size: "sm",
1316
+ disabled: busy,
1317
+ onClick: () => refreshRef.current(),
1318
+ children: text.retry
1319
+ })
1320
+ ]
1020
1321
  });
1021
1322
  const current = settings.model;
1022
1323
  const selected = choices.find((item) => item.provider === current.provider && item.model === current.model);
@@ -1027,15 +1328,30 @@ function GitCommitSettingsPanel(props) {
1027
1328
  children: [
1028
1329
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1029
1330
  className: "gcm-settings-status",
1030
- children: [error && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1031
- role: "alert",
1032
- className: "dsh-ui-error",
1033
- children: error
1034
- }), note && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1035
- role: "status",
1036
- className: "dsh-ui-notice",
1037
- children: note
1038
- })]
1331
+ children: [
1332
+ syncError && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1333
+ role: "status",
1334
+ className: "dsh-ui-hint",
1335
+ children: text.syncUnavailable
1336
+ }),
1337
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
1338
+ variant: "ghost",
1339
+ size: "sm",
1340
+ disabled: busy,
1341
+ onClick: () => refreshRef.current(),
1342
+ children: text.refresh
1343
+ }),
1344
+ error && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1345
+ role: "alert",
1346
+ className: "dsh-ui-error",
1347
+ children: text[error]
1348
+ }),
1349
+ note && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1350
+ role: "status",
1351
+ className: "dsh-ui-notice",
1352
+ children: text.saved
1353
+ })
1354
+ ]
1039
1355
  }),
1040
1356
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
1041
1357
  className: "dsh-ui-field",
@@ -1119,6 +1435,8 @@ function GitCommitSettingsPanel(props) {
1119
1435
  }
1120
1436
  function apply(ctx) {
1121
1437
  const client = ctx;
1438
+ const settingsSubscriber = createModelSettingsSubscriber(ctx, PLUGIN_NAME);
1439
+ const subscribeSettings = (change, error) => settingsSubscriber.subscribe(change, error);
1122
1440
  ctx.provide(GIT_COMMIT_CLIENT_SERVICE, { active: true });
1123
1441
  ctx.effect(() => client.locale.register(NS, {
1124
1442
  zh: { ...zh },
@@ -1135,7 +1453,10 @@ function apply(ctx) {
1135
1453
  ctx.effect(() => client.slots.inject("plugins.bundle.config", () => client.slots.register({
1136
1454
  name: "plugins.bundle.config",
1137
1455
  key: PLUGIN_NAME
1138
- }, () => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(GitCommitSettingsPanel, { client }))), "git-commit.settings");
1456
+ }, () => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(GitCommitSettingsPanel, {
1457
+ client,
1458
+ subscribeSettings
1459
+ }))), "git-commit.settings");
1139
1460
  ctx.effect(() => client.slots.inject("conversation.session.header.actions", () => client.slots.register({
1140
1461
  name: "conversation.session.header.actions",
1141
1462
  id: "git-commit",