dsh-skill-hub 0.2.6 → 0.3.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.
Files changed (41) hide show
  1. package/README.md +68 -229
  2. package/README.zh.md +67 -203
  3. package/lib/client.js +1955 -413
  4. package/lib/client.js.map +1 -1
  5. package/lib/index.js +441 -84
  6. package/lib/types/client/SkillHubSettingsCard.d.ts +1 -1
  7. package/lib/types/client/api.d.ts +12 -2
  8. package/lib/types/client/index.d.ts +6 -1
  9. package/lib/types/client/locales.d.ts +13 -0
  10. package/lib/types/client/panel/ScenesView.d.ts +1 -1
  11. package/lib/types/client/panel/SourcesView.d.ts +1 -1
  12. package/lib/types/client/panel/useSkillHub.d.ts +20 -2
  13. package/lib/types/client/settings-form.d.ts +1 -1
  14. package/lib/types/client/slash-dots.d.ts +2 -1
  15. package/lib/types/index.d.ts +2 -1
  16. package/lib/types/protocol.d.ts +93 -6
  17. package/lib/types/repo.d.ts +18 -11
  18. package/lib/types/store.d.ts +14 -2
  19. package/package.json +38 -29
  20. package/src/client/SkillHubSettingsCard.tsx +1 -1
  21. package/src/client/api.ts +61 -1
  22. package/src/client/index.tsx +10 -1
  23. package/src/client/locales.ts +26 -0
  24. package/src/client/panel/MarketView.tsx +201 -47
  25. package/src/client/panel/ScenesView.tsx +27 -2
  26. package/src/client/panel/SkillHubPanel.tsx +18 -19
  27. package/src/client/panel/SkillRow.tsx +8 -8
  28. package/src/client/panel/SourcesView.tsx +203 -26
  29. package/src/client/panel/panel.module.css +17 -6
  30. package/src/client/panel/useSkillHub.ts +219 -12
  31. package/src/client/settings-form.ts +2 -1
  32. package/src/client/slash-dots.test.ts +24 -6
  33. package/src/client/slash-dots.tsx +10 -1
  34. package/src/index.ts +20 -2
  35. package/src/protocol.ts +89 -6
  36. package/src/repo.test.ts +61 -19
  37. package/src/repo.ts +86 -23
  38. package/src/routes.test.ts +18 -4
  39. package/src/routes.ts +269 -47
  40. package/src/skillfs.ts +17 -4
  41. package/src/store.ts +72 -4
package/src/client/api.ts CHANGED
@@ -5,10 +5,15 @@
5
5
  import {
6
6
  SKILL_HUB_API,
7
7
  type CatalogResponse,
8
+ type CollectionGroup,
8
9
  type ConfigRequest,
9
10
  type ConfigResponse,
10
11
  type CreateRequest,
11
12
  type CreateResponse,
13
+ type CollectionReorderRequest,
14
+ type CollectionReorderResponse,
15
+ type SourceGroupReorderRequest,
16
+ type SourceGroupReorderResponse,
12
17
  type MarketCheckResponse,
13
18
  type MarketSourceRefRequest,
14
19
  type MarketSourceRequest,
@@ -16,12 +21,15 @@ import {
16
21
  type MarketSourcesResponse,
17
22
  type MarketSyncResponse,
18
23
  type RepoDiscoverResponse,
24
+ type RepoImportCancelResponse,
25
+ type RepoImportProgressResponse,
19
26
  type RepoImportRequest,
20
27
  type RepoImportResponse,
21
28
  type SkillDetail,
22
29
  type SkillDetailResponse,
23
30
  type SkillDeleteRequest,
24
31
  type SkillDeleteResponse,
32
+ type SkillTag,
25
33
  type SourceCheckRequest,
26
34
  type SourceCheckResponse,
27
35
  type SourceDeleteRequest,
@@ -33,6 +41,8 @@ import {
33
41
  type SourceSyncRequest,
34
42
  type SourceSyncResponse,
35
43
  type StatsResponse,
44
+ type TagReorderRequest,
45
+ type TagReorderResponse,
36
46
  type ToggleBatchRequest,
37
47
  type ToggleBatchResponse,
38
48
  type UpdateCheckResponse,
@@ -206,7 +216,7 @@ export class SkillHubApi {
206
216
  return readJson<RepoDiscoverResponse>(response)
207
217
  }
208
218
 
209
- /** Install selected repo skills into the user-dsh root (records the source). */
219
+ /** Install selected repo skills into the user-dsh root (records the source). Returns jobId for polling. */
210
220
  async repoImport(repo: string, paths: string[], ref?: string): Promise<RepoImportResponse> {
211
221
  const payload: RepoImportRequest = { repo, paths, ...(ref !== undefined && ref !== '' ? { ref } : {}) }
212
222
  const response = await fetchWithTimeout(SKILL_HUB_API.repoImport, {
@@ -217,6 +227,23 @@ export class SkillHubApi {
217
227
  return readJson<RepoImportResponse>(response)
218
228
  }
219
229
 
230
+ /** Poll import job progress (B方案轮询) */
231
+ async repoImportProgress(jobId: string): Promise<RepoImportProgressResponse> {
232
+ const url = SKILL_HUB_API.repoImportProgress + '?jobId=' + encodeURIComponent(jobId)
233
+ const response = await fetchWithTimeout(url)
234
+ return readJson<RepoImportProgressResponse>(response)
235
+ }
236
+
237
+ /** Cancel a running import job (选项2:唯有取消才停) */
238
+ async repoImportCancel(jobId: string): Promise<RepoImportCancelResponse> {
239
+ const response = await fetchWithTimeout(SKILL_HUB_API.repoImportCancel, {
240
+ method: 'POST',
241
+ headers: { 'content-type': 'application/json' },
242
+ body: JSON.stringify({ jobId }),
243
+ })
244
+ return readJson<RepoImportCancelResponse>(response)
245
+ }
246
+
220
247
  /** Read the hub's runtime config (effective values + saved overrides). */
221
248
  async config(): Promise<ConfigResponse> {
222
249
  const response = await fetchWithTimeout(SKILL_HUB_API.config)
@@ -272,6 +299,39 @@ export class SkillHubApi {
272
299
  return body.tags
273
300
  }
274
301
 
302
+ /** 拖拽重排场景分组 */
303
+ async reorderTags(orderedIds: string[]): Promise<SkillTag[]> {
304
+ const response = await fetchWithTimeout(SKILL_HUB_API.tagReorder, {
305
+ method: 'POST',
306
+ headers: { 'content-type': 'application/json' },
307
+ body: JSON.stringify({ orderedIds } satisfies TagReorderRequest),
308
+ })
309
+ const body = await readJson<TagReorderResponse>(response)
310
+ return body.tags
311
+ }
312
+
313
+ /** 拖拽重排来源集合 */
314
+ async reorderCollections(orderedNames: string[]): Promise<CollectionGroup[]> {
315
+ const response = await fetchWithTimeout(SKILL_HUB_API.collectionReorder, {
316
+ method: 'POST',
317
+ headers: { 'content-type': 'application/json' },
318
+ body: JSON.stringify({ orderedNames } satisfies CollectionReorderRequest),
319
+ })
320
+ const body = await readJson<CollectionReorderResponse>(response)
321
+ return body.collections
322
+ }
323
+
324
+ /** 拖拽重排来源顶层分组(project / collections / personal) */
325
+ async reorderSourceGroups(orderedKeys: string[]): Promise<string[]> {
326
+ const response = await fetchWithTimeout(SKILL_HUB_API.sourceGroupReorder, {
327
+ method: 'POST',
328
+ headers: { 'content-type': 'application/json' },
329
+ body: JSON.stringify({ orderedKeys } satisfies SourceGroupReorderRequest),
330
+ })
331
+ const body = await readJson<SourceGroupReorderResponse>(response)
332
+ return body.order
333
+ }
334
+
275
335
  /** 来源列表 + 派生 origin 映射 + 集合组 + 回收站。 */
276
336
  async sources(): Promise<SourcesResponse> {
277
337
  const response = await fetchWithTimeout(SKILL_HUB_API.sources)
@@ -22,7 +22,7 @@
22
22
  * internal.
23
23
  */
24
24
 
25
- import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
25
+ import type { Context as ClientContext } from '@deepseek-ai/cordis'
26
26
  // Type-only: pulls the locale plugin's Context merge (ctx.locale).
27
27
  import type {} from '@deepseek-ai/dsh-client-locale/client'
28
28
  // Type-only: pulls the settings-scope service merge and the settings.section slot.
@@ -33,6 +33,15 @@ import type {} from '@deepseek-ai/dsh-client-ui-slots'
33
33
  import type {} from '@deepseek-ai/dsh-client-ui-settings-plugins/client'
34
34
  // Type-only: pulls the Context merge for ctx.inputTriggers (slash-dots wiring).
35
35
  import type {} from '@deepseek-ai/dsh-client-ui-input-trigger/client'
36
+ // Type-only: pulls the connection/reset event.
37
+ import type {} from '@deepseek-ai/dsh-client-connection/client'
38
+
39
+ // 0.1.2-alpha.2: slots service moved to ui-renderer; provide local augmentation for build.
40
+ declare module '@deepseek-ai/cordis' {
41
+ interface Context {
42
+ slots: any
43
+ }
44
+ }
36
45
  import type { HubSettingsValue } from '../protocol.ts'
37
46
  import { SkillHubApi } from './api.ts'
38
47
  import { en, zh, type HubKey } from './locales.ts'
@@ -53,6 +53,11 @@ export const zh = {
53
53
  'market.scan': '扫描',
54
54
  'market.scanning': '扫描中…',
55
55
  'market.check': '检查',
56
+ 'market.checkUpdate': '检查更新',
57
+ 'market.checking': '检查中…',
58
+ 'market.upToDate': '已是最新',
59
+ 'market.update': '更新',
60
+ 'market.updateCount': '更新 {count} 个',
56
61
  'market.sync': '同步',
57
62
  'market.syncing': '同步中…',
58
63
  'market.updated': '有更新',
@@ -76,8 +81,12 @@ export const zh = {
76
81
  'repo.imported': '导入成功 {count} 个',
77
82
  'repo.skippedExisting': '跳过已存在 {count} 个',
78
83
  'repo.failed': '失败 {count} 个',
84
+ 'repo.cancel': '取消',
85
+ 'repo.cancelled': '已取消 · 已导入 {imported}/{total} 已保留,临时文件已清理',
86
+ 'repo.importingCurrent': '正在导入: {name} ({done}/{total})',
79
87
  'repo.root.skills': '技能 skills',
80
88
  'repo.root.designTemplates': '模板 design-templates',
89
+ 'repo.root.generic': '{root}',
81
90
  'update.check': '检查更新',
82
91
  'update.checking': '检查中…',
83
92
  'update.upToDate': '已是最新版本 v{version}',
@@ -164,6 +173,10 @@ export const zh = {
164
173
  'source.followDelete': '跟进删除',
165
174
  'source.deleteConfirmTitle': '跟进上游删除?',
166
175
  'source.deleteConfirmText': '以下技能已从上游仓库移除,本地目录将移入回收站(可恢复):',
176
+ 'source.deleteGroup': '删除整组',
177
+ 'source.deleteGroupHint': '删除该分组下的全部 {count} 个技能',
178
+ 'source.deleteGroupTitle': '删除整组?',
179
+ 'source.deleteGroupText': '将把分组“{name}”下的全部 {count} 个技能移入回收站(可恢复),只读技能会跳过:',
167
180
  'source.trash': '回收站',
168
181
  'source.restore': '恢复',
169
182
  'source.clearTrash': '清空回收站',
@@ -266,6 +279,11 @@ export const en: Record<HubKey, string> = {
266
279
  'market.scan': 'Scan',
267
280
  'market.scanning': 'Scanning…',
268
281
  'market.check': 'Check',
282
+ 'market.checkUpdate': 'Check for updates',
283
+ 'market.checking': 'Checking…',
284
+ 'market.upToDate': 'Up to date',
285
+ 'market.update': 'Update',
286
+ 'market.updateCount': 'Update {count}',
269
287
  'market.sync': 'Sync',
270
288
  'market.syncing': 'Syncing…',
271
289
  'market.updated': 'Updates available',
@@ -289,8 +307,12 @@ export const en: Record<HubKey, string> = {
289
307
  'repo.imported': 'Imported {count}',
290
308
  'repo.skippedExisting': 'Skipped existing {count}',
291
309
  'repo.failed': 'Failed {count}',
310
+ 'repo.cancel': 'Cancel',
311
+ 'repo.cancelled': 'Cancelled · {imported}/{total} kept, temp files cleaned',
312
+ 'repo.importingCurrent': 'Importing: {name} ({done}/{total})',
292
313
  'repo.root.skills': 'Skills',
293
314
  'repo.root.designTemplates': 'Templates design-templates',
315
+ 'repo.root.generic': '{root}',
294
316
  'update.check': 'Check for updates',
295
317
  'update.checking': 'Checking…',
296
318
  'update.upToDate': 'Up to date: v{version}',
@@ -377,6 +399,10 @@ export const en: Record<HubKey, string> = {
377
399
  'source.followDelete': 'Follow deletion',
378
400
  'source.deleteConfirmTitle': 'Follow upstream deletion?',
379
401
  'source.deleteConfirmText': 'These skills were removed upstream; their local directories move to trash (restorable):',
402
+ 'source.deleteGroup': 'Delete group',
403
+ 'source.deleteGroupHint': 'Delete all {count} skills in this group',
404
+ 'source.deleteGroupTitle': 'Delete group?',
405
+ 'source.deleteGroupText': 'All {count} skills in group "{name}" will be moved to trash (restorable); read-only skills will be skipped:',
380
406
  'source.trash': 'Trash',
381
407
  'source.restore': 'Restore',
382
408
  'source.clearTrash': 'Empty trash',
@@ -5,10 +5,10 @@
5
5
  * update-all actions and the repo scan result with the importable checklist.
6
6
  */
7
7
 
8
- import { Fragment, type JSX } from 'react'
8
+ import { Fragment, useEffect, useMemo, useState, type JSX } from 'react'
9
9
  import { tt } from '../helpers.ts'
10
10
  import { MARKET_CATALOG } from '../market-catalog.ts'
11
- import type { MarketSourceRecord } from '../../protocol.ts'
11
+ import type { MarketSourceRecord, RepoSkillEntry } from '../../protocol.ts'
12
12
  import { ConfirmDialog } from './dialogs.tsx'
13
13
  import type { SkillHubState } from './useSkillHub.ts'
14
14
  import css from './panel.module.css'
@@ -19,19 +19,60 @@ function formatBytes(bytes: number): string {
19
19
  if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
20
20
  return (bytes / 1024 / 1024).toFixed(1) + ' MB'
21
21
  }
22
+ function formatSpeed(bps?: number): string {
23
+ if (bps === undefined || bps <= 0) return ''
24
+ if (bps < 1024) return bps + ' B/s'
25
+ if (bps < 1024 * 1024) return (bps / 1024).toFixed(1) + ' KB/s'
26
+ return (bps / 1024 / 1024).toFixed(1) + ' MB/s'
27
+ }
22
28
 
23
29
  export function MarketView(props: { hub: SkillHubState }): JSX.Element {
24
30
  const { hub } = props
25
- const { marketState, marketCheck, sourceCheck, sourcesState, repoDiscoverState, scanningRepo, repoSelected, setRepoSelected, repoImporting, repoResult, newSourceName, setNewSourceName, syncingMarket, tagBusy, addSource, addMarketSource, removeMarketSource, scanRepo, checkMarket, checkSources, syncMarketSource, toggleRepoSelected, importRepo, updateAllDialog, setUpdateAllDialog, updateAll } = hub
31
+ const { marketState, marketCheck, sourceCheck, sourcesState, repoDiscoverState, scanningRepo, repoSelected, setRepoSelected, repoImporting, repoResult, newSourceName, setNewSourceName, syncingMarket, tagBusy, checkingSource, addSource, addMarketSource, removeMarketSource, scanRepo, checkMarket, checkSources, syncMarketSource, toggleRepoSelected, importRepo, cancelImport, updateAllDialog, setUpdateAllDialog, updateAll } = hub
26
32
  /** 有可更新技能的来源(全部更新按钮与确认列表共用)。 */
27
33
  const updatableRepos = Object.entries(sourceCheck).filter(([, check]) => check.changed && check.updated.length > 0)
28
34
 
29
- /** 一个已添加来源的完整行:状态徽章 + 操作按钮。 */
35
+ // 276 列表本地状态:搜索 / 过滤 / 分页(精简:扁平列表,无 A-Z 分组)
36
+ const [repoSearch, setRepoSearch] = useState('')
37
+ const [repoFilter, setRepoFilter] = useState<string>('all')
38
+ const [visibleCount, setVisibleCount] = useState(50)
39
+ useEffect(() => { setVisibleCount(50) }, [repoDiscoverState, repoSearch, repoFilter])
40
+
41
+ /** 一个已添加来源的完整行:状态徽章 + 操作按钮。B 方案:扫描主按钮 + 智能更新按钮(检查/更新二合一)+ 溢出移除 */
30
42
  const sourceRow = (record: MarketSourceRecord): JSX.Element => {
31
43
  const releaseCheck = marketCheck[record.repo]
32
44
  const skillCheck = sourceCheck[record.repo]
33
45
  const installedCount = sourcesState?.sources.find((source) => source.repo === record.repo)?.skills.length ?? 0
34
46
  const scanning = scanningRepo === record.repo
47
+ const syncing = syncingMarket === record.repo
48
+ const checking = checkingSource === record.repo
49
+ const hasSkillUpdate = skillCheck?.changed === true && skillCheck.updated.length > 0
50
+ const hasReleaseUpdate = releaseCheck?.updateAvailable === true
51
+ const hasUpdate = hasSkillUpdate || hasReleaseUpdate
52
+ const isChecked = releaseCheck !== undefined || skillCheck !== undefined
53
+ // 智能按钮三态:未检查→检查更新,未检查完/有更新→更新 N 个,已检查无更新→已是最新
54
+ let updateLabel: string
55
+ let updateDisabled = false
56
+ let updateDanger = false
57
+ let updateAction: () => void = () => {}
58
+ if (syncing) {
59
+ updateLabel = tt('market.syncing')
60
+ updateDisabled = true
61
+ } else if (checking) {
62
+ updateLabel = tt('market.checking')
63
+ updateDisabled = true
64
+ } else if (!isChecked) {
65
+ updateLabel = tt('market.checkUpdate')
66
+ updateAction = () => { void checkSources(record.repo); void checkMarket() }
67
+ } else if (hasUpdate) {
68
+ const count = hasSkillUpdate ? skillCheck!.updated.length : 1
69
+ updateLabel = count > 1 || hasSkillUpdate ? tt('market.updateCount', { count }) : tt('market.update')
70
+ updateDanger = true
71
+ updateAction = () => { void syncMarketSource(record.repo) }
72
+ } else {
73
+ updateLabel = tt('market.upToDate')
74
+ updateDisabled = true
75
+ }
35
76
  return (
36
77
  <div className={css.row + ' ' + css.rowStatic}>
37
78
  <div className={css.rowMain}>
@@ -39,25 +80,38 @@ export function MarketView(props: { hub: SkillHubState }): JSX.Element {
39
80
  <a className={css.sourceLink} href={'https://github.com/' + record.repo} target='_blank' rel='noreferrer'>{record.repo}</a>
40
81
  {record.ref !== undefined ? <span className={css.badge + ' ' + css.badgeSource}>{record.ref}</span> : null}
41
82
  {installedCount > 0 ? <span className={css.badge + ' ' + css.badgeCount}>{tt('market.installed', { count: installedCount })}</span> : null}
42
- {skillCheck?.changed === true && skillCheck.updated.length > 0
43
- ? <span className={css.badge + ' ' + css.statusUpdated}>{tt('market.updatable', { count: skillCheck.updated.length })}</span>
83
+ {hasSkillUpdate
84
+ ? <span className={css.badge + ' ' + css.statusUpdated}>{tt('market.updatable', { count: skillCheck!.updated.length })}</span>
44
85
  : null}
45
86
  {skillCheck !== undefined && skillCheck.deleted.length > 0
46
87
  ? <span className={css.badge + ' ' + css.statusError}>{tt('market.deletedUpstream', { count: skillCheck.deleted.length })}</span>
47
88
  : null}
48
- {releaseCheck?.updateAvailable === true
49
- ? <span className={css.badge + ' ' + css.statusUpdated}>{releaseCheck.latestTag !== undefined ? tt('market.newRelease', { version: releaseCheck.latestTag }) : tt('market.updated')}</span>
89
+ {hasReleaseUpdate
90
+ ? <span className={css.badge + ' ' + css.statusUpdated}>{releaseCheck!.latestTag !== undefined ? tt('market.newRelease', { version: releaseCheck!.latestTag }) : tt('market.updated')}</span>
50
91
  : null}
51
92
  </div>
52
93
  </div>
53
- <button type='button' className={css.opBtn} disabled={scanning} onClick={() => { void checkMarket() }}>{tt('market.check')}</button>
54
- <button type='button' className={css.opBtn + (releaseCheck?.updateAvailable === true ? ' ' + css.opDanger : '')} disabled={syncingMarket === record.repo} onClick={() => { void syncMarketSource(record.repo) }}>
55
- {syncingMarket === record.repo ? tt('market.syncing') : tt('market.sync')}
56
- </button>
57
- <button type='button' className={css.opBtn} disabled={scanning} onClick={() => { void scanRepo(record.repo) }}>
94
+ <button type='button' className={css.button + ' ' + css.primary} style={{ padding:'4px 10px', fontSize:12 }} disabled={scanning} onClick={() => { void scanRepo(record.repo) }}>
58
95
  {scanning ? tt('market.scanning') : tt('market.scan')}
59
96
  </button>
60
- <button type='button' className={css.opBtn} disabled={tagBusy} title={tt('market.removeHint')} onClick={() => { void removeMarketSource(record.repo) }}>{tt('market.deleteSource')}</button>
97
+ <button
98
+ type='button'
99
+ className={css.opBtn + (updateDanger ? ' ' + css.opDanger : '')}
100
+ disabled={updateDisabled}
101
+ title={hasUpdate ? '将同步到上游最新版并提示可更新的本地技能' : undefined}
102
+ onClick={updateAction}
103
+ >
104
+ {updateLabel}
105
+ </button>
106
+ <button
107
+ type='button'
108
+ className={css.opBtn}
109
+ disabled={tagBusy}
110
+ title={tt('market.removeHint')}
111
+ aria-label={tt('market.deleteSource')}
112
+ onClick={() => { void removeMarketSource(record.repo) }}
113
+ style={{ padding:'4px 8px', minWidth:28 }}
114
+ >×</button>
61
115
  </div>
62
116
  )
63
117
  }
@@ -122,53 +176,153 @@ export function MarketView(props: { hub: SkillHubState }): JSX.Element {
122
176
  {marketState.status !== 'loading' && rows.length === 0 ? <div className={css.empty}>{tt('market.noSources')}</div> : null}
123
177
  {marketState.status !== 'loading' ? rows.map((row) => <Fragment key={row.key}>{row.element}</Fragment>) : null}
124
178
  </section>
125
- {repoDiscoverState.status === 'scanning' ? <div className={css.empty}>{tt('market.scanning')}</div> : null}
126
- {repoDiscoverState.status === 'error' ? <div className={css.errorBanner}>{repoDiscoverState.message}</div> : null}
127
- {repoDiscoverState.status === 'ready' ? (() => {
179
+ {/* 扫描结果归属卡片:用同一 section 卡片包裹扫描中/错误/就绪,标题直接点明所属仓库,消除割裂感 */}
180
+ {(repoDiscoverState.status !== 'idle' || repoResult !== null) ? (
181
+ <section className={css.section} style={{ borderLeft: '3px solid var(--hub-model, #2f81f7)', background: 'rgba(47,129,247,0.04)' }}>
182
+ <div className={css.sectionTitle + ' ' + css.sectionHeadRow} style={{ margin: '10px 14px 6px' }}>
183
+ <span className={css.sectionTitleFill} style={{ display:'flex', alignItems:'center', gap:6 }}>
184
+ <span style={{ width:7, height:7, borderRadius:'50%', background:'var(--hub-model, #2f81f7)', display:'inline-block' }} />
185
+ 扫描结果
186
+ <span style={{ fontWeight:400, opacity:.75 }}>
187
+ — {repoDiscoverState.status === 'ready' ? repoDiscoverState.data.repo : repoDiscoverState.status === 'error' ? (scanningRepo ?? '') : (scanningRepo ?? '')}
188
+ {repoDiscoverState.status === 'ready' && repoDiscoverState.data.ref !== null ? <span style={{ opacity:.5, marginLeft:6 }}>ref {repoDiscoverState.data.ref}</span> : null}
189
+ </span>
190
+ </span>
191
+ <button type='button' className={css.opBtn} onClick={() => { hub.clearScan() }}>关闭</button>
192
+ </div>
193
+ <div style={{ padding:'0 12px 12px' }}>
194
+ {repoDiscoverState.status === 'scanning' ? <div className={css.empty} style={{ padding:'12px 0' }}>{tt('market.scanning')}</div> : null}
195
+ {repoDiscoverState.status === 'error' ? <div className={css.errorBanner}>{repoDiscoverState.message}</div> : null}
196
+ {repoDiscoverState.status === 'ready' ? (() => {
128
197
  const entries = repoDiscoverState.data.entries
129
- const selected = entries.filter((entry) => repoSelected.has(entry.path) && !entry.existing)
198
+ // 导入后 catalog 已包含新技能,但 scan 快照的 existing 仍为 false,需用实时 catalog + 本次导入结果兜底,避免已导入仍可选
199
+ const installedNames = new Set<string>([
200
+ ...((hub.catalog?.skills ?? []).map((s) => s.name)),
201
+ ...((hub.catalog?.disabled ?? []).map((d) => d.name)),
202
+ ...((repoResult?.imported ?? []).map((r) => r.name)),
203
+ ...((repoResult?.skipped ?? []).map((r) => r.name)),
204
+ ])
205
+ const isExisting = (entry: RepoSkillEntry): boolean => entry.existing || installedNames.has(entry.name)
206
+ const selected = entries.filter((entry) => repoSelected.has(entry.path) && !isExisting(entry))
207
+ const selectedBytes = selected.reduce((s, e) => s + e.totalBytes, 0)
208
+ // 过滤 + 搜索(276 场景核心)
209
+ const filtered = entries.filter((entry) => {
210
+ if (repoFilter !== 'all' && entry.root !== repoFilter) return false
211
+ if (repoSearch.trim() !== '' && !entry.name.toLowerCase().includes(repoSearch.trim().toLowerCase())) return false
212
+ return true
213
+ }).sort((a, b) => a.name.localeCompare(b.name))
214
+ const paged = filtered.slice(0, visibleCount)
130
215
  return (
131
216
  <>
132
- <p className={css.hintLine + ' ' + css.hintInline}>{tt('repo.ready', { count: entries.length })}</p>
217
+ <p className={css.hintLine + ' ' + css.hintInline}>{tt('repo.ready', { count: entries.length })} · {repoDiscoverState.data.ref !== null ? `ref ${repoDiscoverState.data.ref}` : ''}</p>
133
218
  {entries.length === 0 ? <div className={css.empty}>{tt('repo.empty')}</div> : (
134
219
  <>
135
- <div className={css.buttons + ' ' + css.actionsBottom}>
136
- <button type='button' className={css.button} onClick={() => { setRepoSelected(new Set(entries.filter((entry) => !entry.existing).map((entry) => entry.path))) }}>{tt('repo.selectAll')}</button>
137
- <button type='button' className={css.button} onClick={() => { setRepoSelected(new Set()) }}>{tt('repo.clearAll')}</button>
138
- </div>
139
- {(['skills', 'design-templates'] as const).map((root) => {
140
- const rootEntries = entries.filter((entry) => entry.root === root)
141
- if (rootEntries.length === 0) return null
220
+ {/* 搜索 + 过滤 + 统计 — 精简 */}
221
+ {(() => {
222
+ // 动态根:按已知顺序 skills/design-templates 优先,其余按字母序
223
+ const order = new Map<string, number>([['skills', 0], ['design-templates', 1]])
224
+ const roots = [...new Set(entries.map((e) => e.root))].sort((a, b) => {
225
+ const ao = order.get(a) ?? 99
226
+ const bo = order.get(b) ?? 99
227
+ return ao !== bo ? ao - bo : a.localeCompare(b)
228
+ })
229
+ const counts = new Map<string, number>()
230
+ for (const e of entries) counts.set(e.root, (counts.get(e.root) ?? 0) + 1)
142
231
  return (
143
- <div key={root} className={css.section}>
144
- <div className={css.sectionTitle}>{root === 'skills' ? tt('repo.root.skills') : tt('repo.root.designTemplates')}</div>
145
- {rootEntries.map((entry) => (
146
- <label key={entry.path} className={css.row + (entry.existing ? ' ' + css.rowMuted : '')}>
147
- <input
148
- type='checkbox'
149
- checked={repoSelected.has(entry.path)}
150
- disabled={entry.existing}
151
- onChange={(event) => { toggleRepoSelected(entry.path, event.target.checked) }}
152
- />
153
- <div className={css.rowMain}>
154
- <div className={css.rowName}>{entry.name}</div>
155
- <div className={css.rowDesc}>{entry.dir} · {tt('repo.files', { count: entry.fileCount, size: formatBytes(entry.totalBytes) })}</div>
156
- </div>
157
- {entry.existing
158
- ? <span className={css.badge + ' ' + css.badgeReadonly}>{tt('repo.existing')}</span>
159
- : <span className={css.badge + ' ' + css.badgeSource}>{entry.origin}</span>}
160
- </label>
232
+ <div style={{ display:'flex', gap:8, marginBottom:8, flexWrap:'wrap' }}>
233
+ <input
234
+ className={css.input}
235
+ style={{ flex:1, minWidth:140 }}
236
+ value={repoSearch}
237
+ onChange={(e) => setRepoSearch(e.target.value)}
238
+ placeholder="搜索技能名…"
239
+ />
240
+ <button type='button' className={css.button + (repoFilter === 'all' ? ' ' + css.primary : '')} style={{ padding:'6px 10px', fontSize:12 }} onClick={() => setRepoFilter('all')}>全部 {entries.length}</button>
241
+ {roots.map((root) => (
242
+ <button key={root} type='button' className={css.button + (repoFilter === root ? ' ' + css.primary : '')} style={{ padding:'6px 10px', fontSize:12 }} onClick={() => setRepoFilter(root)}>{root} {counts.get(root) ?? 0}</button>
161
243
  ))}
162
244
  </div>
163
245
  )
164
- })}
165
- <button type='button' className={css.button + ' ' + css.primary} disabled={selected.length === 0 || repoImporting} onClick={() => { void importRepo() }}>{repoImporting ? tt('repo.importing') : tt('repo.import', { count: selected.length })}</button>
246
+ })()}
247
+ <div className={css.hintLine} style={{ display:'flex', justifyContent:'space-between', flexWrap:'wrap', gap:8 }}>
248
+ <span>已选 {selected.length}/{entries.length} · {formatBytes(selectedBytes)} · 显示 {paged.length}/{filtered.length}(过滤后)</span>
249
+ <span style={{ display:'flex', gap:6 }}>
250
+ <button type='button' className={css.button} style={{ padding:'4px 8px', fontSize:11 }} onClick={() => { setRepoSelected(new Set(filtered.filter((e)=>!isExisting(e)).slice(0, visibleCount).map((e)=>e.path))) }}>全选当前显示</button>
251
+ <button type='button' className={css.button} style={{ padding:'4px 8px', fontSize:11 }} onClick={() => { setRepoSelected(new Set(entries.filter((e)=>!isExisting(e)).map((e)=>e.path))) }}>全选全部 {entries.filter(e=>!isExisting(e)).length}</button>
252
+ <button type='button' className={css.button} style={{ padding:'4px 8px', fontSize:11 }} onClick={() => { setRepoSelected(new Set()) }}>{tt('repo.clearAll')}</button>
253
+ </span>
254
+ </div>
255
+
256
+ {/* 扁平虚拟滚动容器 — 主题自适应,CSS Modules */}
257
+ <div className={css.scanList}>
258
+ {paged.length === 0 ? <div className={css.empty} style={{ padding:20 }}>无匹配技能</div> : paged.map((entry) => {
259
+ const existing = isExisting(entry)
260
+ return (
261
+ <label key={entry.path} className={css.row + (existing ? ' ' + css.rowMuted : '')}>
262
+ <input
263
+ type='checkbox'
264
+ checked={repoSelected.has(entry.path)}
265
+ disabled={existing}
266
+ onChange={(event) => { toggleRepoSelected(entry.path, event.target.checked) }}
267
+ />
268
+ <div className={css.rowMain}>
269
+ <div className={css.rowName}>{entry.name}</div>
270
+ <div className={css.rowDesc}>{entry.dir} · {tt('repo.files', { count: entry.fileCount, size: formatBytes(entry.totalBytes) })}</div>
271
+ </div>
272
+ {existing
273
+ ? <span className={css.badge + ' ' + css.badgeReadonly}>{tt('repo.existing')}</span>
274
+ : <span className={css.badge + ' ' + css.badgeSource}>{entry.origin}</span>}
275
+ </label>
276
+ )})}
277
+ </div>
278
+ {visibleCount < filtered.length ? (
279
+ <div style={{ textAlign:'center', marginTop:8 }}>
280
+ <button type='button' className={css.button} onClick={() => setVisibleCount((n) => n + 50)}>加载更多 50 (剩余 {filtered.length - visibleCount})</button>
281
+ </div>
282
+ ) : null}
283
+
284
+ <div className={css.buttons + ' ' + css.actionsTop}>
285
+ <button type='button' className={css.button + ' ' + css.primary} disabled={selected.length === 0 || repoImporting} onClick={() => { void importRepo() }}>{repoImporting ? tt('repo.importing') : `${tt('repo.import', { count: selected.length })} · ${formatBytes(selectedBytes)}`}</button>
286
+ {repoImporting ? <button type='button' className={css.button} onClick={() => { void cancelImport() }}>{tt('repo.cancel')}</button> : null}
287
+ </div>
288
+ {repoResult !== null && repoResult.status === 'running' ? (() => {
289
+ const pct = repoResult.totalBytes > 0 ? Math.min(100, Math.round(repoResult.downloadedBytes / repoResult.totalBytes * 100)) : (repoResult.total > 0 ? Math.round(repoResult.done / repoResult.total * 100) : 0)
290
+ const speed = formatSpeed(repoResult.bytesPerSecond)
291
+ return (
292
+ <div className={css.formSuccess + ' ' + css.actionsTop}>
293
+ <div style={{ display:'flex', alignItems:'center', gap:8, marginBottom:6 }}>
294
+ <div className={css.scanProgressTrack}>
295
+ <div className={css.scanProgressFill} style={{ width: `${pct}%` }} />
296
+ </div>
297
+ <span style={{ fontSize:11, opacity:.75, minWidth:32, textAlign:'right' }}>{pct}%</span>
298
+ </div>
299
+ <div className={css.hintLine}>
300
+ {formatBytes(repoResult.downloadedBytes)} / {formatBytes(repoResult.totalBytes)}
301
+ {speed !== '' ? ` · ${speed}` : ''} · {repoResult.done}/{repoResult.total} 个技能
302
+ </div>
303
+ <div className={css.hintLine} style={{marginTop:4}}>
304
+ {repoResult.current !== undefined ? tt('repo.importingCurrent', { name: repoResult.current, done: repoResult.done + 1, total: repoResult.total }) : tt('repo.importing')}
305
+ {repoResult.currentFile !== undefined ? ` · ${repoResult.currentFile}` : ''}
306
+ </div>
307
+ {repoResult.failed.length > 0 ? <div className={css.errorBanner} style={{marginTop:6}}>{repoResult.failed.map(f => `${f.name}: ${f.error}`).join('; ')}</div> : null}
308
+ </div>
309
+ )
310
+ })() : null}
166
311
  </>
167
312
  )}
168
313
  </>
169
314
  )
170
315
  })() : null}
171
- {repoResult !== null ? <div className={css.formSuccess + ' ' + css.actionsTop}>{tt('repo.imported', { count: repoResult.imported.length })} · {tt('repo.skippedExisting', { count: repoResult.skipped.length })} · {tt('repo.failed', { count: repoResult.failed.length })}</div> : null}
316
+ {repoResult !== null && repoResult.status !== 'running' ? (
317
+ <div className={repoResult.status === 'cancelled' ? css.errorBanner + ' ' + css.actionsTop : css.formSuccess + ' ' + css.actionsTop}>
318
+ {repoResult.status === 'cancelled' ? tt('repo.cancelled', { imported: repoResult.imported.length, total: repoResult.total }) : `${tt('repo.imported', { count: repoResult.imported.length })} · ${tt('repo.skippedExisting', { count: repoResult.skipped.length })} · ${tt('repo.failed', { count: repoResult.failed.length })}`}
319
+ {repoResult.failed.length > 0 ? <div style={{marginTop:6}}>{repoResult.failed.map(f => `${f.name}: ${f.error}`).join('; ')}</div> : null}
320
+ {repoResult.status === 'error' && repoResult.error ? <div style={{marginTop:6}}>{repoResult.error}</div> : null}
321
+ </div>
322
+ ) : null}
323
+ </div>
324
+ </section>
325
+ ) : null}
172
326
 
173
327
  {updateAllDialog ? (
174
328
  <ConfirmDialog
@@ -5,7 +5,7 @@
5
5
  * are managed in the sources tab.
6
6
  */
7
7
 
8
- import type { JSX } from 'react'
8
+ import { useState, type JSX } from 'react'
9
9
  import { tt } from '../helpers.ts'
10
10
  import { groupSwitchView } from '../grouping.ts'
11
11
  import { SkillRow } from './SkillRow.tsx'
@@ -17,6 +17,19 @@ import css from './panel.module.css'
17
17
  export function ScenesView(props: { hub: SkillHubState }): JSX.Element {
18
18
  const { hub } = props
19
19
  const { catalog, groupsState, sorted, normalized, collapsedGroups, viewNames, actionNames, batchBusy, busyNames, newTagName, setNewTagName, tagBusy, createTag, toggleGroupCollapse, toggleGroup, setEditingTag, setEditName, setMembersDraft, setEditSearch, enableDisabled } = hub
20
+ const [dragId, setDragId] = useState<string | null>(null)
21
+ const [overId, setOverId] = useState<string | null>(null)
22
+ const handleDrop = (targetId: string): void => {
23
+ if (dragId === null || dragId === targetId || groupsState === null) return
24
+ const ids = groupsState.tags.map((t) => t.id)
25
+ const from = ids.indexOf(dragId)
26
+ const to = ids.indexOf(targetId)
27
+ if (from === -1 || to === -1) return
28
+ const next = [...ids]
29
+ const [moved] = next.splice(from, 1)
30
+ next.splice(to, 0, moved)
31
+ void hub.reorderTags(next)
32
+ }
20
33
  return (
21
34
  <>
22
35
  <form className={css.form} onSubmit={(event) => { void createTag(event) }}>
@@ -38,9 +51,21 @@ export function ScenesView(props: { hub: SkillHubState }): JSX.Element {
38
51
  const collapsed = collapsedGroups.has('tag:' + tag.id)
39
52
  const view = groupSwitchView(tag.skillNames, viewNames)
40
53
  const hasWritable = tag.skillNames.some((name) => actionNames.has(name))
54
+ const isDragging = dragId === tag.id
55
+ const isOver = overId === tag.id && dragId !== tag.id
41
56
  return (
42
- <section key={'tag:' + tag.id} className={css.section}>
57
+ <section
58
+ key={'tag:' + tag.id}
59
+ className={css.section + (isDragging ? ' ' + css.dragging : '') + (isOver ? ' ' + css.dragOver : '')}
60
+ draggable
61
+ onDragStart={(e) => { setDragId(tag.id); e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', tag.id) }}
62
+ onDragOver={(e) => { e.preventDefault(); if (overId !== tag.id) setOverId(tag.id) }}
63
+ onDragLeave={() => { if (overId === tag.id) setOverId(null) }}
64
+ onDrop={(e) => { e.preventDefault(); handleDrop(tag.id); setOverId(null) }}
65
+ onDragEnd={() => { setDragId(null); setOverId(null) }}
66
+ >
43
67
  <div className={css.groupHead}>
68
+ <span className={css.dragHandle} aria-hidden title="拖拽调整顺序">⋮⋮</span>
44
69
  <button type='button' className={css.disclosure} aria-expanded={!collapsed} onClick={() => { toggleGroupCollapse('tag:' + tag.id) }}>
45
70
  <span className={css.chevron + (collapsed ? ' ' + css.chevronCollapsed : '')} />
46
71
  <span className={css.groupTitle}>