dsh-skill-hub 0.3.11 → 0.3.13

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/lib/index.js CHANGED
@@ -108,10 +108,45 @@ function isProjectSource(source) {
108
108
  * 错误文案收敛:宿主各处把 unknown 异常转成一行可读文字。原先这条表达式
109
109
  * 在宿主侧内联了 22 次(`error instanceof Error ? error.message : String(error)`),
110
110
  * 现在统一走这里。浏览器半有自己的 `helpers.errorMessage`,两半互不引用。
111
+ *
112
+ * Error 的 `cause` 链(undici 的 `fetch failed`、AggregateError 的多地址
113
+ * 失败、TLS 证书错误等)以括号附录带出,避免只剩笼统的顶层 message。
111
114
  */
112
- /** unknown 异常取出可读文案;非 Error 值用 String 兜底。 */
115
+ /** cause 链最大展开深度,防自引用/超深链。 */
116
+ const MAX_CAUSE_DEPTH = 4;
117
+ /** 合成一条 cause 细节;code 已在 message 里则不重复。 */
118
+ function causeDetail(message, code) {
119
+ const text = message.trim();
120
+ if (code === "") return text;
121
+ if (text === "") return "[" + code + "]";
122
+ return text.includes(code) ? text : text + " [" + code + "]";
123
+ }
124
+ /** 深度受限地收集 cause 链细节(AggregateError 展开其 errors)。 */
125
+ function collectCauseDetails(cause, depth, seen, out) {
126
+ if (depth > MAX_CAUSE_DEPTH || cause === null || cause === void 0 || seen.has(cause)) return;
127
+ seen.add(cause);
128
+ if (cause instanceof Error) {
129
+ const errors = cause.errors;
130
+ if (Array.isArray(errors)) for (const item of errors) collectCauseDetails(item, depth + 1, seen, out);
131
+ const rawCode = cause.code;
132
+ const code = typeof rawCode === "string" ? rawCode : "";
133
+ const detail = causeDetail(cause.message, code);
134
+ if (detail !== "") out.push(detail);
135
+ collectCauseDetails(cause.cause, depth + 1, seen, out);
136
+ return;
137
+ }
138
+ const detail = causeDetail(String(cause), "");
139
+ if (detail !== "") out.push(detail);
140
+ }
141
+ /** 从 unknown 异常取出可读文案;非 Error 值用 String 兜底,cause 附在括号里。 */
113
142
  function errorText(error) {
114
- return error instanceof Error ? error.message : String(error);
143
+ if (!(error instanceof Error)) return String(error);
144
+ const details = [];
145
+ collectCauseDetails(error.cause, 1, /* @__PURE__ */ new Set(), details);
146
+ for (let index = details.length - 1; index >= 0; index -= 1) if (details[index] === error.message || details.indexOf(details[index]) !== index) details.splice(index, 1);
147
+ if (details.length === 0) return error.message;
148
+ const suffix = details.join("; ");
149
+ return error.message === "" ? suffix : error.message + " (" + suffix + ")";
115
150
  }
116
151
  //#endregion
117
152
  //#region src/store/paths.ts
@@ -1088,6 +1123,39 @@ async function scanRoot(base) {
1088
1123
  function listSkillEntries(root, home = dshHome()) {
1089
1124
  return scanRoot(rootPath(root, home));
1090
1125
  }
1126
+ /**
1127
+ * Scan one skills root for hub-disabled discovery files: directory bundles
1128
+ * renamed to SKILL.md.disabled and flat <name>.md.disabled files. Used by
1129
+ * the startup reconcile to rebuild sidecar records that were lost, which
1130
+ * would otherwise leave the skill invisible in every view.
1131
+ */
1132
+ async function scanDisabledRoot(base) {
1133
+ const paths = [];
1134
+ let names;
1135
+ try {
1136
+ names = await readdir(base);
1137
+ } catch (error) {
1138
+ if (error.code === "ENOENT") return paths;
1139
+ throw error;
1140
+ }
1141
+ for (const name of names) {
1142
+ if (name.startsWith(".")) continue;
1143
+ const absolute = join(base, name);
1144
+ let stats;
1145
+ try {
1146
+ stats = await stat(absolute);
1147
+ } catch {
1148
+ continue;
1149
+ }
1150
+ if (stats.isDirectory()) {
1151
+ const candidate = join(absolute, "SKILL.md.disabled");
1152
+ try {
1153
+ if ((await stat(candidate)).isFile()) paths.push(candidate);
1154
+ } catch {}
1155
+ } else if (name.endsWith(".md.disabled") && name !== "SKILL.md.disabled") paths.push(absolute);
1156
+ }
1157
+ return paths.sort((a, b) => a < b ? -1 : a > b ? 1 : 0);
1158
+ }
1091
1159
  /** Read UI metadata from `agents/openai.yaml` beside a directory skill (mirrors codex SkillInterface). */
1092
1160
  async function readSkillInterface(directory) {
1093
1161
  const yamlPath = join(directory, "agents", "openai.yaml");
@@ -4366,6 +4434,62 @@ function createSkillStatsReader(query, ttlMs = 3e5, options = {}) {
4366
4434
  return reader;
4367
4435
  }
4368
4436
  //#endregion
4437
+ //#region src/reconcile.ts
4438
+ /**
4439
+ * Sidecar reconcile for hub-disabled skills.
4440
+ *
4441
+ * The catalog merges two sources: enabled skills come from the provider
4442
+ * (SKILL.md discovery), disabled skills come from the sidecar's `disabled`
4443
+ * records. The two can drift — a sidecar restored from a backup, a hand
4444
+ * edit, or an older build can leave a `SKILL.md.disabled` file on disk with
4445
+ * no record, which makes the skill invisible in every view (it is neither
4446
+ * enabled nor disabled) and leaves its origin collection rendering as an
4447
+ * empty shell. This walk rebuilds the missing records from disk at startup.
4448
+ */
4449
+ /**
4450
+ * Add sidecar disabled records for every `.disabled` discovery file that has
4451
+ * none yet. Existing records (matched by path or by name) win, so this never
4452
+ * rewrites user data; unreadable or invalid files are skipped (they surface
4453
+ * in the diagnostics scan instead). Returns the records added.
4454
+ */
4455
+ async function reconcileDisabledSkills(store, home = dshHome()) {
4456
+ const known = await store.listDisabled();
4457
+ const knownNames = new Set(known.map((entry) => entry.name));
4458
+ const knownPaths = new Set(known.map((entry) => entry.path));
4459
+ const added = [];
4460
+ for (const root of WRITABLE_ROOTS) for (const path of await scanDisabledRoot(rootPath(root, home))) {
4461
+ if (knownPaths.has(path)) continue;
4462
+ let text;
4463
+ try {
4464
+ text = await readFile(path, "utf8");
4465
+ } catch {
4466
+ continue;
4467
+ }
4468
+ const parsed = parseFrontmatter(text);
4469
+ if ("error" in parsed) continue;
4470
+ const { name, description } = parsed.value;
4471
+ if (knownNames.has(name)) continue;
4472
+ let disabledAt = 0;
4473
+ try {
4474
+ disabledAt = (await stat(path)).mtimeMs;
4475
+ } catch {
4476
+ continue;
4477
+ }
4478
+ const record = {
4479
+ name,
4480
+ description,
4481
+ path,
4482
+ root,
4483
+ disabledAt
4484
+ };
4485
+ await store.addDisabled(record);
4486
+ knownNames.add(name);
4487
+ knownPaths.add(path);
4488
+ added.push(record);
4489
+ }
4490
+ return added;
4491
+ }
4492
+ //#endregion
4369
4493
  //#region src/index.ts
4370
4494
  /** Stable cordis plugin name (matches cordis.patch.yml insert id). */
4371
4495
  const name = "skill-hub";
@@ -4492,6 +4616,12 @@ function apply(ctx, config) {
4492
4616
  } catch (error) {
4493
4617
  ctx.logger.warn("[dsh-skill-hub] startup cleanup failed", error);
4494
4618
  }
4619
+ try {
4620
+ const reconciled = await reconcileDisabledSkills(store, home);
4621
+ if (reconciled.length > 0) ctx.logger.info(`[dsh-skill-hub] startup reconciled ${reconciled.length} disabled skill record(s): ${reconciled.map((entry) => entry.name).join(", ")}`);
4622
+ } catch (error) {
4623
+ ctx.logger.warn("[dsh-skill-hub] startup disabled-skill reconcile failed", error);
4624
+ }
4495
4625
  })();
4496
4626
  (async () => {
4497
4627
  try {
@@ -12,7 +12,7 @@
12
12
  * Closing a group whose member is enabled in another group is a conflict the
13
13
  * GUI resolves with a dialog; the helpers below compute both sides.
14
14
  */
15
- import type { CatalogSkill, CollectionGroup, SkillTag } from '../protocol.ts';
15
+ import type { CatalogSkill, CollectionGroup, DisabledSkill, SkillTag } from '../protocol.ts';
16
16
  import { isProjectSource } from '../protocol.ts';
17
17
  export { isProjectSource };
18
18
  /** Grouped switch state derived from member enablement. */
@@ -50,6 +50,23 @@ export declare const PRIVATE_SOURCE = "private";
50
50
  * 项目级技能(有 workspace 归属)永远不算「个人」。
51
51
  */
52
52
  export declare function filterBySource(skills: readonly CatalogSkill[], source: string, origins: Readonly<Record<string, string>>): CatalogSkill[];
53
+ /** One origin collection with the members visible under the current filters. */
54
+ export interface VisibleCollection {
55
+ collection: CollectionGroup;
56
+ /** Enabled, currently visible members. */
57
+ skills: CatalogSkill[];
58
+ /** Disabled records passing the current name/description filter. */
59
+ disabledMembers: DisabledSkill[];
60
+ }
61
+ /**
62
+ * Match origin collections against the currently visible enabled skills and
63
+ * disabled records, dropping collections with no visible member. Without
64
+ * this, a stale origin (skill deleted on disk, or a `.disabled` file whose
65
+ * sidecar record was lost) renders a group header with zero rows — an empty
66
+ * shell the sources tab otherwise never shows (project/personal groups
67
+ * already disappear when they have nothing to display).
68
+ */
69
+ export declare function visibleCollections(collections: readonly CollectionGroup[], visibleSkills: readonly CatalogSkill[], disabledRecords: readonly DisabledSkill[], normalized: string, sourceFilter: string, origins: Readonly<Record<string, string>>): VisibleCollection[];
53
70
  /** Catalog sort keys offered by the filter bar. */
54
71
  export type SortKey = 'name' | 'added' | 'uses';
55
72
  /**
@@ -2,6 +2,9 @@
2
2
  * 错误文案收敛:宿主各处把 unknown 异常转成一行可读文字。原先这条表达式
3
3
  * 在宿主侧内联了 22 次(`error instanceof Error ? error.message : String(error)`),
4
4
  * 现在统一走这里。浏览器半有自己的 `helpers.errorMessage`,两半互不引用。
5
+ *
6
+ * Error 的 `cause` 链(undici 的 `fetch failed`、AggregateError 的多地址
7
+ * 失败、TLS 证书错误等)以括号附录带出,避免只剩笼统的顶层 message。
5
8
  */
6
- /** 从 unknown 异常取出可读文案;非 Error 值用 String 兜底。 */
9
+ /** 从 unknown 异常取出可读文案;非 Error 值用 String 兜底,cause 附在括号里。 */
7
10
  export declare function errorText(error: unknown): string;
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Sidecar reconcile for hub-disabled skills.
3
+ *
4
+ * The catalog merges two sources: enabled skills come from the provider
5
+ * (SKILL.md discovery), disabled skills come from the sidecar's `disabled`
6
+ * records. The two can drift — a sidecar restored from a backup, a hand
7
+ * edit, or an older build can leave a `SKILL.md.disabled` file on disk with
8
+ * no record, which makes the skill invisible in every view (it is neither
9
+ * enabled nor disabled) and leaves its origin collection rendering as an
10
+ * empty shell. This walk rebuilds the missing records from disk at startup.
11
+ */
12
+ import type { DisabledSkill } from './protocol.ts';
13
+ /** Narrow store view used by the reconcile (SkillHubStore satisfies it). */
14
+ export interface DisabledReconcileStore {
15
+ listDisabled(): Promise<DisabledSkill[]>;
16
+ addDisabled(entry: DisabledSkill): Promise<void>;
17
+ }
18
+ /**
19
+ * Add sidecar disabled records for every `.disabled` discovery file that has
20
+ * none yet. Existing records (matched by path or by name) win, so this never
21
+ * rewrites user data; unreadable or invalid files are skipped (they surface
22
+ * in the diagnostics scan instead). Returns the records added.
23
+ */
24
+ export declare function reconcileDisabledSkills(store: DisabledReconcileStore, home?: string): Promise<DisabledSkill[]>;
@@ -15,6 +15,13 @@ export interface SkillEntry {
15
15
  export declare function scanRoot(base: string): Promise<SkillEntry[]>;
16
16
  /** Scan one writable root. */
17
17
  export declare function listSkillEntries(root: WritableRoot, home?: string): Promise<SkillEntry[]>;
18
+ /**
19
+ * Scan one skills root for hub-disabled discovery files: directory bundles
20
+ * renamed to SKILL.md.disabled and flat <name>.md.disabled files. Used by
21
+ * the startup reconcile to rebuild sidecar records that were lost, which
22
+ * would otherwise leave the skill invisible in every view.
23
+ */
24
+ export declare function scanDisabledRoot(base: string): Promise<string[]>;
18
25
  /** UI metadata from `agents/openai.yaml` beside a directory skill (mirrors codex SkillInterface). */
19
26
  export interface SkillInterface {
20
27
  displayName?: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-skill-hub",
3
- "version": "0.3.11",
3
+ "version": "0.3.13",
4
4
  "description": "In-GUI skill hub for DeepSeek Harness (dsh): browse the full local skill catalog from the official ctx.skills registry (every root + third-party providers), toggle skills on/off, inspect bodies, surface frontmatter diagnostics, and scaffold new skills — plus a codex-style skill market (built-in catalog, upstream update checks, one-click update-all) with tracked source sync. The full manager beyond the read-only dsh-skill-manager browser.",
5
5
  "type": "module",
6
6
  "engines": {
@@ -69,7 +69,11 @@
69
69
  "0.1.2-alpha.5",
70
70
  "0.1.2-rc.1",
71
71
  "0.1.3-alpha.2",
72
- "0.1.5-alpha.1"
72
+ "0.1.5-alpha.1",
73
+ "0.1.5-alpha.2",
74
+ "0.1.5-rc.1",
75
+ "0.1.5-rc.2",
76
+ "0.1.6-alpha.1"
73
77
  ]
74
78
  },
75
79
  "capability": {
@@ -91,19 +95,19 @@
91
95
  },
92
96
  "peerDependencies": {
93
97
  "@deepseek-ai/cordis": "^4.0.1 || ^4.0.2",
94
- "@deepseek-ai/dsh-client-connection": ">=0.1.0-rc.7 <0.2.0-0 || >=0.1.1-rc.2 <0.2.0-0 || >=0.1.2-alpha.2 <0.2.0-0 || >=0.1.2-alpha.3 <0.2.0-0 || >=0.1.2-alpha.4 <0.2.0-0 || >=0.1.2-alpha.5 <0.2.0-0 || >=0.1.2-rc.1 <0.2.0-0 || >=0.1.3-alpha.2 <0.2.0-0 || >=0.1.5-alpha.1 <0.2.0-0",
95
- "@deepseek-ai/dsh-client-locale": ">=0.1.0-rc.7 <0.2.0-0 || >=0.1.1-rc.2 <0.2.0-0 || >=0.1.2-alpha.2 <0.2.0-0 || >=0.1.2-alpha.3 <0.2.0-0 || >=0.1.2-alpha.4 <0.2.0-0 || >=0.1.2-alpha.5 <0.2.0-0 || >=0.1.2-rc.1 <0.2.0-0 || >=0.1.3-alpha.2 <0.2.0-0 || >=0.1.5-alpha.1 <0.2.0-0",
96
- "@deepseek-ai/dsh-client-runtime": ">=0.1.0-rc.7 <0.2.0-0 || >=0.1.1-rc.2 <0.2.0-0 || >=0.1.2-alpha.3 <0.2.0-0 || >=0.1.2-alpha.4 <0.2.0-0 || >=0.1.2-alpha.5 <0.2.0-0 || >=0.1.3-alpha.2 <0.2.0-0 || >=0.1.5-alpha.1 <0.2.0-0",
97
- "@deepseek-ai/dsh-client-store": ">=0.1.2-alpha.2 <0.2.0-0 || >=0.1.2-alpha.3 <0.2.0-0 || >=0.1.2-alpha.4 <0.2.0-0 || >=0.1.2-alpha.5 <0.2.0-0 || >=0.1.2-rc.1 <0.2.0-0 || >=0.1.3-alpha.2 <0.2.0-0 || >=0.1.5-alpha.1 <0.2.0-0",
98
- "@deepseek-ai/dsh-client-ui-input-trigger": ">=0.1.0-rc.7 <0.2.0-0 || >=0.1.1-rc.2 <0.2.0-0 || >=0.1.2-alpha.2 <0.2.0-0 || >=0.1.2-alpha.3 <0.2.0-0 || >=0.1.2-alpha.4 <0.2.0-0 || >=0.1.2-alpha.5 <0.2.0-0 || >=0.1.2-rc.1 <0.2.0-0 || >=0.1.3-alpha.2 <0.2.0-0 || >=0.1.5-alpha.1 <0.2.0-0",
99
- "@deepseek-ai/dsh-client-ui-settings": ">=0.1.0-rc.7 <0.2.0-0 || >=0.1.1-rc.2 <0.2.0-0 || >=0.1.2-alpha.2 <0.2.0-0 || >=0.1.2-alpha.3 <0.2.0-0 || >=0.1.2-alpha.4 <0.2.0-0 || >=0.1.2-alpha.5 <0.2.0-0 || >=0.1.2-rc.1 <0.2.0-0 || >=0.1.3-alpha.2 <0.2.0-0 || >=0.1.5-alpha.1 <0.2.0-0",
100
- "@deepseek-ai/dsh-client-ui-slots": ">=0.1.0-rc.7 <0.2.0-0 || >=0.1.1-rc.2 <0.2.0-0 || >=0.1.2-alpha.2 <0.2.0-0 || >=0.1.2-alpha.3 <0.2.0-0 || >=0.1.2-alpha.4 <0.2.0-0 || >=0.1.2-alpha.5 <0.2.0-0 || >=0.1.2-rc.1 <0.2.0-0 || >=0.1.3-alpha.2 <0.2.0-0 || >=0.1.5-alpha.1 <0.2.0-0",
101
- "@deepseek-ai/dsh-host-webserver": ">=0.1.0-rc.7 <0.2.0-0 || >=0.1.1-rc.2 <0.2.0-0 || >=0.1.2-alpha.2 <0.2.0-0 || >=0.1.2-alpha.3 <0.2.0-0 || >=0.1.2-alpha.4 <0.2.0-0 || >=0.1.2-alpha.5 <0.2.0-0 || >=0.1.2-rc.1 <0.2.0-0 || >=0.1.3-alpha.2 <0.2.0-0 || >=0.1.5-alpha.1 <0.2.0-0",
102
- "@deepseek-ai/dsh-session": ">=0.1.0-rc.7 <0.2.0-0 || >=0.1.1-rc.2 <0.2.0-0 || >=0.1.2-alpha.2 <0.2.0-0 || >=0.1.2-alpha.3 <0.2.0-0 || >=0.1.2-alpha.4 <0.2.0-0 || >=0.1.2-alpha.5 <0.2.0-0 || >=0.1.2-rc.1 <0.2.0-0 || >=0.1.3-alpha.2 <0.2.0-0 || >=0.1.5-alpha.1 <0.2.0-0",
103
- "@deepseek-ai/dsh-session-query": ">=0.1.0-rc.7 <0.2.0-0 || >=0.1.1-rc.2 <0.2.0-0 || >=0.1.2-alpha.2 <0.2.0-0 || >=0.1.2-alpha.3 <0.2.0-0 || >=0.1.2-alpha.4 <0.2.0-0 || >=0.1.2-alpha.5 <0.2.0-0 || >=0.1.2-rc.1 <0.2.0-0 || >=0.1.3-alpha.2 <0.2.0-0 || >=0.1.5-alpha.1 <0.2.0-0",
104
- "@deepseek-ai/dsh-settings": ">=0.1.0-rc.7 <0.2.0-0 || >=0.1.1-rc.2 <0.2.0-0 || >=0.1.2-alpha.2 <0.2.0-0 || >=0.1.2-alpha.3 <0.2.0-0 || >=0.1.2-alpha.4 <0.2.0-0 || >=0.1.2-alpha.5 <0.2.0-0 || >=0.1.2-rc.1 <0.2.0-0 || >=0.1.3-alpha.2 <0.2.0-0 || >=0.1.5-alpha.1 <0.2.0-0",
105
- "@deepseek-ai/dsh-skill": ">=0.1.0-rc.7 <0.2.0-0 || >=0.1.1-rc.2 <0.2.0-0 || >=0.1.2-alpha.2 <0.2.0-0 || >=0.1.2-alpha.3 <0.2.0-0 || >=0.1.2-alpha.4 <0.2.0-0 || >=0.1.2-alpha.5 <0.2.0-0 || >=0.1.2-rc.1 <0.2.0-0 || >=0.1.3-alpha.2 <0.2.0-0 || >=0.1.5-alpha.1 <0.2.0-0",
106
- "@deepseek-ai/dsh-system-prompt": ">=0.1.0-rc.7 <0.2.0-0 || >=0.1.1-rc.2 <0.2.0-0 || >=0.1.2-alpha.2 <0.2.0-0 || >=0.1.2-alpha.3 <0.2.0-0 || >=0.1.2-alpha.4 <0.2.0-0 || >=0.1.2-alpha.5 <0.2.0-0 || >=0.1.2-rc.1 <0.2.0-0 || >=0.1.3-alpha.2 <0.2.0-0 || >=0.1.5-alpha.1 <0.2.0-0",
98
+ "@deepseek-ai/dsh-client-connection": ">=0.1.0-rc.7 <0.2.0-0 || >=0.1.1-rc.2 <0.2.0-0 || >=0.1.2-alpha.2 <0.2.0-0 || >=0.1.2-alpha.3 <0.2.0-0 || >=0.1.2-alpha.4 <0.2.0-0 || >=0.1.2-alpha.5 <0.2.0-0 || >=0.1.2-rc.1 <0.2.0-0 || >=0.1.3-alpha.2 <0.2.0-0 || >=0.1.5-alpha.1 <0.2.0-0 || >=0.1.5-alpha.2 <0.2.0-0 || >=0.1.5-rc.1 <0.2.0-0 || >=0.1.5-rc.2 <0.2.0-0 || >=0.1.6-alpha.1 <0.2.0-0",
99
+ "@deepseek-ai/dsh-client-locale": ">=0.1.0-rc.7 <0.2.0-0 || >=0.1.1-rc.2 <0.2.0-0 || >=0.1.2-alpha.2 <0.2.0-0 || >=0.1.2-alpha.3 <0.2.0-0 || >=0.1.2-alpha.4 <0.2.0-0 || >=0.1.2-alpha.5 <0.2.0-0 || >=0.1.2-rc.1 <0.2.0-0 || >=0.1.3-alpha.2 <0.2.0-0 || >=0.1.5-alpha.1 <0.2.0-0 || >=0.1.5-alpha.2 <0.2.0-0 || >=0.1.5-rc.1 <0.2.0-0 || >=0.1.5-rc.2 <0.2.0-0 || >=0.1.6-alpha.1 <0.2.0-0",
100
+ "@deepseek-ai/dsh-client-runtime": ">=0.1.0-rc.7 <0.2.0-0 || >=0.1.1-rc.2 <0.2.0-0 || >=0.1.2-alpha.3 <0.2.0-0 || >=0.1.2-alpha.4 <0.2.0-0 || >=0.1.2-alpha.5 <0.2.0-0 || >=0.1.3-alpha.2 <0.2.0-0 || >=0.1.5-alpha.1 <0.2.0-0 || >=0.1.5-alpha.2 <0.2.0-0 || >=0.1.5-rc.1 <0.2.0-0 || >=0.1.5-rc.2 <0.2.0-0 || >=0.1.6-alpha.1 <0.2.0-0",
101
+ "@deepseek-ai/dsh-client-store": ">=0.1.2-alpha.2 <0.2.0-0 || >=0.1.2-alpha.3 <0.2.0-0 || >=0.1.2-alpha.4 <0.2.0-0 || >=0.1.2-alpha.5 <0.2.0-0 || >=0.1.2-rc.1 <0.2.0-0 || >=0.1.3-alpha.2 <0.2.0-0 || >=0.1.5-alpha.1 <0.2.0-0 || >=0.1.5-alpha.2 <0.2.0-0 || >=0.1.5-rc.1 <0.2.0-0 || >=0.1.5-rc.2 <0.2.0-0 || >=0.1.6-alpha.1 <0.2.0-0",
102
+ "@deepseek-ai/dsh-client-ui-input-trigger": ">=0.1.0-rc.7 <0.2.0-0 || >=0.1.1-rc.2 <0.2.0-0 || >=0.1.2-alpha.2 <0.2.0-0 || >=0.1.2-alpha.3 <0.2.0-0 || >=0.1.2-alpha.4 <0.2.0-0 || >=0.1.2-alpha.5 <0.2.0-0 || >=0.1.2-rc.1 <0.2.0-0 || >=0.1.3-alpha.2 <0.2.0-0 || >=0.1.5-alpha.1 <0.2.0-0 || >=0.1.5-alpha.2 <0.2.0-0 || >=0.1.5-rc.1 <0.2.0-0 || >=0.1.5-rc.2 <0.2.0-0 || >=0.1.6-alpha.1 <0.2.0-0",
103
+ "@deepseek-ai/dsh-client-ui-settings": ">=0.1.0-rc.7 <0.2.0-0 || >=0.1.1-rc.2 <0.2.0-0 || >=0.1.2-alpha.2 <0.2.0-0 || >=0.1.2-alpha.3 <0.2.0-0 || >=0.1.2-alpha.4 <0.2.0-0 || >=0.1.2-alpha.5 <0.2.0-0 || >=0.1.2-rc.1 <0.2.0-0 || >=0.1.3-alpha.2 <0.2.0-0 || >=0.1.5-alpha.1 <0.2.0-0 || >=0.1.5-alpha.2 <0.2.0-0 || >=0.1.5-rc.1 <0.2.0-0 || >=0.1.5-rc.2 <0.2.0-0 || >=0.1.6-alpha.1 <0.2.0-0",
104
+ "@deepseek-ai/dsh-client-ui-slots": ">=0.1.0-rc.7 <0.2.0-0 || >=0.1.1-rc.2 <0.2.0-0 || >=0.1.2-alpha.2 <0.2.0-0 || >=0.1.2-alpha.3 <0.2.0-0 || >=0.1.2-alpha.4 <0.2.0-0 || >=0.1.2-alpha.5 <0.2.0-0 || >=0.1.2-rc.1 <0.2.0-0 || >=0.1.3-alpha.2 <0.2.0-0 || >=0.1.5-alpha.1 <0.2.0-0 || >=0.1.5-alpha.2 <0.2.0-0 || >=0.1.5-rc.1 <0.2.0-0 || >=0.1.5-rc.2 <0.2.0-0 || >=0.1.6-alpha.1 <0.2.0-0",
105
+ "@deepseek-ai/dsh-host-webserver": ">=0.1.0-rc.7 <0.2.0-0 || >=0.1.1-rc.2 <0.2.0-0 || >=0.1.2-alpha.2 <0.2.0-0 || >=0.1.2-alpha.3 <0.2.0-0 || >=0.1.2-alpha.4 <0.2.0-0 || >=0.1.2-alpha.5 <0.2.0-0 || >=0.1.2-rc.1 <0.2.0-0 || >=0.1.3-alpha.2 <0.2.0-0 || >=0.1.5-alpha.1 <0.2.0-0 || >=0.1.5-alpha.2 <0.2.0-0 || >=0.1.5-rc.1 <0.2.0-0 || >=0.1.5-rc.2 <0.2.0-0 || >=0.1.6-alpha.1 <0.2.0-0",
106
+ "@deepseek-ai/dsh-session": ">=0.1.0-rc.7 <0.2.0-0 || >=0.1.1-rc.2 <0.2.0-0 || >=0.1.2-alpha.2 <0.2.0-0 || >=0.1.2-alpha.3 <0.2.0-0 || >=0.1.2-alpha.4 <0.2.0-0 || >=0.1.2-alpha.5 <0.2.0-0 || >=0.1.2-rc.1 <0.2.0-0 || >=0.1.3-alpha.2 <0.2.0-0 || >=0.1.5-alpha.1 <0.2.0-0 || >=0.1.5-alpha.2 <0.2.0-0 || >=0.1.5-rc.1 <0.2.0-0 || >=0.1.5-rc.2 <0.2.0-0 || >=0.1.6-alpha.1 <0.2.0-0",
107
+ "@deepseek-ai/dsh-session-query": ">=0.1.0-rc.7 <0.2.0-0 || >=0.1.1-rc.2 <0.2.0-0 || >=0.1.2-alpha.2 <0.2.0-0 || >=0.1.2-alpha.3 <0.2.0-0 || >=0.1.2-alpha.4 <0.2.0-0 || >=0.1.2-alpha.5 <0.2.0-0 || >=0.1.2-rc.1 <0.2.0-0 || >=0.1.3-alpha.2 <0.2.0-0 || >=0.1.5-alpha.1 <0.2.0-0 || >=0.1.5-alpha.2 <0.2.0-0 || >=0.1.5-rc.1 <0.2.0-0 || >=0.1.5-rc.2 <0.2.0-0 || >=0.1.6-alpha.1 <0.2.0-0",
108
+ "@deepseek-ai/dsh-settings": ">=0.1.0-rc.7 <0.2.0-0 || >=0.1.1-rc.2 <0.2.0-0 || >=0.1.2-alpha.2 <0.2.0-0 || >=0.1.2-alpha.3 <0.2.0-0 || >=0.1.2-alpha.4 <0.2.0-0 || >=0.1.2-alpha.5 <0.2.0-0 || >=0.1.2-rc.1 <0.2.0-0 || >=0.1.3-alpha.2 <0.2.0-0 || >=0.1.5-alpha.1 <0.2.0-0 || >=0.1.5-alpha.2 <0.2.0-0 || >=0.1.5-rc.1 <0.2.0-0 || >=0.1.5-rc.2 <0.2.0-0 || >=0.1.6-alpha.1 <0.2.0-0",
109
+ "@deepseek-ai/dsh-skill": ">=0.1.0-rc.7 <0.2.0-0 || >=0.1.1-rc.2 <0.2.0-0 || >=0.1.2-alpha.2 <0.2.0-0 || >=0.1.2-alpha.3 <0.2.0-0 || >=0.1.2-alpha.4 <0.2.0-0 || >=0.1.2-alpha.5 <0.2.0-0 || >=0.1.2-rc.1 <0.2.0-0 || >=0.1.3-alpha.2 <0.2.0-0 || >=0.1.5-alpha.1 <0.2.0-0 || >=0.1.5-alpha.2 <0.2.0-0 || >=0.1.5-rc.1 <0.2.0-0 || >=0.1.5-rc.2 <0.2.0-0 || >=0.1.6-alpha.1 <0.2.0-0",
110
+ "@deepseek-ai/dsh-system-prompt": ">=0.1.0-rc.7 <0.2.0-0 || >=0.1.1-rc.2 <0.2.0-0 || >=0.1.2-alpha.2 <0.2.0-0 || >=0.1.2-alpha.3 <0.2.0-0 || >=0.1.2-alpha.4 <0.2.0-0 || >=0.1.2-alpha.5 <0.2.0-0 || >=0.1.2-rc.1 <0.2.0-0 || >=0.1.3-alpha.2 <0.2.0-0 || >=0.1.5-alpha.1 <0.2.0-0 || >=0.1.5-alpha.2 <0.2.0-0 || >=0.1.5-rc.1 <0.2.0-0 || >=0.1.5-rc.2 <0.2.0-0 || >=0.1.6-alpha.1 <0.2.0-0",
107
111
  "react": "^18.2.0",
108
112
  "react-dom": "^18.2.0"
109
113
  },
@@ -117,30 +121,32 @@
117
121
  },
118
122
  "devDependencies": {
119
123
  "@deepseek-ai/cordis": "^4.0.2",
120
- "@deepseek-ai/dsh-client-connection": "^0.1.5-alpha.1",
121
- "@deepseek-ai/dsh-client-locale": "^0.1.5-alpha.1",
122
- "@deepseek-ai/dsh-client-store": "^0.1.5-alpha.1",
123
- "@deepseek-ai/dsh-client-ui-input-trigger": "^0.1.5-alpha.1",
124
- "@deepseek-ai/dsh-client-ui-settings": "^0.1.5-alpha.1",
125
- "@deepseek-ai/dsh-client-ui-settings-plugins": "^0.1.5-alpha.1",
126
- "@deepseek-ai/dsh-client-ui-slots": "^0.1.5-alpha.1",
127
- "@deepseek-ai/dsh-host-webserver": "^0.1.5-alpha.1",
128
- "@deepseek-ai/dsh-session": "^0.1.5-alpha.1",
129
- "@deepseek-ai/dsh-session-query": "^0.1.5-alpha.1",
130
- "@deepseek-ai/dsh-settings": "^0.1.5-alpha.1",
131
- "@deepseek-ai/dsh-skill": "^0.1.5-alpha.1",
132
- "@deepseek-ai/dsh-system-prompt": "^0.1.5-alpha.1",
124
+ "@deepseek-ai/dsh-client-connection": "^0.1.6-alpha.1",
125
+ "@deepseek-ai/dsh-client-locale": "^0.1.6-alpha.1",
126
+ "@deepseek-ai/dsh-client-store": "^0.1.6-alpha.1",
127
+ "@deepseek-ai/dsh-client-ui-input-trigger": "^0.1.6-alpha.1",
128
+ "@deepseek-ai/dsh-client-ui-settings": "^0.1.6-alpha.1",
129
+ "@deepseek-ai/dsh-client-ui-settings-plugins": "^0.1.6-alpha.1",
130
+ "@deepseek-ai/dsh-client-ui-slots": "^0.1.6-alpha.1",
131
+ "@deepseek-ai/dsh-host-webserver": "^0.1.6-alpha.1",
132
+ "@deepseek-ai/dsh-session": "^0.1.6-alpha.1",
133
+ "@deepseek-ai/dsh-session-query": "^0.1.6-alpha.1",
134
+ "@deepseek-ai/dsh-settings": "^0.1.6-alpha.1",
135
+ "@deepseek-ai/dsh-skill": "^0.1.6-alpha.1",
136
+ "@deepseek-ai/dsh-system-prompt": "^0.1.6-alpha.1",
133
137
  "@types/js-yaml": "^4.0.9",
134
138
  "@types/node": "^22.20.0",
135
139
  "@types/react": "~18.3.1",
136
140
  "@types/react-dom": "^18.3.5",
141
+ "immer": "^10.2.0",
137
142
  "lightningcss": "1.32.0",
138
143
  "react": "^18.3.1",
139
144
  "react-dom": "^18.3.1",
140
145
  "schemastery": "^3.18.0",
141
146
  "tsdown": "0.22.2",
142
147
  "typescript": "~5.7.2",
143
- "vitest": "^3.0.0"
148
+ "vitest": "^3.0.0",
149
+ "zustand": "~4.4.7"
144
150
  },
145
151
  "files": [
146
152
  "lib/**/*.js",
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, it } from 'vitest'
2
- import type { CatalogSkill, SkillTag } from '../protocol.ts'
3
- import { conflictsOnClose, filterBySource, formatRelativeTime, groupNamesOf, groupSwitchView, PRIVATE_SOURCE, sortSkills } from './grouping.ts'
2
+ import type { CatalogSkill, CollectionGroup, DisabledSkill, SkillTag } from '../protocol.ts'
3
+ import { conflictsOnClose, filterBySource, formatRelativeTime, groupNamesOf, groupSwitchView, PRIVATE_SOURCE, sortSkills, visibleCollections } from './grouping.ts'
4
4
 
5
5
  function skill(name: string, writable = true): CatalogSkill {
6
6
  return {
@@ -75,6 +75,39 @@ describe('filterBySource', () => {
75
75
  })
76
76
  })
77
77
 
78
+ describe('visibleCollections', () => {
79
+ const collections: CollectionGroup[] = [
80
+ { name: 'repo/x', skillNames: ['enabled-one', 'disabled-one'] },
81
+ { name: 'repo/ghost', skillNames: ['gone'] },
82
+ ]
83
+ const disabledOne: DisabledSkill = { name: 'disabled-one', description: 'Paused skill', path: '/x/disabled-one/SKILL.md.disabled', root: 'user-dsh', disabledAt: 1 }
84
+ const origins = { 'enabled-one': 'repo/x', 'disabled-one': 'repo/x' }
85
+
86
+ it('keeps collections with visible members and drops empty shells', () => {
87
+ const visible = visibleCollections(collections, [skill('enabled-one')], [disabledOne], '', 'all', origins)
88
+ expect(visible.map((entry) => entry.collection.name)).toEqual(['repo/x'])
89
+ expect(visible[0].skills.map((s) => s.name)).toEqual(['enabled-one'])
90
+ expect(visible[0].disabledMembers.map((d) => d.name)).toEqual(['disabled-one'])
91
+ })
92
+
93
+ it('drops a collection when neither side passes the search filter', () => {
94
+ expect(visibleCollections(collections, [], [disabledOne], 'no-match', 'all', origins)).toEqual([])
95
+ expect(visibleCollections(collections, [], [disabledOne], 'paused', 'all', origins).map((e) => e.collection.name)).toEqual(['repo/x'])
96
+ })
97
+
98
+ it('applies the source filter to disabled-only collections', () => {
99
+ expect(visibleCollections(collections, [], [disabledOne], '', 'repo/other', origins)).toEqual([])
100
+ expect(visibleCollections(collections, [], [disabledOne], '', 'private', origins)).toEqual([])
101
+ expect(visibleCollections(collections, [], [disabledOne], '', 'repo/x', origins).map((e) => e.collection.name)).toEqual(['repo/x'])
102
+ })
103
+
104
+ it('treats a disabled record with no origin as private', () => {
105
+ const privateRecord = { ...disabledOne, name: 'private-one' }
106
+ const privateCollection: CollectionGroup = { name: 'repo/y', skillNames: ['private-one'] }
107
+ expect(visibleCollections([privateCollection], [], [privateRecord], '', PRIVATE_SOURCE, {}).map((e) => e.collection.name)).toEqual(['repo/y'])
108
+ })
109
+ })
110
+
78
111
  describe('sortSkills', () => {
79
112
  it('sorts by name ascending', () => {
80
113
  const skills = [skill('zeta'), skill('alpha'), skill('beta')]
@@ -13,7 +13,7 @@
13
13
  * GUI resolves with a dialog; the helpers below compute both sides.
14
14
  */
15
15
 
16
- import type { CatalogSkill, CollectionGroup, SkillTag } from '../protocol.ts'
16
+ import type { CatalogSkill, CollectionGroup, DisabledSkill, SkillTag } from '../protocol.ts'
17
17
  import { isProjectSource } from '../protocol.ts'
18
18
 
19
19
  export { isProjectSource }
@@ -87,6 +87,43 @@ export function filterBySource(skills: readonly CatalogSkill[], source: string,
87
87
  })
88
88
  }
89
89
 
90
+ /** One origin collection with the members visible under the current filters. */
91
+ export interface VisibleCollection {
92
+ collection: CollectionGroup
93
+ /** Enabled, currently visible members. */
94
+ skills: CatalogSkill[]
95
+ /** Disabled records passing the current name/description filter. */
96
+ disabledMembers: DisabledSkill[]
97
+ }
98
+
99
+ /**
100
+ * Match origin collections against the currently visible enabled skills and
101
+ * disabled records, dropping collections with no visible member. Without
102
+ * this, a stale origin (skill deleted on disk, or a `.disabled` file whose
103
+ * sidecar record was lost) renders a group header with zero rows — an empty
104
+ * shell the sources tab otherwise never shows (project/personal groups
105
+ * already disappear when they have nothing to display).
106
+ */
107
+ export function visibleCollections(
108
+ collections: readonly CollectionGroup[],
109
+ visibleSkills: readonly CatalogSkill[],
110
+ disabledRecords: readonly DisabledSkill[],
111
+ normalized: string,
112
+ sourceFilter: string,
113
+ origins: Readonly<Record<string, string>>,
114
+ ): VisibleCollection[] {
115
+ const visible: VisibleCollection[] = []
116
+ for (const collection of collections) {
117
+ const skills = visibleSkills.filter((skill) => collection.skillNames.includes(skill.name))
118
+ const disabledMembers = disabledRecords.filter((record) =>
119
+ collection.skillNames.includes(record.name)
120
+ && (normalized.length === 0 || record.name.toLocaleLowerCase().includes(normalized) || record.description.toLocaleLowerCase().includes(normalized))
121
+ && (sourceFilter === 'all' || (origins[record.name] ?? PRIVATE_SOURCE) === sourceFilter))
122
+ if (skills.length > 0 || disabledMembers.length > 0) visible.push({ collection, skills, disabledMembers })
123
+ }
124
+ return visible
125
+ }
126
+
90
127
  /** Catalog sort keys offered by the filter bar. */
91
128
  export type SortKey = 'name' | 'added' | 'uses'
92
129
 
@@ -101,8 +101,14 @@ export function PanelDialogs(props: PanelDialogsProps): JSX.Element {
101
101
  <VersionChoiceDialog
102
102
  choice={versionDialog}
103
103
  busy={versionBusy}
104
- onSelect={(selected) => { setVersionDialog({ ...versionDialog, selected }) }}
105
- onCustom={(custom) => { setVersionDialog({ ...versionDialog, custom }) }}
104
+ // Keep the selected ref and custom override in one functional
105
+ // update, so a ref selection cannot be lost to a stale snapshot.
106
+ onSelect={(selected) => {
107
+ setVersionDialog((previous) => previous === null ? previous : { ...previous, selected, custom: '' })
108
+ }}
109
+ onCustom={(custom) => {
110
+ setVersionDialog((previous) => previous === null ? previous : { ...previous, custom })
111
+ }}
106
112
  onCancel={() => { setVersionDialog(null) }}
107
113
  onConfirm={() => { void confirmVersionDialog() }}
108
114
  />
@@ -8,7 +8,7 @@
8
8
 
9
9
  import { useMemo, useState, type JSX } from 'react'
10
10
  import { tt } from '../helpers.ts'
11
- import { filterBySource, groupSwitchView, isProjectSource, PRIVATE_SOURCE } from '../grouping.ts'
11
+ import { filterBySource, groupSwitchView, isProjectSource, PRIVATE_SOURCE, visibleCollections } from '../grouping.ts'
12
12
  import { SkillRow } from './SkillRow.tsx'
13
13
  import { DisabledRow } from './DisabledRow.tsx'
14
14
  import { GroupSummary } from './GroupSummary.tsx'
@@ -28,10 +28,14 @@ export function SourcesView(props: { hub: SkillHubState }): JSX.Element {
28
28
  const duplicateNames = useMemo(() => new Set(catalog?.duplicateNames ?? []), [catalog])
29
29
 
30
30
  // ----- 顶层分组统一拖拽(project / col:xxx / personal 全部可拖) -----
31
- const projectSkillsAll = filterBySource(sorted, sourceFilter, origins).filter((skill) => isProjectSource(skill.source))
31
+ const sourceFiltered = filterBySource(sorted, sourceFilter, origins)
32
+ const projectSkillsAll = sourceFiltered.filter((skill) => isProjectSource(skill.source))
32
33
  const hasProject = projectSkillsAll.length > 0
33
- const collections = groupsState?.collections ?? []
34
- const uncategorized = filterBySource(sorted, sourceFilter, origins).filter((skill) => origins[skill.name] === undefined && !isProjectSource(skill.source))
34
+ // 无可见成员的来源组不渲染:来源记录指向的技能可能已被删除,或禁用记录
35
+ // 丢失导致技能既非启用也非禁用,留下一个组头有数字、展开 0 行的空壳。
36
+ const visible = visibleCollections(groupsState?.collections ?? [], sourceFiltered, catalog?.disabled ?? [], normalized, sourceFilter, origins)
37
+ const collections = visible.map((entry) => entry.collection)
38
+ const uncategorized = sourceFiltered.filter((skill) => origins[skill.name] === undefined && !isProjectSource(skill.source))
35
39
  const personalDisabled = (catalog?.disabled ?? []).filter((record) => origins[record.name] === undefined)
36
40
  .filter((record) => normalized.length === 0 || record.name.toLocaleLowerCase().includes(normalized) || record.description.toLocaleLowerCase().includes(normalized))
37
41
  .filter((record) => sourceFilter === 'all' || sourceFilter === PRIVATE_SOURCE)
@@ -79,7 +83,7 @@ export function SourcesView(props: { hub: SkillHubState }): JSX.Element {
79
83
  const rowProps = { uses: hub.uses, hubConfig: hub.hubConfig, busyNames, editMode: hub.editMode, tagBusy: hub.tagBusy, duplicateNames, toggle: hub.toggle, openDetail: hub.openDetail, requestDeleteSkill: hub.requestDeleteSkill }
80
84
 
81
85
  if (skillView === 'flat') {
82
- return <>{filterBySource(sorted, sourceFilter, origins).map((skill) => <SkillRow key={skill.name} skill={skill} {...rowProps} />)}</>
86
+ return <>{sourceFiltered.map((skill) => <SkillRow key={skill.name} skill={skill} {...rowProps} />)}</>
83
87
  }
84
88
 
85
89
  // 空状态:没有任何分组时提示
@@ -117,13 +121,9 @@ export function SourcesView(props: { hub: SkillHubState }): JSX.Element {
117
121
  // Collection 卡片(可拖,归属顶层排序)
118
122
  if (topKey.startsWith('col:')) {
119
123
  const colName = topKey.slice(4)
120
- const collection = collections.find((c) => c.name === colName)
121
- if (collection === undefined) return null
122
- const skills = filterBySource(sorted, sourceFilter, origins).filter((skill) => collection.skillNames.includes(skill.name))
123
- const disabledMembers = (catalog?.disabled ?? []).filter((record) =>
124
- collection.skillNames.includes(record.name)
125
- && (normalized.length === 0 || record.name.toLocaleLowerCase().includes(normalized) || record.description.toLocaleLowerCase().includes(normalized))
126
- && (sourceFilter === 'all' || (origins[record.name] ?? PRIVATE_SOURCE) === sourceFilter))
124
+ const entry = visible.find((item) => item.collection.name === colName)
125
+ if (entry === undefined) return null
126
+ const { collection, skills, disabledMembers } = entry
127
127
  const collapsed = collapsedGroups.has('col:' + collection.name)
128
128
  const view = groupSwitchView(collection.skillNames, viewNames)
129
129
  const check = sourceCheck[collection.name]
@@ -5,7 +5,7 @@
5
5
  * the same role/aria shell; the panel owns all dialog state.
6
6
  */
7
7
 
8
- import { useEffect, type JSX, type ReactNode } from 'react'
8
+ import { useEffect, useState, type JSX, type ReactNode } from 'react'
9
9
  import type { CollectionGroup, SkillTag } from '../../protocol.ts'
10
10
  import { tt } from '../helpers.ts'
11
11
  import { groupNamesOf } from '../grouping.ts'
@@ -170,29 +170,56 @@ export function VersionChoiceDialog(props: {
170
170
  }): JSX.Element {
171
171
  const { choice, busy, onSelect, onCustom, onCancel, onConfirm } = props
172
172
  const effective = choice.custom.trim() !== '' ? choice.custom.trim() : choice.selected
173
+ type RefGroup = 'releases' | 'branches'
174
+ const branchOptions = choice.branches.filter((branch) => !choice.releases.includes(branch))
175
+ const [refGroup, setRefGroup] = useState<RefGroup>(
176
+ choice.releases.includes(choice.selected) || choice.branches.length === 0 ? 'releases' : 'branches',
177
+ )
178
+ // The dialog starts in a loading state and receives the release/branch
179
+ // lists in a later render. Align the group once that data arrives, without
180
+ // resetting it after the user selects another ref.
181
+ useEffect(() => {
182
+ if (choice.loading) return
183
+ if (choice.releases.includes(choice.selected)) setRefGroup('releases')
184
+ else if (branchOptions.includes(choice.selected)) setRefGroup('branches')
185
+ }, [choice.loading, choice.repo])
186
+ const activeRefs = refGroup === 'releases' ? choice.releases : branchOptions
187
+ const listedRefs = [...choice.releases, ...branchOptions]
188
+ const currentUnlistedRef = choice.selected !== '' && !listedRefs.includes(choice.selected) ? choice.selected : undefined
189
+ const selectRefs = currentUnlistedRef !== undefined ? [currentUnlistedRef, ...activeRefs] : activeRefs
190
+ const selectValue = selectRefs.includes(choice.selected) ? choice.selected : selectRefs[0] ?? ''
191
+ const selectGroup = (next: RefGroup): void => {
192
+ setRefGroup(next)
193
+ const nextRefs = next === 'releases' ? choice.releases : branchOptions
194
+ if (nextRefs.length > 0 && !nextRefs.includes(choice.selected)) onSelect(nextRefs[0])
195
+ }
173
196
  return (
174
197
  <DialogShell onClose={onCancel}>
175
198
  <h3 className={css.dialogTitle}>{tt('market.versionTitle')}</h3>
176
199
  <p className={css.dialogText}>{tt('market.versionText', { repo: choice.repo })}{choice.current !== undefined ? ` (${tt('market.versionCurrent', { ref: choice.current })})` : ''}</p>
177
200
  {choice.loading ? <p className={css.dialogText}>{tt('market.scanning')}</p> : (
178
201
  <>
179
- {choice.releases.length > 0 ? (
180
- <>
181
- <p className={css.dialogText} style={{ marginBottom: 4 }}>{tt('market.versionReleases')}</p>
182
- <select className={css.select + ' ' + css.dialogSelect} value={choice.releases.includes(choice.selected) ? choice.selected : choice.releases[0]}
183
- onChange={(event) => { onSelect(event.target.value); onCustom('') }}>
184
- {choice.releases.map((tag) => <option key={tag} value={tag}>{tag}</option>)}
185
- </select>
186
- </>
187
- ) : null}
188
- {choice.branches.length > 0 ? (
189
- <>
190
- <p className={css.dialogText} style={{ marginBottom: 4 }}>{tt('market.versionBranches')}</p>
191
- <select className={css.select + ' ' + css.dialogSelect} value={choice.branches.includes(choice.selected) ? choice.selected : choice.branches[0]}
192
- onChange={(event) => { onSelect(event.target.value); onCustom('') }}>
193
- {choice.branches.map((branch) => <option key={branch} value={branch}>{branch}</option>)}
194
- </select>
195
- </>
202
+ <div className={css.segmented} role='group' aria-label={tt('market.versionTitle')} style={{ marginBottom: 8 }}>
203
+ <button
204
+ type='button'
205
+ className={css.segBtn + (refGroup === 'releases' ? ' ' + css.segBtnActive : '')}
206
+ disabled={choice.releases.length === 0}
207
+ aria-pressed={refGroup === 'releases'}
208
+ onClick={() => { selectGroup('releases') }}
209
+ >{tt('market.versionReleases')}</button>
210
+ <button
211
+ type='button'
212
+ className={css.segBtn + (refGroup === 'branches' ? ' ' + css.segBtnActive : '')}
213
+ disabled={branchOptions.length === 0}
214
+ aria-pressed={refGroup === 'branches'}
215
+ onClick={() => { selectGroup('branches') }}
216
+ >{tt('market.versionBranches')}</button>
217
+ </div>
218
+ {selectValue !== '' ? (
219
+ <select className={css.select + ' ' + css.dialogSelect} value={selectValue}
220
+ onChange={(event) => { onSelect(event.target.value) }}>
221
+ {selectRefs.map((ref) => <option key={ref} value={ref}>{ref}</option>)}
222
+ </select>
196
223
  ) : null}
197
224
  <p className={css.dialogText} style={{ marginBottom: 4 }}>{tt('market.versionCustom')}</p>
198
225
  <input className={css.input + ' ' + css.dialogSelect} value={choice.custom}