@klarkxy/dsh-git-commit 0.1.6 → 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.
@@ -3,21 +3,14 @@ let react = require("react");
3
3
  let react_dom = require("react-dom");
4
4
  let _deepseek_ai_dsh_client_ui_primitives = require("@deepseek-ai/dsh-client-ui-primitives");
5
5
  let react_jsx_runtime = require("react/jsx-runtime");
6
- //#region ../dsh-plugin-kit/src/model-menu.ts
7
- /**
8
- * Shared plugin-page model menu.
9
- *
10
- * A feature plugin's own settings row exposes a model select. An empty
11
- * selection means the host default chat model for that feature's own call.
12
- *
13
- * Browser-safe and React-optional: pure helpers live here so hosts and clients
14
- * share one catalog contract without bundling React into the host build.
15
- */
6
+ //#region ../dsh-model-route/src/route.ts
16
7
  const KEY_SEPARATOR = "";
17
- function modelMenuChoiceKey(provider, model) {
8
+ /** Stable key for one route, safe to use as a select-option value. */
9
+ function modelRouteKey(provider, model) {
18
10
  return provider && model ? `${provider}${KEY_SEPARATOR}${model}` : "";
19
11
  }
20
- function parseModelMenuChoiceKey(value) {
12
+ /** Inverse of modelRouteKey; undefined for malformed keys. */
13
+ function parseModelRouteKey(value) {
21
14
  const index = value.indexOf(KEY_SEPARATOR);
22
15
  if (index <= 0) return void 0;
23
16
  const provider = value.slice(0, index);
@@ -28,6 +21,8 @@ function parseModelMenuChoiceKey(value) {
28
21
  model
29
22
  };
30
23
  }
24
+ //#endregion
25
+ //#region ../dsh-model-route/src/catalog.ts
31
26
  function asRecord(value) {
32
27
  return value && typeof value === "object" && !Array.isArray(value) ? value : void 0;
33
28
  }
@@ -386,7 +381,7 @@ ${scope} {
386
381
  border: 0.5px solid var(--dsw-alias-border-l3);
387
382
  border-radius: var(--dsw-radius-md);
388
383
  background: var(--dsw-alias-bg-layer-1);
389
- font-family: var(--ds-font-family-code, ui-monospace, 'Cascadia Mono', Consolas, monospace);
384
+ font-family: var(--ds-font-family-code);
390
385
  font-size: 12px;
391
386
  line-height: 20px;
392
387
  color: var(--dsw-alias-label-secondary);
@@ -462,6 +457,162 @@ ${scope} {
462
457
  `.replace(/^& (\.dsh-ui-[a-z0-9-]+)(?![+>~])/gm, (_match, className) => `&${className}, & ${className}`);
463
458
  }
464
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
465
616
  //#region src/contracts.ts
466
617
  const PLUGIN_NAME = "@klarkxy/dsh-git-commit";
467
618
  const RPC_CHANNEL = "/dsh-git-commit";
@@ -492,6 +643,12 @@ const zh = {
492
643
  loading: "正在读取仓库状态…",
493
644
  running: "正在整理修改并分次提交…",
494
645
  done: "已创建提交",
646
+ partial: "本次提交未全部完成;以下提交已保留",
647
+ stopped: "本次操作未确认完成",
648
+ uncertain: "以下提交组的结果不确定。请检查 Git 日志和状态,确认后再操作。",
649
+ hashUnavailable: "提交已创建,哈希未读取",
650
+ remaining: "尚未提交:{n} 组",
651
+ cancelled: "提交操作已取消",
495
652
  fallback: "模型不可用,已合并为单个提交",
496
653
  noWorkspace: "工作区没有修改",
497
654
  unavailable: "不可用",
@@ -502,6 +659,12 @@ const zh = {
502
659
  sessionRunning: "会话正在运行,提交已暂停",
503
660
  refresh: "刷新",
504
661
  failed: "操作失败",
662
+ disabled: "插件已停用",
663
+ busy: "已有提交操作正在进行",
664
+ stale: "设置已更新,请刷新后重试",
665
+ workspaceChanged: "工作区或分支已变更,请刷新后重试提交",
666
+ settingsUnavailable: "无法读取提交设置,请重新加载插件后重试",
667
+ invalidRequest: "提交请求无效,请刷新后重试",
505
668
  planUnavailable: "模型没有给出可用的提交方案,已取消本次提交,工作区没有被改动。换一个模型重试,或在插件设置里开启兜底提交。",
506
669
  close: "关闭"
507
670
  };
@@ -520,6 +683,12 @@ const en = {
520
683
  loading: "Reading repository status…",
521
684
  running: "Organizing changes into commits…",
522
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",
523
692
  fallback: "Model unavailable; used a single commit",
524
693
  noWorkspace: "No workspace changes",
525
694
  unavailable: "Unavailable",
@@ -530,23 +699,31 @@ const en = {
530
699
  sessionRunning: "Session is running; commits paused",
531
700
  refresh: "Refresh",
532
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",
533
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.",
534
709
  close: "Close"
535
710
  };
536
711
  /** A failed RPC keeps its code so the panel can explain a rejection in its own words. */
537
712
  var RpcError = class extends Error {
538
713
  code;
539
- constructor(code, message) {
714
+ partial;
715
+ constructor(code, message, partial) {
540
716
  super(message);
541
717
  this.name = "RpcError";
542
718
  this.code = code;
719
+ this.partial = partial;
543
720
  }
544
721
  };
545
- async function rpc(call, endpoint, payload = {}, fallback = "Operation failed") {
722
+ async function rpc(call, endpoint, payload = {}, fallback = "") {
546
723
  const result = await call(endpoint, payload);
547
724
  if (!result || result.ok !== true) {
548
725
  const failed = result && "error" in result ? result.error : void 0;
549
- 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);
550
727
  }
551
728
  return result.value;
552
729
  }
@@ -561,7 +738,25 @@ function reasonText(t, status) {
561
738
  }
562
739
  /** A refusal the plugin can name gets its own sentence; the rest stay verbatim. */
563
740
  function errorText(t, error) {
564
- 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");
565
760
  }
566
761
  function panelError(reason) {
567
762
  return reason instanceof RpcError ? {
@@ -660,7 +855,7 @@ function GitCommitAction({ sessionId, useSession, call, t }) {
660
855
  setLoading(true);
661
856
  setError(null);
662
857
  try {
663
- const next = await rpc(call, "status", { sessionId }, t("failed"));
858
+ const next = await rpc(call, "status", { sessionId });
664
859
  if (generationRef.current !== generation) return;
665
860
  setStatus(next);
666
861
  } catch (reason) {
@@ -670,11 +865,7 @@ function GitCommitAction({ sessionId, useSession, call, t }) {
670
865
  } finally {
671
866
  if (generationRef.current === generation) setLoading(false);
672
867
  }
673
- }, [
674
- call,
675
- sessionId,
676
- t
677
- ]);
868
+ }, [call, sessionId]);
678
869
  (0, react.useEffect)(() => {
679
870
  if (!open) return;
680
871
  loadStatus();
@@ -697,23 +888,23 @@ function GitCommitAction({ sessionId, useSession, call, t }) {
697
888
  setResult(null);
698
889
  setError(null);
699
890
  try {
700
- const value = await rpc(call, "commit", { sessionId }, t("failed"));
891
+ const value = await rpc(call, "commit", { sessionId });
701
892
  if (generationRef.current !== generation) return;
702
893
  setResult(value);
703
- const next = await rpc(call, "status", { sessionId }).catch(() => void 0);
704
- if (generationRef.current !== generation) return;
705
- if (next) setStatus(next);
706
894
  } catch (reason) {
707
895
  if (generationRef.current !== generation) return;
896
+ if (reason instanceof RpcError && reason.partial) setResult(reason.partial);
708
897
  setError(panelError(reason));
709
898
  } finally {
710
- 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
+ }
711
906
  }
712
- }, [
713
- call,
714
- sessionId,
715
- t
716
- ]);
907
+ }, [call, sessionId]);
717
908
  const available = status?.available === true;
718
909
  const workspaceCount = status?.workspace.files ?? 0;
719
910
  const canCommit = available && !running && !sessionRunning && workspaceCount > 0;
@@ -861,23 +1052,48 @@ function GitCommitAction({ sessionId, useSession, call, t }) {
861
1052
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("p", {
862
1053
  className: "dsh-ui-hint dsh-ui-wrap",
863
1054
  children: [
864
- t("done"),
1055
+ t("failure" in result ? result.commits.length ? "partial" : "stopped" : "done"),
865
1056
  " · ",
866
1057
  result.branch
867
1058
  ]
868
1059
  }),
869
1060
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("ul", {
870
1061
  className: "gcm-commits",
871
- children: result.commits.map((commit) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("li", { children: [
872
- /* @__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") }),
873
1064
  " ",
874
- commit.message
875
- ] }, 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}`))
876
1071
  }),
877
1072
  result.fallback && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
878
1073
  className: "dsh-ui-hint",
879
1074
  children: t("fallback")
880
- })
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
+ })] })
881
1097
  ]
882
1098
  })
883
1099
  ]
@@ -904,10 +1120,10 @@ const clientCss = `${officialUiCss("gcm-root")}
904
1120
  .gcm-settings { display: flex; flex-direction: column; gap: 12px; min-width: 0; }
905
1121
  .gcm-settings-status { display: flex; flex-direction: column; gap: 4px; }
906
1122
  `;
907
- /** 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. */
908
1124
  function useHostLocale(client) {
909
1125
  const subscribe = (0, react.useCallback)((listener) => client.locale.subscribe?.(listener) ?? (() => {}), [client]);
910
- const snapshot = (0, react.useCallback)(() => client.locale.getSnapshot?.().active ?? "zh", [client]);
1126
+ const snapshot = (0, react.useCallback)(() => client.locale.getSnapshot?.().active ?? "en", [client]);
911
1127
  return (0, react.useSyncExternalStore)(subscribe, snapshot, snapshot);
912
1128
  }
913
1129
  const settingsZh = {
@@ -922,7 +1138,9 @@ const settingsZh = {
922
1138
  saved: "已保存。",
923
1139
  stale: "设置已更新,请刷新后重试。",
924
1140
  failed: "无法读取提交设置。",
925
- retry: "重试"
1141
+ retry: "重试",
1142
+ refresh: "刷新设置",
1143
+ syncUnavailable: "自动刷新不可用,请手动刷新设置。"
926
1144
  };
927
1145
  const settingsEn = {
928
1146
  pageTitle: "Git Commit",
@@ -936,7 +1154,9 @@ const settingsEn = {
936
1154
  saved: "Saved.",
937
1155
  stale: "Settings changed; refresh and retry.",
938
1156
  failed: "Could not load commit settings.",
939
- retry: "Retry"
1157
+ retry: "Retry",
1158
+ refresh: "Refresh settings",
1159
+ syncUnavailable: "Automatic refresh unavailable; refresh settings manually."
940
1160
  };
941
1161
  function settingsCopy(locale) {
942
1162
  return String(locale).startsWith("zh") ? settingsZh : settingsEn;
@@ -948,23 +1168,77 @@ function GitCommitSettingsPanel(props) {
948
1168
  const [settings, setSettings] = (0, react.useState)(void 0);
949
1169
  const [choices, setChoices] = (0, react.useState)([]);
950
1170
  const [error, setError] = (0, react.useState)("");
951
- const [note, setNote] = (0, react.useState)("");
1171
+ const [note, setNote] = (0, react.useState)(false);
952
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)(() => {});
953
1182
  const call = (0, react.useCallback)((endpoint, payload = {}) => props.client.connection.rpc.call(RPC_CHANNEL, endpoint, payload), [props.client]);
954
- (0, react.useEffect)(() => {
955
- 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;
956
1194
  rpc(call, "settings", {}, text.failed).then((next) => {
957
- if (live) {
1195
+ if (state.active && state.epoch === epoch && !state.pending) {
958
1196
  setSettings(next);
959
1197
  setError("");
960
1198
  }
961
1199
  }).catch(() => {
962
- 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);
963
1236
  });
964
1237
  return () => {
965
1238
  live = false;
1239
+ stop?.();
966
1240
  };
967
- }, [call, text.failed]);
1241
+ }, [props.subscribeSettings, props.client]);
968
1242
  const boundProvider = settings?.model.provider ?? "";
969
1243
  const boundModel = settings?.model.model ?? "";
970
1244
  (0, react.useEffect)(() => {
@@ -987,9 +1261,12 @@ function GitCommitSettingsPanel(props) {
987
1261
  boundModel
988
1262
  ]);
989
1263
  const save = (0, react.useCallback)((patch) => {
990
- 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;
991
1268
  setBusy(true);
992
- setNote("");
1269
+ setNote(false);
993
1270
  setError("");
994
1271
  rpc(call, "settings.update", {
995
1272
  settings: {
@@ -998,12 +1275,17 @@ function GitCommitSettingsPanel(props) {
998
1275
  },
999
1276
  expectedRevision: settings.revision
1000
1277
  }, text.stale).then((next) => {
1001
- setSettings(next);
1002
- setNote(text.saved);
1003
- setBusy(false);
1278
+ if (state.active && state.epoch === epoch) {
1279
+ setSettings(next);
1280
+ setNote(true);
1281
+ }
1004
1282
  }).catch(() => {
1005
- 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;
1006
1287
  setBusy(false);
1288
+ if (state.pending) refreshRef.current();
1007
1289
  });
1008
1290
  }, [
1009
1291
  call,
@@ -1011,17 +1293,31 @@ function GitCommitSettingsPanel(props) {
1011
1293
  text.saved,
1012
1294
  text.stale
1013
1295
  ]);
1014
- if (!settings) return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("section", {
1296
+ if (!settings) return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
1015
1297
  className: "gcm-root gcm-settings",
1016
- children: error ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1017
- role: "alert",
1018
- className: "dsh-ui-error",
1019
- children: error
1020
- }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1021
- role: "status",
1022
- className: "dsh-ui-hint",
1023
- children: text.loading
1024
- })
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
+ ]
1025
1321
  });
1026
1322
  const current = settings.model;
1027
1323
  const selected = choices.find((item) => item.provider === current.provider && item.model === current.model);
@@ -1032,15 +1328,30 @@ function GitCommitSettingsPanel(props) {
1032
1328
  children: [
1033
1329
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1034
1330
  className: "gcm-settings-status",
1035
- children: [error && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1036
- role: "alert",
1037
- className: "dsh-ui-error",
1038
- children: error
1039
- }), note && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1040
- role: "status",
1041
- className: "dsh-ui-notice",
1042
- children: note
1043
- })]
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
+ ]
1044
1355
  }),
1045
1356
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
1046
1357
  className: "dsh-ui-field",
@@ -1049,7 +1360,7 @@ function GitCommitSettingsPanel(props) {
1049
1360
  children: text.model
1050
1361
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("select", {
1051
1362
  className: "dsh-ui-select",
1052
- value: modelMenuChoiceKey(current.provider, current.model),
1363
+ value: modelRouteKey(current.provider, current.model),
1053
1364
  disabled: busy,
1054
1365
  onChange: (event) => {
1055
1366
  const key = event.target.value;
@@ -1060,16 +1371,16 @@ function GitCommitSettingsPanel(props) {
1060
1371
  } });
1061
1372
  return;
1062
1373
  }
1063
- const parsed = parseModelMenuChoiceKey(key);
1374
+ const parsed = parseModelRouteKey(key);
1064
1375
  if (parsed) save({ model: parsed });
1065
1376
  },
1066
1377
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
1067
1378
  value: "",
1068
1379
  children: text.modelDefault
1069
1380
  }), choices.map((choice) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
1070
- value: modelMenuChoiceKey(choice.provider, choice.model),
1381
+ value: modelRouteKey(choice.provider, choice.model),
1071
1382
  children: choice.label
1072
- }, modelMenuChoiceKey(choice.provider, choice.model)))]
1383
+ }, modelRouteKey(choice.provider, choice.model)))]
1073
1384
  })]
1074
1385
  }),
1075
1386
  selected && efforts.length > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
@@ -1124,6 +1435,8 @@ function GitCommitSettingsPanel(props) {
1124
1435
  }
1125
1436
  function apply(ctx) {
1126
1437
  const client = ctx;
1438
+ const settingsSubscriber = createModelSettingsSubscriber(ctx, PLUGIN_NAME);
1439
+ const subscribeSettings = (change, error) => settingsSubscriber.subscribe(change, error);
1127
1440
  ctx.provide(GIT_COMMIT_CLIENT_SERVICE, { active: true });
1128
1441
  ctx.effect(() => client.locale.register(NS, {
1129
1442
  zh: { ...zh },
@@ -1140,7 +1453,10 @@ function apply(ctx) {
1140
1453
  ctx.effect(() => client.slots.inject("plugins.bundle.config", () => client.slots.register({
1141
1454
  name: "plugins.bundle.config",
1142
1455
  key: PLUGIN_NAME
1143
- }, () => /* @__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");
1144
1460
  ctx.effect(() => client.slots.inject("conversation.session.header.actions", () => client.slots.register({
1145
1461
  name: "conversation.session.header.actions",
1146
1462
  id: "git-commit",