dsh-skill-hub 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/README.md +146 -33
  2. package/lib/client.js +3477 -436
  3. package/lib/client.js.map +1 -1
  4. package/lib/index.js +2408 -366
  5. package/lib/types/client/SkillHubSettingsCard.d.ts +5 -0
  6. package/lib/types/client/api-config-scope.d.ts +6 -0
  7. package/lib/types/client/api.d.ts +44 -2
  8. package/lib/types/client/grouping.d.ts +64 -0
  9. package/lib/types/client/locales.d.ts +155 -18
  10. package/lib/types/client/market-catalog.d.ts +14 -0
  11. package/lib/types/client/panel/DisabledRow.d.ts +12 -0
  12. package/lib/types/client/panel/GroupSummary.d.ts +10 -0
  13. package/lib/types/client/panel/MarketView.d.ts +11 -0
  14. package/lib/types/client/panel/ScenesView.d.ts +11 -0
  15. package/lib/types/client/panel/SkillDetailView.d.ts +31 -0
  16. package/lib/types/client/panel/SkillHubPanel.d.ts +11 -3
  17. package/lib/types/client/panel/SkillRow.d.ts +11 -0
  18. package/lib/types/client/panel/SourceStatusBadge.d.ts +18 -0
  19. package/lib/types/client/panel/SourcesView.d.ts +10 -0
  20. package/lib/types/client/panel/TagEditorView.d.ts +29 -0
  21. package/lib/types/client/panel/dialogs.d.ts +73 -0
  22. package/lib/types/client/panel/format.d.ts +14 -0
  23. package/lib/types/client/panel/useSkillHub.d.ts +183 -0
  24. package/lib/types/client/settings-card.d.ts +21 -4
  25. package/lib/types/client/settings-form.d.ts +2 -0
  26. package/lib/types/client/settings-nav-icon.d.ts +8 -0
  27. package/lib/types/index.d.ts +6 -0
  28. package/lib/types/protocol.d.ts +428 -7
  29. package/lib/types/provider.d.ts +6 -1
  30. package/lib/types/repo.d.ts +133 -0
  31. package/lib/types/routes.d.ts +9 -4
  32. package/lib/types/skillfs.d.ts +19 -14
  33. package/lib/types/stats.d.ts +17 -4
  34. package/lib/types/store.d.ts +112 -6
  35. package/lib/types/update.d.ts +20 -0
  36. package/package.json +5 -3
  37. package/src/client/SkillHubSettingsCard.tsx +61 -8
  38. package/src/client/api-config-scope.test.ts +79 -0
  39. package/src/client/api-config-scope.ts +28 -4
  40. package/src/client/api.ts +239 -9
  41. package/src/client/grouping.test.ts +109 -0
  42. package/src/client/grouping.ts +120 -0
  43. package/src/client/index.tsx +5 -2
  44. package/src/client/locales.ts +314 -40
  45. package/src/client/market-catalog.ts +22 -0
  46. package/src/client/panel/DisabledRow.tsx +32 -0
  47. package/src/client/panel/GroupSummary.tsx +27 -0
  48. package/src/client/panel/MarketView.tsx +173 -0
  49. package/src/client/panel/ScenesView.tsx +75 -0
  50. package/src/client/panel/SkillDetailView.tsx +95 -0
  51. package/src/client/panel/SkillHubPanel.tsx +251 -257
  52. package/src/client/panel/SkillRow.tsx +54 -0
  53. package/src/client/panel/SourceStatusBadge.tsx +61 -0
  54. package/src/client/panel/SourcesView.tsx +104 -0
  55. package/src/client/panel/TagEditorView.tsx +89 -0
  56. package/src/client/panel/dialogs.tsx +185 -0
  57. package/src/client/panel/format.ts +31 -0
  58. package/src/client/panel/panel.module.css +209 -45
  59. package/src/client/panel/useSkillHub.ts +797 -0
  60. package/src/client/settings-card.module.css +44 -28
  61. package/src/client/settings-card.tsx +64 -8
  62. package/src/client/settings-form.ts +12 -0
  63. package/src/client/settings-nav-icon.ts +42 -0
  64. package/src/index.ts +19 -15
  65. package/src/protocol.ts +460 -7
  66. package/src/provider.test.ts +2 -4
  67. package/src/provider.ts +18 -14
  68. package/src/repo.test.ts +212 -0
  69. package/src/repo.ts +528 -0
  70. package/src/routes.test.ts +564 -52
  71. package/src/routes.ts +999 -153
  72. package/src/skillfs.test.ts +98 -16
  73. package/src/skillfs.ts +68 -33
  74. package/src/stats.test.ts +52 -17
  75. package/src/stats.ts +55 -18
  76. package/src/store.test.ts +205 -1
  77. package/src/store.ts +527 -24
  78. package/src/update.test.ts +73 -0
  79. package/src/update.ts +136 -0
package/src/client/api.ts CHANGED
@@ -9,9 +9,33 @@ import {
9
9
  type ConfigResponse,
10
10
  type CreateRequest,
11
11
  type CreateResponse,
12
+ type MarketCheckResponse,
13
+ type MarketSourceRefRequest,
14
+ type MarketSourceRequest,
15
+ type MarketSourceResponse,
16
+ type MarketSourcesResponse,
17
+ type MarketSyncResponse,
18
+ type RepoDiscoverResponse,
19
+ type RepoImportRequest,
20
+ type RepoImportResponse,
12
21
  type SkillDetail,
13
22
  type SkillDetailResponse,
23
+ type SkillDeleteRequest,
24
+ type SkillDeleteResponse,
25
+ type SourceCheckRequest,
26
+ type SourceCheckResponse,
27
+ type SourceDeleteRequest,
28
+ type SourceDeleteResponse,
29
+ type SourceRestoreRequest,
30
+ type SourceRestoreResponse,
31
+ type SourceTrashClearResponse,
32
+ type SourcesResponse,
33
+ type SourceSyncRequest,
34
+ type SourceSyncResponse,
14
35
  type StatsResponse,
36
+ type ToggleBatchRequest,
37
+ type ToggleBatchResponse,
38
+ type UpdateCheckResponse,
15
39
  } from '../protocol.ts'
16
40
 
17
41
  /** Error carrying the route's JSON error message. */
@@ -39,23 +63,48 @@ async function readJson<T>(response: Response): Promise<T> {
39
63
  return body as T
40
64
  }
41
65
 
66
+ /**
67
+ * Fetch with a hard timeout: a hung connection (proxy, stalled keep-alive,
68
+ * host wedged) must never leave the panel stuck on "loading" forever — it
69
+ * fails fast and surfaces the error banner instead.
70
+ */
71
+ async function fetchWithTimeout(input: RequestInfo | URL, init?: RequestInit, ms = 10_000): Promise<Response> {
72
+ const controller = new AbortController()
73
+ const timer = setTimeout(() => controller.abort(), ms)
74
+ try {
75
+ return await fetch(input, { ...init, signal: controller.signal })
76
+ } finally {
77
+ clearTimeout(timer)
78
+ }
79
+ }
80
+
42
81
  /** The browser half's only data entry point. */
43
82
  export class SkillHubApi {
44
83
  async catalog(): Promise<CatalogResponse> {
45
- const response = await fetch(SKILL_HUB_API.catalog)
84
+ const response = await fetchWithTimeout(SKILL_HUB_API.catalog)
46
85
  return readJson<CatalogResponse>(response)
47
86
  }
48
87
 
49
88
  async skill(name: string): Promise<SkillDetail> {
50
89
  const url = SKILL_HUB_API.skill + '?name=' + encodeURIComponent(name)
51
- const response = await fetch(url)
90
+ const response = await fetchWithTimeout(url)
52
91
  const body = await readJson<SkillDetailResponse>(response)
53
92
  return body.skill
54
93
  }
55
94
 
56
- /** Toggle a skill; resolves with the fresh catalog from the route. */
95
+ /** Move one writable skill into the restorable trash. */
96
+ async deleteSkill(name: string): Promise<SkillDeleteResponse> {
97
+ const response = await fetchWithTimeout(SKILL_HUB_API.skillDelete, {
98
+ method: 'POST',
99
+ headers: { 'content-type': 'application/json' },
100
+ body: JSON.stringify({ name } satisfies SkillDeleteRequest),
101
+ })
102
+ return readJson<SkillDeleteResponse>(response)
103
+ }
104
+
105
+ /** Toggle one skill; resolves with the fresh catalog from the route. */
57
106
  async toggle(name: string, enabled: boolean): Promise<CatalogResponse> {
58
- const response = await fetch(SKILL_HUB_API.toggle, {
107
+ const response = await fetchWithTimeout(SKILL_HUB_API.toggle, {
59
108
  method: 'POST',
60
109
  headers: { 'content-type': 'application/json' },
61
110
  body: JSON.stringify({ name, enabled } satisfies import('../protocol.ts').ToggleRequest),
@@ -65,7 +114,7 @@ export class SkillHubApi {
65
114
  }
66
115
 
67
116
  async create(payload: CreateRequest): Promise<CreateResponse> {
68
- const response = await fetch(SKILL_HUB_API.create, {
117
+ const response = await fetchWithTimeout(SKILL_HUB_API.create, {
69
118
  method: 'POST',
70
119
  headers: { 'content-type': 'application/json' },
71
120
  body: JSON.stringify(payload),
@@ -74,24 +123,205 @@ export class SkillHubApi {
74
123
  }
75
124
 
76
125
  async stats(): Promise<StatsResponse> {
77
- const response = await fetch(SKILL_HUB_API.stats)
126
+ const response = await fetchWithTimeout(SKILL_HUB_API.stats)
78
127
  return readJson<StatsResponse>(response)
79
128
  }
80
129
 
130
+ /** Toggle a whole group in one write; resolves with the fresh catalog + failures. */
131
+ async toggleBatch(names: string[], enabled: boolean): Promise<ToggleBatchResponse> {
132
+ const payload: ToggleBatchRequest = { names, enabled }
133
+ const response = await fetchWithTimeout(SKILL_HUB_API.toggleBatch, {
134
+ method: 'POST',
135
+ headers: { 'content-type': 'application/json' },
136
+ body: JSON.stringify(payload),
137
+ })
138
+ return readJson<ToggleBatchResponse>(response)
139
+ }
140
+
141
+ /** The user's added market sources (codex-style repo slugs). */
142
+ async market(): Promise<MarketSourcesResponse> {
143
+ const response = await fetchWithTimeout(SKILL_HUB_API.market)
144
+ return readJson<MarketSourcesResponse>(response)
145
+ }
146
+
147
+ /** Add a market source repo; resolves with the fresh list. */
148
+ async addMarketSource(repo: string): Promise<MarketSourcesResponse> {
149
+ const response = await fetchWithTimeout(SKILL_HUB_API.marketSource, {
150
+ method: 'POST',
151
+ headers: { 'content-type': 'application/json' },
152
+ body: JSON.stringify({ repo } satisfies MarketSourceRequest),
153
+ })
154
+ return readJson<MarketSourceResponse>(response)
155
+ }
156
+
157
+ /** Remove a market source repo; resolves with the fresh list. */
158
+ async removeMarketSource(repo: string): Promise<MarketSourcesResponse> {
159
+ const response = await fetchWithTimeout(SKILL_HUB_API.marketSourceDelete, {
160
+ method: 'POST',
161
+ headers: { 'content-type': 'application/json' },
162
+ body: JSON.stringify({ repo } satisfies MarketSourceRequest),
163
+ })
164
+ return readJson<MarketSourceResponse>(response)
165
+ }
166
+
167
+ /** Check every market source for updates (throttled server-side). */
168
+ async marketCheck(): Promise<MarketCheckResponse> {
169
+ const response = await fetchWithTimeout(SKILL_HUB_API.marketCheck)
170
+ return readJson<MarketCheckResponse>(response)
171
+ }
172
+
173
+ /** Align a market source to its pinned ref; resolves with tracked skills. */
174
+ async marketSync(repo: string): Promise<MarketSyncResponse> {
175
+ const response = await fetchWithTimeout(SKILL_HUB_API.marketSync, {
176
+ method: 'POST',
177
+ headers: { 'content-type': 'application/json' },
178
+ body: JSON.stringify({ repo }),
179
+ })
180
+ return readJson<MarketSyncResponse>(response)
181
+ }
182
+
183
+ /** Pin a market source to an explicit ref (branch picker). */
184
+ async setMarketSourceRef(repo: string, ref: string): Promise<MarketSourcesResponse> {
185
+ const response = await fetchWithTimeout(SKILL_HUB_API.marketSourceRef, {
186
+ method: 'POST',
187
+ headers: { 'content-type': 'application/json' },
188
+ body: JSON.stringify({ repo, ref } satisfies MarketSourceRefRequest),
189
+ })
190
+ return readJson<MarketSourceResponse>(response)
191
+ }
192
+
193
+ /** Check the hub's own latest GitHub release. */
194
+ async updateCheck(): Promise<UpdateCheckResponse> {
195
+ const response = await fetchWithTimeout(SKILL_HUB_API.update)
196
+ return readJson<UpdateCheckResponse>(response)
197
+ }
198
+
199
+ /** Discover importable skills in a GitHub repo. */
200
+ async repoDiscover(repo: string): Promise<RepoDiscoverResponse> {
201
+ const url = SKILL_HUB_API.repo + '?repo=' + encodeURIComponent(repo)
202
+ const response = await fetchWithTimeout(url)
203
+ return readJson<RepoDiscoverResponse>(response)
204
+ }
205
+
206
+ /** Install selected repo skills into the user-dsh root (records the source). */
207
+ async repoImport(repo: string, paths: string[], ref?: string): Promise<RepoImportResponse> {
208
+ const payload: RepoImportRequest = { repo, paths, ...(ref !== undefined && ref !== '' ? { ref } : {}) }
209
+ const response = await fetchWithTimeout(SKILL_HUB_API.repoImport, {
210
+ method: 'POST',
211
+ headers: { 'content-type': 'application/json' },
212
+ body: JSON.stringify(payload),
213
+ })
214
+ return readJson<RepoImportResponse>(response)
215
+ }
216
+
81
217
  /** Read the hub's runtime config (effective values + saved overrides). */
82
218
  async config(): Promise<ConfigResponse> {
83
- const response = await fetch(SKILL_HUB_API.config)
219
+ const response = await fetchWithTimeout(SKILL_HUB_API.config)
84
220
  return readJson<ConfigResponse>(response)
85
221
  }
86
222
 
87
223
  /** Patch the hub's runtime config (null clears a saved override). */
88
224
  async saveConfig(patch: ConfigRequest): Promise<ConfigResponse> {
89
- const response = await fetch(SKILL_HUB_API.config, {
225
+ const response = await fetchWithTimeout(SKILL_HUB_API.config, {
90
226
  method: 'POST',
91
227
  headers: { 'content-type': 'application/json' },
92
228
  body: JSON.stringify(patch),
93
229
  })
94
230
  return readJson<ConfigResponse>(response)
95
231
  }
96
- }
97
232
 
233
+ /** 用户 tag 分组 + 系统集合组 + origin 映射。 */
234
+ async groups(): Promise<import('../protocol.ts').GroupsResponse> {
235
+ const response = await fetchWithTimeout(SKILL_HUB_API.groups)
236
+ return readJson<import('../protocol.ts').GroupsResponse>(response)
237
+ }
238
+
239
+ /** 新建(无 id)或重命名(带 id)一个 tag 分组;返回全部 tag。 */
240
+ async saveTag(payload: import('../protocol.ts').TagSaveRequest): Promise<import('../protocol.ts').SkillTag[]> {
241
+ const response = await fetchWithTimeout(SKILL_HUB_API.tag, {
242
+ method: 'POST',
243
+ headers: { 'content-type': 'application/json' },
244
+ body: JSON.stringify(payload),
245
+ })
246
+ const body = await readJson<import('../protocol.ts').TagSaveResponse>(response)
247
+ return body.tags
248
+ }
249
+
250
+ /** 删除一个 tag 分组;返回全部 tag。 */
251
+ async deleteTag(id: string): Promise<import('../protocol.ts').SkillTag[]> {
252
+ const response = await fetchWithTimeout(SKILL_HUB_API.tagDelete, {
253
+ method: 'POST',
254
+ headers: { 'content-type': 'application/json' },
255
+ body: JSON.stringify({ id } satisfies import('../protocol.ts').TagDeleteRequest),
256
+ })
257
+ const body = await readJson<import('../protocol.ts').TagDeleteResponse>(response)
258
+ return body.tags
259
+ }
260
+
261
+ /** 直接设置某 tag 的完整成员列表;返回全部 tag。 */
262
+ async setTagMembers(id: string, skillNames: string[]): Promise<import('../protocol.ts').SkillTag[]> {
263
+ const response = await fetchWithTimeout(SKILL_HUB_API.tagMembers, {
264
+ method: 'POST',
265
+ headers: { 'content-type': 'application/json' },
266
+ body: JSON.stringify({ id, skillNames } satisfies import('../protocol.ts').TagMembersRequest),
267
+ })
268
+ const body = await readJson<import('../protocol.ts').TagMembersResponse>(response)
269
+ return body.tags
270
+ }
271
+
272
+ /** 来源列表 + 派生 origin 映射 + 集合组 + 回收站。 */
273
+ async sources(): Promise<SourcesResponse> {
274
+ const response = await fetchWithTimeout(SKILL_HUB_API.sources)
275
+ return readJson<SourcesResponse>(response)
276
+ }
277
+
278
+ /** 检查指定(或全部)来源的上游更新。 */
279
+ async checkSources(repo?: string): Promise<SourceCheckResponse> {
280
+ const payload: SourceCheckRequest = repo !== undefined ? { repo } : {}
281
+ const response = await fetchWithTimeout(SKILL_HUB_API.sourceCheck, {
282
+ method: 'POST',
283
+ headers: { 'content-type': 'application/json' },
284
+ body: JSON.stringify(payload),
285
+ })
286
+ return readJson<SourceCheckResponse>(response)
287
+ }
288
+
289
+ /** 同步一个来源的所选(或全部)技能到上游最新版本。 */
290
+ async syncSource(repo: string, skills?: string[]): Promise<SourceSyncResponse> {
291
+ const payload: SourceSyncRequest = skills !== undefined ? { repo, skills } : { repo }
292
+ const response = await fetchWithTimeout(SKILL_HUB_API.sourceSync, {
293
+ method: 'POST',
294
+ headers: { 'content-type': 'application/json' },
295
+ body: JSON.stringify(payload),
296
+ })
297
+ return readJson<SourceSyncResponse>(response)
298
+ }
299
+
300
+ /** 跟进上游删除:把所选技能移入回收站。 */
301
+ async confirmDeleteSource(repo: string, skills: string[]): Promise<SourceDeleteResponse> {
302
+ const payload: SourceDeleteRequest = { repo, skills }
303
+ const response = await fetchWithTimeout(SKILL_HUB_API.sourceDelete, {
304
+ method: 'POST',
305
+ headers: { 'content-type': 'application/json' },
306
+ body: JSON.stringify(payload),
307
+ })
308
+ return readJson<SourceDeleteResponse>(response)
309
+ }
310
+
311
+ /** 从回收站恢复一个技能。 */
312
+ async restoreSource(name: string): Promise<SourceRestoreResponse> {
313
+ const payload: SourceRestoreRequest = { name }
314
+ const response = await fetchWithTimeout(SKILL_HUB_API.sourceRestore, {
315
+ method: 'POST',
316
+ headers: { 'content-type': 'application/json' },
317
+ body: JSON.stringify(payload),
318
+ })
319
+ return readJson<SourceRestoreResponse>(response)
320
+ }
321
+
322
+ /** Permanently delete every skill currently in the trash. */
323
+ async clearTrash(): Promise<SourceTrashClearResponse> {
324
+ const response = await fetchWithTimeout(SKILL_HUB_API.sourceTrashClear, { method: 'POST' })
325
+ return readJson<SourceTrashClearResponse>(response)
326
+ }
327
+ }
@@ -0,0 +1,109 @@
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'
4
+
5
+ function skill(name: string, writable = true): CatalogSkill {
6
+ return {
7
+ name,
8
+ description: '',
9
+ invocation: { modelInvocable: true, userInvocable: true },
10
+ provider: 'filesystem',
11
+ writable,
12
+ }
13
+ }
14
+
15
+ describe('groupSwitchView', () => {
16
+ it('is on when every member is enabled, off when none, mixed otherwise', () => {
17
+ const enabled = new Set(['a', 'b', 'c'])
18
+ expect(groupSwitchView(['a', 'b', 'c'], enabled).state).toBe('on')
19
+ expect(groupSwitchView(['a'], new Set()).state).toBe('off')
20
+ expect(groupSwitchView(['a', 'b'], new Set(['a'])).state).toBe('mixed')
21
+ })
22
+
23
+ it('lists the enabled and disabled sides', () => {
24
+ const view = groupSwitchView(['a', 'b', 'c'], new Set(['a', 'c']))
25
+ expect(view.enabled).toEqual(['a', 'c'])
26
+ expect(view.disabled).toEqual(['b'])
27
+ })
28
+ })
29
+
30
+ describe('conflictsOnClose', () => {
31
+ it('flags enabled members that live in another group', () => {
32
+ const members = ['a', 'b', 'c']
33
+ const enabled = new Set(['a', 'b'])
34
+ const others = [{ members: ['c', 'a'] }, { members: ['x'] }]
35
+ expect(conflictsOnClose(members, enabled, others)).toEqual(['a'])
36
+ })
37
+
38
+ it('ignores disabled members and groups without the member', () => {
39
+ const members = ['a', 'b']
40
+ const enabled = new Set(['b'])
41
+ const others = [{ members: ['a'] }]
42
+ expect(conflictsOnClose(members, enabled, others)).toEqual([])
43
+ })
44
+ })
45
+
46
+ describe('groupNamesOf', () => {
47
+ it('collects tag and collection names for a skill', () => {
48
+ const tags: SkillTag[] = [{ id: '1', name: 'web', skillNames: ['a'] }]
49
+ const collections = [{ name: 'repo/x', skillNames: ['a', 'b'] }, { name: 'repo/y', skillNames: ['b'] }]
50
+ expect(groupNamesOf('a', tags, collections)).toEqual(['web', 'repo/x'])
51
+ expect(groupNamesOf('b', tags, collections)).toEqual(['repo/x', 'repo/y'])
52
+ expect(groupNamesOf('c', tags, collections)).toEqual([])
53
+ })
54
+ })
55
+
56
+ describe('filterBySource', () => {
57
+ it('filters by origin repo, buckets untracked skills as private, and returns all for "all"', () => {
58
+ const origins = { a: 'repo/x', b: 'repo/y' }
59
+ const skills = [skill('a'), skill('b'), skill('c')]
60
+ expect(filterBySource(skills, 'repo/x', origins).map((s) => s.name)).toEqual(['a'])
61
+ expect(filterBySource(skills, PRIVATE_SOURCE, origins).map((s) => s.name)).toEqual(['c'])
62
+ expect(filterBySource(skills, 'all', origins)).toHaveLength(3)
63
+ })
64
+ })
65
+
66
+ describe('sortSkills', () => {
67
+ it('sorts by name ascending', () => {
68
+ const skills = [skill('zeta'), skill('alpha'), skill('beta')]
69
+ expect(sortSkills(skills, 'name').map((s) => s.name)).toEqual(['alpha', 'beta', 'zeta'])
70
+ })
71
+
72
+ it('sorts by added time descending, unknown times last', () => {
73
+ const skills = [
74
+ { ...skill('old'), addedAt: 100 },
75
+ { ...skill('new'), addedAt: 300 },
76
+ { ...skill('unknown') },
77
+ ]
78
+ expect(sortSkills(skills, 'added').map((s) => s.name)).toEqual(['new', 'old', 'unknown'])
79
+ })
80
+
81
+ it('sorts by invocation count descending, unknown counts as zero', () => {
82
+ const skills = [skill('few'), skill('many'), skill('none')]
83
+ const uses: Record<string, number | undefined> = { few: 2, many: 9 }
84
+ expect(sortSkills(skills, 'uses', (name) => uses[name]).map((s) => s.name)).toEqual(['many', 'few', 'none'])
85
+ })
86
+
87
+ it('does not mutate the input list', () => {
88
+ const skills = [skill('b'), skill('a')]
89
+ const sorted = sortSkills(skills, 'name')
90
+ expect(sorted.map((s) => s.name)).toEqual(['a', 'b'])
91
+ expect(skills.map((s) => s.name)).toEqual(['b', 'a'])
92
+ })
93
+ })
94
+
95
+ describe('formatRelativeTime', () => {
96
+ const now = 1_700_000_000_000
97
+ it('buckets relative times correctly', () => {
98
+ expect(formatRelativeTime(now, now)).toEqual({ key: 'time.justNow' })
99
+ expect(formatRelativeTime(now - 30_000, now)).toEqual({ key: 'time.justNow' })
100
+ expect(formatRelativeTime(now - 5 * 60_000, now)).toEqual({ key: 'time.minutesAgo', value: 5 })
101
+ expect(formatRelativeTime(now - 3 * 3_600_000, now)).toEqual({ key: 'time.hoursAgo', value: 3 })
102
+ expect(formatRelativeTime(now - 2 * 86_400_000, now)).toEqual({ key: 'time.daysAgo', value: 2 })
103
+ expect(formatRelativeTime(now - 14 * 86_400_000, now)).toEqual({ key: 'time.weeksAgo', value: 2 })
104
+ })
105
+
106
+ it('clamps future timestamps to just now', () => {
107
+ expect(formatRelativeTime(now + 5_000, now)).toEqual({ key: 'time.justNow' })
108
+ })
109
+ })
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Pure grouping helpers for the skill-hub panel (no React/DOM deps, so the
3
+ * merge/classify/filter logic stays unit-testable).
4
+ *
5
+ * The "分组" view unifies two ways a skill can belong to a group:
6
+ * - user tags (sidecar SkillTag)
7
+ * - origin collections (sidecar sources → backend-aggregated CollectionGroup)
8
+ *
9
+ * Switch semantics: a skill's enabled state is global (the file is renamed
10
+ * once), so a group switch is derived from its members' actual states:
11
+ * all members enabled → 'on', all disabled → 'off', otherwise 'mixed'.
12
+ * Closing a group whose member is enabled in another group is a conflict the
13
+ * GUI resolves with a dialog; the helpers below compute both sides.
14
+ */
15
+
16
+ import type { CatalogSkill, CollectionGroup, SkillTag } from '../protocol.ts'
17
+
18
+ /** Grouped switch state derived from member enablement. */
19
+ export type GroupSwitchState = 'on' | 'off' | 'mixed'
20
+
21
+ /** The derived switch view of one group. */
22
+ export interface GroupSwitchView {
23
+ state: GroupSwitchState
24
+ /** Writable members currently enabled. */
25
+ enabled: string[]
26
+ /** Writable members currently disabled. */
27
+ disabled: string[]
28
+ }
29
+
30
+ /**
31
+ * Derive a group switch view from its member names and the set of currently
32
+ * enabled skill names (catalog.skills). Members not in the enabled set count
33
+ * as disabled (catalog.disabled or absent read-only rows).
34
+ */
35
+ export function groupSwitchView(members: readonly string[], enabledNames: ReadonlySet<string>): GroupSwitchView {
36
+ const enabled: string[] = []
37
+ const disabled: string[] = []
38
+ for (const name of members) {
39
+ if (enabledNames.has(name)) enabled.push(name)
40
+ else disabled.push(name)
41
+ }
42
+ const state: GroupSwitchState = disabled.length === 0 ? 'on' : enabled.length === 0 ? 'off' : 'mixed'
43
+ return { state, enabled, disabled }
44
+ }
45
+
46
+ /** Names of every group a skill belongs to (tags + collections). */
47
+ export function groupNamesOf(name: string, tags: readonly SkillTag[], collections: readonly CollectionGroup[]): string[] {
48
+ const names: string[] = []
49
+ for (const tag of tags) if (tag.skillNames.includes(name)) names.push(tag.name)
50
+ for (const collection of collections) if (collection.skillNames.includes(name)) names.push(collection.name)
51
+ return names
52
+ }
53
+
54
+ /**
55
+ * Members of a group that are currently enabled AND also belong to at least
56
+ * one other group — the set a "close" action must ask about.
57
+ */
58
+ export function conflictsOnClose(
59
+ members: readonly string[],
60
+ enabledNames: ReadonlySet<string>,
61
+ otherGroups: ReadonlyArray<{ members: readonly string[] }>,
62
+ ): string[] {
63
+ return members.filter((name) => {
64
+ if (!enabledNames.has(name)) return false
65
+ return otherGroups.some((group) => group.members.includes(name))
66
+ })
67
+ }
68
+
69
+ /** Origin-repo filter value: skills with no source record (private skills). */
70
+ export const PRIVATE_SOURCE = 'private'
71
+
72
+ /**
73
+ * Apply the origin filter ('all' or a specific origin repo; skills without a
74
+ * source record count as PRIVATE_SOURCE). The origins map is the store's
75
+ * skillName → repo derivation, so filtering follows the tracked source
76
+ * records instead of the filesystem root a skill happens to live under.
77
+ */
78
+ export function filterBySource(skills: readonly CatalogSkill[], source: string, origins: Readonly<Record<string, string>>): CatalogSkill[] {
79
+ if (source === 'all') return [...skills]
80
+ return skills.filter((skill) => (origins[skill.name] ?? PRIVATE_SOURCE) === source)
81
+ }
82
+
83
+ /** Catalog sort keys offered by the filter bar. */
84
+ export type SortKey = 'name' | 'added' | 'uses'
85
+
86
+ /**
87
+ * Sort a skill list in place-safe copy order: name ascending, added
88
+ * descending (newest first, unknown addedAt last), or uses descending
89
+ * (most-called first). Unknown values always trail.
90
+ */
91
+ export function sortSkills(skills: readonly CatalogSkill[], key: SortKey, getUses?: (name: string) => number | undefined): CatalogSkill[] {
92
+ const list = [...skills]
93
+ if (key === 'name') {
94
+ list.sort((a, b) => a.name.localeCompare(b.name))
95
+ } else if (key === 'added') {
96
+ list.sort((a, b) => (b.addedAt ?? -Infinity) - (a.addedAt ?? -Infinity))
97
+ } else if (key === 'uses') {
98
+ list.sort((a, b) => (getUses?.(b.name) ?? 0) - (getUses?.(a.name) ?? 0))
99
+ }
100
+ return list
101
+ }
102
+
103
+ /** Localized relative-time tuple; the caller resolves it via tt(). */
104
+ export interface RelativeTime {
105
+ key: 'time.justNow' | 'time.minutesAgo' | 'time.hoursAgo' | 'time.daysAgo' | 'time.weeksAgo'
106
+ value?: number
107
+ }
108
+
109
+ /** Format an epoch-ms timestamp as a short relative time bucket. */
110
+ export function formatRelativeTime(ms: number, now = Date.now()): RelativeTime {
111
+ const diff = Math.max(0, now - ms)
112
+ const minutes = Math.floor(diff / 60_000)
113
+ if (minutes < 1) return { key: 'time.justNow' }
114
+ if (minutes < 60) return { key: 'time.minutesAgo', value: minutes }
115
+ const hours = Math.floor(minutes / 60)
116
+ if (hours < 24) return { key: 'time.hoursAgo', value: hours }
117
+ const days = Math.floor(hours / 24)
118
+ if (days < 7) return { key: 'time.daysAgo', value: days }
119
+ return { key: 'time.weeksAgo', value: Math.floor(days / 7) }
120
+ }
@@ -34,6 +34,7 @@ import type {} from '@deepseek-ai/dsh-client-ui-settings-plugins/client'
34
34
  import { SkillHubApi } from './api.ts'
35
35
  import { ApiConfigScope } from './api-config-scope.ts'
36
36
  import { en, zh, type HubKey } from './locales.ts'
37
+ import { applySettingsNavIcon } from './settings-nav-icon.ts'
37
38
  import { SkillHubSettingsCard, SkillHubSettingsCardController } from './SkillHubSettingsCard.tsx'
38
39
  import { SkillHubPanel } from './panel/SkillHubPanel.tsx'
39
40
 
@@ -89,7 +90,7 @@ export function apply(ctx: ClientContext): void {
89
90
  ctx.effect(
90
91
  () => ctx.slots.inject('settings.section', () => ctx.slots.register({
91
92
  name: 'settings.section',
92
- id: 'skills',
93
+ id: 'skill-hub',
93
94
  order: 12,
94
95
  label: () => t('entry.label'),
95
96
  locale: NS,
@@ -97,5 +98,7 @@ export function apply(ctx: ClientContext): void {
97
98
  }, () => <SkillHubPanel api={api} />)),
98
99
  'dsh-skill-hub: settings section',
99
100
  )
100
- }
101
101
 
102
+ // Host shell has no section-icon registration; keep the nav gear swapped for the skill icon.
103
+ ctx.effect(() => applySettingsNavIcon(), 'dsh-skill-hub: settings nav icon')
104
+ }