dsh-skill-hub 0.3.6 → 0.3.7

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 (38) hide show
  1. package/lib/client.js +890 -356
  2. package/lib/client.js.map +1 -1
  3. package/lib/index.js +401 -47
  4. package/lib/types/client/SkillHubSettingsCard.d.ts +0 -2
  5. package/lib/types/client/api.d.ts +5 -1
  6. package/lib/types/client/helpers.d.ts +10 -0
  7. package/lib/types/client/locales.d.ts +30 -0
  8. package/lib/types/client/panel/DisabledRow.d.ts +2 -0
  9. package/lib/types/client/panel/SkillDetailView.d.ts +5 -1
  10. package/lib/types/client/panel/dialogs.d.ts +19 -0
  11. package/lib/types/client/panel/useSkillHub.d.ts +12 -1
  12. package/lib/types/client/settings-card.d.ts +0 -17
  13. package/lib/types/client/settings-form.d.ts +0 -1
  14. package/lib/types/protocol.d.ts +40 -17
  15. package/lib/types/repo.d.ts +4 -6
  16. package/lib/types/skillfs.d.ts +13 -0
  17. package/package.json +29 -28
  18. package/src/client/SkillHubSettingsCard.tsx +0 -2
  19. package/src/client/api.ts +18 -0
  20. package/src/client/helpers.ts +40 -0
  21. package/src/client/locales.ts +60 -0
  22. package/src/client/panel/DisabledRow.tsx +22 -7
  23. package/src/client/panel/MarketView.tsx +8 -1
  24. package/src/client/panel/ScenesView.tsx +1 -1
  25. package/src/client/panel/SkillDetailView.tsx +35 -11
  26. package/src/client/panel/SkillHubPanel.tsx +42 -4
  27. package/src/client/panel/SkillRow.tsx +5 -2
  28. package/src/client/panel/SourcesView.tsx +2 -17
  29. package/src/client/panel/dialogs.tsx +61 -0
  30. package/src/client/panel/panel.module.css +1 -8
  31. package/src/client/panel/useSkillHub.ts +97 -12
  32. package/src/client/settings-card.tsx +0 -42
  33. package/src/client/settings-form.ts +1 -5
  34. package/src/protocol.ts +42 -8
  35. package/src/repo.ts +71 -29
  36. package/src/routes.test.ts +22 -0
  37. package/src/routes.ts +181 -10
  38. package/src/skillfs.ts +188 -3
@@ -65,12 +65,22 @@ export const zh = {
65
65
  'market.branchTitle': '选择分支',
66
66
  'market.branchHint': '该仓库没有正式发布,选择要跟踪的分支(默认为 main):',
67
67
  'market.branchConfirm': '确定',
68
+ 'market.versionTitle': '切换跟踪版本',
69
+ 'market.versionText': '选择 {repo} 要跟踪的版本,切换后建议重新扫描并同步已装技能',
70
+ 'market.versionCurrent': '当前 {ref}',
71
+ 'market.versionReleases': '发布版',
72
+ 'market.versionBranches': '分支',
73
+ 'market.versionCustom': '自定义(tag / 分支 / commit)',
74
+ 'market.versionConfirm': '切换到 {ref}',
75
+ 'market.versionHint': '点击切换跟踪版本',
76
+ 'market.unpinned': '未定版',
68
77
  'market.syncTitle': '同步本地技能',
69
78
  'market.syncText': '市场源已对齐到 {ref},要同步更新以下本地技能吗?',
70
79
  'market.syncNone': '该来源还没有导入本地技能。',
71
80
  'market.syncCancel': '跳过',
72
81
  'market.removeHint': '移除源不影响已安装的技能。',
73
82
  'repo.ready': '发现 {count} 个可导入技能',
83
+ 'repo.truncated': '⚠ 仓库过大,仅展示部分技能(GitHub 截断),可缩小仓库或指定子目录',
74
84
  'repo.empty': '没有找到可导入技能。',
75
85
  'repo.selectAll': '全选',
76
86
  'repo.clearAll': '清空',
@@ -143,6 +153,26 @@ export const zh = {
143
153
  'row.enable': '启用',
144
154
  'row.delete': '移入回收站',
145
155
  'row.open': '查看 {name} 详情(回车)',
156
+ 'row.duplicate': '重名',
157
+ 'row.duplicateHint': '同名技能存在多个来源,当前显示为优先级最高的版本',
158
+ 'row.copy': '复制',
159
+ 'row.copyMention': '复制 $mention',
160
+ 'row.copied': '已复制',
161
+ 'row.copyPath': '复制路径',
162
+ 'detail.copyMention': '复制 $mention',
163
+ 'detail.copyPath': '复制路径',
164
+ 'detail.copied': '已复制',
165
+ 'diag.fix': '修复',
166
+ 'diag.fixing': '修复中…',
167
+ 'diag.fixed': '已修复',
168
+ 'diag.fixConfirm': '用自动修复重写以下文件的 frontmatter?建议先备份:{path}',
169
+ 'filter.invocation': '调用',
170
+ 'filter.invocationAll': '全部调用',
171
+ 'filter.modelOnly': '模型可调',
172
+ 'filter.userOnly': '用户可调',
173
+ 'filter.showing': '显示 {shown}/{total}(已启用){filtered}',
174
+ 'filter.filteredSuffix': ' · 已筛选',
175
+ 'filter.shortened': '短描述 {count} 条,悬浮看全文',
146
176
  'delete.confirmTitle': '移入回收站?',
147
177
  'delete.confirmText': '确定把「{name}」移入回收站吗?可随时恢复。',
148
178
  'delete.confirm': '移入回收站',
@@ -291,12 +321,22 @@ export const en: Record<HubKey, string> = {
291
321
  'market.branchTitle': 'Choose a branch',
292
322
  'market.branchHint': 'This repo has no releases. Pick a branch to track (main by default):',
293
323
  'market.branchConfirm': 'Confirm',
324
+ 'market.versionTitle': 'Switch tracked version',
325
+ 'market.versionText': 'Pick the version for {repo} to track; rescan and sync installed skills afterwards',
326
+ 'market.versionCurrent': 'current {ref}',
327
+ 'market.versionReleases': 'Releases',
328
+ 'market.versionBranches': 'Branches',
329
+ 'market.versionCustom': 'Custom (tag / branch / commit)',
330
+ 'market.versionConfirm': 'Switch to {ref}',
331
+ 'market.versionHint': 'Click to switch the tracked version',
332
+ 'market.unpinned': 'unpinned',
294
333
  'market.syncTitle': 'Update local skills',
295
334
  'market.syncText': 'Market source aligned to {ref}. Update these local skills?',
296
335
  'market.syncNone': 'No local skills tracked from this repo yet.',
297
336
  'market.syncCancel': 'Skip',
298
337
  'market.removeHint': 'Removing a source does not affect installed skills.',
299
338
  'repo.ready': '{count} importable skills found',
339
+ 'repo.truncated': '⚠ Repo too large, only partial skills shown (GitHub truncated)',
300
340
  'repo.empty': 'No importable skills found.',
301
341
  'repo.selectAll': 'Select all',
302
342
  'repo.clearAll': 'Clear',
@@ -369,6 +409,26 @@ export const en: Record<HubKey, string> = {
369
409
  'row.enable': 'Enable',
370
410
  'row.delete': 'Move to trash',
371
411
  'row.open': 'View {name} details (Enter)',
412
+ 'row.duplicate': 'duplicate',
413
+ 'row.duplicateHint': 'Multiple sources provide this name; the highest-priority one is shown',
414
+ 'row.copy': 'Copy',
415
+ 'row.copyMention': 'Copy $mention',
416
+ 'row.copied': 'Copied',
417
+ 'row.copyPath': 'Copy path',
418
+ 'detail.copyMention': 'Copy $mention',
419
+ 'detail.copyPath': 'Copy path',
420
+ 'detail.copied': 'Copied',
421
+ 'diag.fix': 'Fix',
422
+ 'diag.fixing': 'Fixing…',
423
+ 'diag.fixed': 'Fixed',
424
+ 'diag.fixConfirm': 'Rewrite the frontmatter of this file with the auto-fix? Back it up first: {path}',
425
+ 'filter.invocation': 'Invocation',
426
+ 'filter.invocationAll': 'All',
427
+ 'filter.modelOnly': 'Model',
428
+ 'filter.userOnly': 'User',
429
+ 'filter.showing': 'Showing {shown}/{total} (enabled){filtered}',
430
+ 'filter.filteredSuffix': ' · filtered',
431
+ 'filter.shortened': '{count} short descriptions, hover for full text',
372
432
  'delete.confirmTitle': 'Move to trash?',
373
433
  'delete.confirmText': 'Move "{name}" to trash? It can be restored anytime.',
374
434
  'delete.confirm': 'Move to trash',
@@ -4,7 +4,7 @@
4
4
  * disabled section.
5
5
  */
6
6
 
7
- import type { JSX } from 'react'
7
+ import type { JSX, KeyboardEvent } from 'react'
8
8
  import type { DisabledSkill, WritableRoot } from '../../protocol.ts'
9
9
  import { tt } from '../helpers.ts'
10
10
  import type { HubKey } from '../locales.ts'
@@ -15,18 +15,33 @@ function disabledSourceLabel(root: WritableRoot): string {
15
15
  return tt(('badge.source.' + root) as HubKey)
16
16
  }
17
17
 
18
- export function DisabledRow(props: { record: DisabledSkill; busy: boolean; onEnable: () => void }): JSX.Element {
19
- const { record, busy, onEnable } = props
18
+ export function DisabledRow(props: { record: DisabledSkill; busy: boolean; onEnable: () => void; duplicate?: boolean; onOpen?: () => void }): JSX.Element {
19
+ const { record, busy, onEnable, duplicate, onOpen } = props
20
+ /** 键盘打开详情:Enter 或空格。仅当焦点在行本身(而非行内开关)时生效。 */
21
+ const onKeyDown = (event: KeyboardEvent<HTMLDivElement>): void => {
22
+ if (onOpen === undefined || event.target !== event.currentTarget) return
23
+ if (event.key === 'Enter' || event.key === ' ') {
24
+ event.preventDefault()
25
+ onOpen()
26
+ }
27
+ }
20
28
  return (
21
- <div className={css.row + ' ' + css.rowStatic}>
29
+ <div
30
+ className={css.row + (onOpen === undefined ? ' ' + css.rowStatic : '')}
31
+ role={onOpen === undefined ? undefined : 'button'}
32
+ tabIndex={onOpen === undefined ? undefined : 0}
33
+ aria-label={onOpen === undefined ? undefined : tt('row.open', { name: record.name })}
34
+ onClick={onOpen}
35
+ onKeyDown={onKeyDown}
36
+ >
22
37
  <div className={css.rowMain}>
23
- <div className={css.rowName}>{record.name}</div>
24
- <div className={css.rowDesc}>{record.description}</div>
38
+ <div className={css.rowName}>{record.name}{duplicate === true ? <span className={css.badge + ' ' + css.statusError} style={{ marginLeft: 6 }} title={tt('row.duplicateHint')}>{tt('row.duplicate')}</span> : null}</div>
39
+ <div className={css.rowDesc} title={record.description}>{record.description}</div>
25
40
  </div>
26
41
  <span className={css.badges}>
27
42
  <span className={css.badge + ' ' + css.badgeSource}>{disabledSourceLabel(record.root)}</span>
28
43
  </span>
29
- <button type='button' role='switch' aria-checked={false} aria-label={tt('row.enable')} className={css.switch} disabled={busy} onClick={onEnable}><span className={css.switchThumb} /></button>
44
+ <button type='button' role='switch' aria-checked={false} aria-label={tt('row.enable')} className={css.switch} disabled={busy} onClick={(event) => { event.stopPropagation(); onEnable() }}><span className={css.switchThumb} /></button>
30
45
  </div>
31
46
  )
32
47
  }
@@ -78,7 +78,13 @@ export function MarketView(props: { hub: SkillHubState }): JSX.Element {
78
78
  <div className={css.rowMain}>
79
79
  <div className={css.rowName}>
80
80
  <a className={css.sourceLink} href={'https://github.com/' + record.repo} target='_blank' rel='noreferrer'>{record.repo}</a>
81
- {record.ref !== undefined ? <span className={css.badge + ' ' + css.badgeSource}>{record.ref}</span> : null}
81
+ <button
82
+ type='button'
83
+ className={css.badge + ' ' + css.badgeSource}
84
+ style={{ cursor:'pointer', fontFamily:'inherit' }}
85
+ title={tt('market.versionHint')}
86
+ onClick={() => { void hub.openVersionDialog(record.repo) }}
87
+ >{record.ref ?? tt('market.unpinned')}</button>
82
88
  {installedCount > 0 ? <span className={css.badge + ' ' + css.badgeCount}>{tt('market.installed', { count: installedCount })}</span> : null}
83
89
  {hasSkillUpdate
84
90
  ? <span className={css.badge + ' ' + css.statusUpdated}>{tt('market.updatable', { count: skillCheck!.updated.length })}</span>
@@ -214,6 +220,7 @@ export function MarketView(props: { hub: SkillHubState }): JSX.Element {
214
220
  return (
215
221
  <>
216
222
  <p className={css.hintLine + ' ' + css.hintInline}>{tt('repo.ready', { count: entries.length })} · {repoDiscoverState.data.ref !== null ? `ref ${repoDiscoverState.data.ref}` : ''}</p>
223
+ {repoDiscoverState.data.truncated === true ? <div className={css.errorBanner} style={{ background:'rgba(210,153,34,0.08)', color:'#d29922', borderLeft:'3px solid #d29922' }}>{tt('repo.truncated')}</div> : null}
217
224
  {entries.length === 0 ? <div className={css.empty}>{tt('repo.empty')}</div> : (
218
225
  <>
219
226
  {/* 搜索 + 过滤 + 统计 — 精简 */}
@@ -88,7 +88,7 @@ export function ScenesView(props: { hub: SkillHubState }): JSX.Element {
88
88
  <>
89
89
  {skills.map((skill) => <SkillRow key={skill.name} skill={skill} hub={hub} />)}
90
90
  {disabledMembers.map((record) => (
91
- <DisabledRow key={record.name} record={record} busy={busyNames.has(record.name)} onEnable={() => { void enableDisabled(record) }} />
91
+ <DisabledRow key={record.name} record={record} busy={busyNames.has(record.name)} duplicate={hub.catalog?.duplicateNames?.includes(record.name) === true} onEnable={() => { void enableDisabled(record) }} onOpen={() => { void hub.openDetail(record.name) }} />
92
92
  ))}
93
93
  </>
94
94
  ) : null}
@@ -5,9 +5,9 @@
5
5
  * panel stays the single owner of state.
6
6
  */
7
7
 
8
- import type { JSX } from 'react'
8
+ import { useEffect, useRef, useState, type JSX } from 'react'
9
9
  import type { GroupsResponse, HubConfig, SkillDetail, SourceCheckResult, SourcesResponse } from '../../protocol.ts'
10
- import { tt } from '../helpers.ts'
10
+ import { copyTextToClipboard, isDisplayNameDistinct, tt } from '../helpers.ts'
11
11
  import css from './panel.module.css'
12
12
  import { dotStyle, formatDateTime, shortSha } from './format.ts'
13
13
  import { SourceStatusBadge } from './SourceStatusBadge.tsx'
@@ -23,6 +23,10 @@ export interface SkillDetailViewProps {
23
23
  checkingSource: string | null
24
24
  syncingSource: string | null
25
25
  loading: boolean
26
+ /** Whether the skill is currently hub-disabled (served from the .disabled file). */
27
+ disabled?: boolean
28
+ /** Re-enable a hub-disabled skill. */
29
+ onEnable?: () => void
26
30
  onBack: () => void
27
31
  /** Check one source repo for upstream updates. */
28
32
  onCheck: (repo: string) => void
@@ -33,22 +37,38 @@ export interface SkillDetailViewProps {
33
37
  }
34
38
 
35
39
  export function SkillDetailView(props: SkillDetailViewProps): JSX.Element {
36
- const { detail, hubConfig, uses, groupsState, sourcesState, sourceCheck, checkingSource, syncingSource, loading, onBack, onCheck, onSync, onFollowDelete } = props
40
+ const { detail, hubConfig, uses, groupsState, sourcesState, sourceCheck, checkingSource, syncingSource, loading, disabled, onEnable, onBack, onCheck, onSync, onFollowDelete } = props
37
41
  const detailSource = sourcesState?.sources.find((source) => source.skills.includes(detail.name))
38
42
  const detailCheck = detailSource !== undefined ? sourceCheck[detailSource.repo] : undefined
43
+ const [copied, setCopied] = useState<string | null>(null)
44
+ const copyTimer = useRef<number | undefined>(undefined)
45
+ useEffect(() => () => { window.clearTimeout(copyTimer.current) }, [])
46
+ const copyText = (text: string, key: string): void => {
47
+ void copyTextToClipboard(text).then((ok) => {
48
+ if (!ok) return
49
+ setCopied(key)
50
+ window.clearTimeout(copyTimer.current)
51
+ copyTimer.current = window.setTimeout(() => { setCopied(null) }, 1200)
52
+ })
53
+ }
39
54
  return (
40
55
  <div className={css.panel}>
41
56
  <div className={css.detailHead}>
42
57
  <button type='button' className={css.back} onClick={onBack}>{tt('detail.back')}</button>
43
- <span className={css.detailName}>
58
+ <span className={css.detailName} style={{ display:'inline-flex', alignItems:'center', gap:6, flexWrap:'wrap' }}>
44
59
  {detail.name}
45
- {/* 单一状态圆点:模型可调 → 蓝;否则用户可调 → 绿。与聊天 / 菜单同规则。 */}
60
+ {isDisplayNameDistinct(detail.name, detail.displayName) ? <span className={css.displayName} style={{ fontSize:13, marginLeft:0 }}>{detail.displayName}</span> : null}
46
61
  {detail.invocation.modelInvocable
47
62
  ? <span className={css.dot + ' ' + css.dotModel} style={dotStyle(hubConfig?.dotModelColor)} title={tt('legend.model')} />
48
63
  : detail.invocation.userInvocable
49
64
  ? <span className={css.dot + ' ' + css.dotUser} style={dotStyle(hubConfig?.dotUserColor)} title={tt('legend.user')} />
50
65
  : null}
51
66
  </span>
67
+ <span className={css.actions} style={{ marginLeft: 'auto', gap: 6 }}>
68
+ {disabled === true && onEnable !== undefined ? <button type='button' className={css.opBtn} role='switch' aria-checked={false} aria-label={tt('row.enable')} onClick={onEnable}>{tt('row.enable')}</button> : null}
69
+ <button type='button' className={css.opBtn} onClick={() => { copyText('$' + detail.name, 'mention') }}>{copied === 'mention' ? tt('detail.copied') : tt('detail.copyMention')}</button>
70
+ {detail.path !== undefined ? <button type='button' className={css.opBtn} onClick={() => { const path = detail.path; if (path !== undefined) copyText(path, 'path') }}>{copied === 'path' ? tt('detail.copied') : tt('detail.copyPath')}</button> : null}
71
+ </span>
52
72
  </div>
53
73
  <div className={css.detailMeta}>
54
74
  <div className={css.detailMetaLine}>{tt('detail.provider')}: {detail.provider}</div>
@@ -81,12 +101,16 @@ export function SkillDetailView(props: SkillDetailViewProps): JSX.Element {
81
101
  <button type='button' className={css.opBtn} disabled={checkingSource !== null} onClick={() => { onCheck(detailSource.repo) }}>
82
102
  {checkingSource === detailSource.repo ? tt('source.checking') : tt('source.check')}
83
103
  </button>
84
- <button type='button' className={css.opBtn} disabled={syncingSource !== null} onClick={() => { onSync(detailSource.repo, [detail.name]) }}>
85
- {syncingSource === detailSource.repo ? tt('source.syncing') : tt('source.sync')}
86
- </button>
87
- {detailCheck?.deleted.includes(detail.name) === true
88
- ? <button type='button' className={css.opBtn + ' ' + css.opDanger} onClick={() => { onFollowDelete(detailSource.repo, [detail.name]) }}>{tt('source.followDelete')}</button>
89
- : null}
104
+ {disabled !== true ? (
105
+ <>
106
+ <button type='button' className={css.opBtn} disabled={syncingSource !== null} onClick={() => { onSync(detailSource.repo, [detail.name]) }}>
107
+ {syncingSource === detailSource.repo ? tt('source.syncing') : tt('source.sync')}
108
+ </button>
109
+ {detailCheck?.deleted.includes(detail.name) === true
110
+ ? <button type='button' className={css.opBtn + ' ' + css.opDanger} onClick={() => { onFollowDelete(detailSource.repo, [detail.name]) }}>{tt('source.followDelete')}</button>
111
+ : null}
112
+ </>
113
+ ) : null}
90
114
  </div>
91
115
  </div>
92
116
  ) : (
@@ -24,7 +24,7 @@ import { TagEditorView } from './TagEditorView.tsx'
24
24
  import { SourcesView } from './SourcesView.tsx'
25
25
  import { ScenesView } from './ScenesView.tsx'
26
26
  import { MarketView } from './MarketView.tsx'
27
- import { BranchChoiceDialog, ConfirmDialog, ConflictDialog, MarketSyncDialog } from './dialogs.tsx'
27
+ import { BranchChoiceDialog, ConfirmDialog, ConflictDialog, MarketSyncDialog, VersionChoiceDialog } from './dialogs.tsx'
28
28
  import { useSkillHub } from './useSkillHub.ts'
29
29
  import css from './panel.module.css'
30
30
 
@@ -51,10 +51,12 @@ export function SkillHubPanel(props: SkillHubPanelProps): React.JSX.Element {
51
51
  checkUpdate, loadMarket, checkSources, requestSync, requestDelete, restoreTrash, clearTrash, runDeleteSkill, runDeleteGroup,
52
52
  runConfirmed, resolveConflict, confirmBranchChoice, confirmMarketSync, create, saveTag, deleteTag, enableDisabled,
53
53
  } = hub
54
+ const { shortenedCount, fixingPaths, clearListFilters } = hub
54
55
 
55
56
  // -------------------------------------------------------------- detail
56
57
 
57
58
  if (detail !== null) {
59
+ const disabledRecord = catalog?.disabled.find((record) => record.name === detail.name)
58
60
  return (
59
61
  <SkillDetailView
60
62
  detail={detail}
@@ -66,6 +68,8 @@ export function SkillHubPanel(props: SkillHubPanelProps): React.JSX.Element {
66
68
  checkingSource={checkingSource}
67
69
  syncingSource={syncingSource}
68
70
  loading={detailLoading}
71
+ disabled={disabledRecord !== undefined}
72
+ onEnable={disabledRecord !== undefined ? () => { void enableDisabled(disabledRecord).then(() => { setDetail(null) }) } : undefined}
69
73
  onBack={() => { setDetail(null) }}
70
74
  onCheck={(repo) => { void checkSources(repo) }}
71
75
  onSync={requestSync}
@@ -126,6 +130,7 @@ export function SkillHubPanel(props: SkillHubPanelProps): React.JSX.Element {
126
130
  </span>
127
131
  : null}
128
132
  {catalog !== null && !catalog.complete ? <span className={css.hint}>{tt('panel.incomplete')}</span> : null}
133
+ {catalog !== null && (catalog.duplicateNames?.length ?? 0) > 0 ? <button type='button' className={css.opBtn} title={tt('row.duplicateHint')} onClick={() => { clearListFilters() }}>⚠ {tt('row.duplicate')}×{(catalog.duplicateNames ?? []).length}</button> : null}
129
134
  <span className={css.actions}>
130
135
  <button type='button' className={css.button} disabled={updateState.status === 'checking'} title={updateTitle} onClick={() => { void checkUpdate() }}>{updateState.status === 'checking' ? tt('update.checking') : tt('update.check')}</button>
131
136
  {updateState.status === 'ready' && updateState.data.updateAvailable && updateState.data.url !== null
@@ -234,9 +239,20 @@ export function SkillHubPanel(props: SkillHubPanelProps): React.JSX.Element {
234
239
  <option value='added'>{tt('sort.added')}</option>
235
240
  <option value='uses'>{tt('sort.uses')}</option>
236
241
  </select>
242
+ <select className={css.select} value={hub.invocationFilter} onChange={(event) => { hub.setInvocationFilter(event.target.value as 'all' | 'model' | 'user') }}>
243
+ <option value='all'>{tt('filter.invocationAll')}</option>
244
+ <option value='model'>{tt('filter.modelOnly')}</option>
245
+ <option value='user'>{tt('filter.userOnly')}</option>
246
+ </select>
237
247
  <input className={css.search} value={search} onChange={(event) => { setSearch(event.target.value) }} placeholder={tt('panel.search')} />
238
248
  <button type='button' className={css.button + (editMode ? ' ' + css.primary : '')} style={{ marginLeft:'auto' }} onClick={() => setEditMode((v) => !v)}>{editMode ? '完成' : '编辑'}</button>
239
249
  </div>
250
+ {catalog !== null && (filtered.length !== catalog.skills.length || hub.invocationFilter !== 'all' || sourceFilter !== 'all') ? (
251
+ <div className={css.hintLine} style={{ margin: '2px 2px 0', display:'flex', gap:8, flexWrap:'wrap' }}>
252
+ <span>{tt('filter.showing', { shown: filtered.length, total: catalog.skills.length, filtered: hub.invocationFilter !== 'all' || sourceFilter !== 'all' ? tt('filter.filteredSuffix') : '' })}</span>
253
+ {shortenedCount > 0 ? <span title={tt('filter.shortened', { count: shortenedCount })}>{tt('filter.shortened', { count: shortenedCount })}</span> : null}
254
+ </div>
255
+ ) : null}
240
256
 
241
257
  {filtered.length === 0 && search.trim() !== '' ? <div className={css.empty}>{tt('panel.empty')}</div> : null}
242
258
  {filtered.length === 0 && search.trim() === '' && catalog.skills.length === 0 && catalog.disabled.length === 0 && catalog.diagnostics.length === 0
@@ -267,9 +283,20 @@ export function SkillHubPanel(props: SkillHubPanelProps): React.JSX.Element {
267
283
  <section className={css.section}>
268
284
  <div className={css.sectionTitle}>{tt('panel.diagnostics')}</div>
269
285
  {catalog.diagnostics.map((entry) => (
270
- <div key={entry.path} className={css.diagRow}>
271
- <div className={css.diagPath}>{entry.path}</div>
272
- <div className={css.diagReason}>{entry.reason}</div>
286
+ <div key={entry.path} className={css.diagRow} style={{ display:'flex', alignItems:'flex-start', gap:10 }}>
287
+ <div style={{ flex:1, minWidth:0 }}>
288
+ <div className={css.diagPath}>{entry.path}</div>
289
+ <div className={css.diagReason}>{entry.reason}</div>
290
+ </div>
291
+ {entry.fixable === true ? (
292
+ <button
293
+ type='button'
294
+ className={css.opBtn}
295
+ disabled={fixingPaths.has(entry.path)}
296
+ onClick={() => { void hub.fixDiagnostic(entry.path) }}
297
+ style={{ flex:'none', alignSelf:'center' }}
298
+ >{fixingPaths.has(entry.path) ? tt('diag.fixing') : tt('diag.fix')}</button>
299
+ ) : null}
273
300
  </div>
274
301
  ))}
275
302
  </section>
@@ -310,6 +337,17 @@ export function SkillHubPanel(props: SkillHubPanelProps): React.JSX.Element {
310
337
  />
311
338
  ) : null}
312
339
 
340
+ {hub.versionDialog !== null ? (
341
+ <VersionChoiceDialog
342
+ choice={hub.versionDialog}
343
+ busy={hub.versionBusy}
344
+ onSelect={(selected) => { hub.setVersionDialog({ ...hub.versionDialog!, selected }) }}
345
+ onCustom={(custom) => { hub.setVersionDialog({ ...hub.versionDialog!, custom }) }}
346
+ onCancel={() => { hub.setVersionDialog(null) }}
347
+ onConfirm={() => { void hub.confirmVersionDialog() }}
348
+ />
349
+ ) : null}
350
+
313
351
  {marketSyncDialog !== null ? (
314
352
  <MarketSyncDialog
315
353
  dialog={marketSyncDialog}
@@ -6,7 +6,7 @@
6
6
  import type { JSX, KeyboardEvent } from 'react'
7
7
  import type { CatalogSkill } from '../../protocol.ts'
8
8
  import { IconTrashOutline16 } from '../icons.tsx'
9
- import { tt } from '../helpers.ts'
9
+ import { isDisplayNameDistinct, tt } from '../helpers.ts'
10
10
  import { dotStyle, relativeTimeText } from './format.ts'
11
11
  import type { SkillHubState } from './useSkillHub.ts'
12
12
  import css from './panel.module.css'
@@ -16,6 +16,7 @@ export function SkillRow(props: { skill: CatalogSkill; hub: SkillHubState }): JS
16
16
  const stat = hub.uses.get(skill.name)
17
17
  const count = stat?.count ?? 0
18
18
  const lastUsed = stat?.lastUsed
19
+ const isDuplicate = hub.catalog?.duplicateNames?.includes(skill.name) === true
19
20
  /** 单一状态圆点:模型可调 → 蓝;否则用户可调 → 绿。与聊天 / 菜单同规则。 */
20
21
  const dot = skill.invocation.modelInvocable
21
22
  ? <span className={css.dot + ' ' + css.dotModel} style={dotStyle(hub.hubConfig?.dotModelColor)} title={tt('legend.model')} />
@@ -42,11 +43,13 @@ export function SkillRow(props: { skill: CatalogSkill; hub: SkillHubState }): JS
42
43
  <div className={css.rowMain}>
43
44
  <div className={css.rowName}>
44
45
  <span className={css.rowNameText}>{skill.name}</span>
46
+ {isDisplayNameDistinct(skill.name, skill.displayName) ? <span className={css.displayName} title={skill.displayName}>{skill.displayName}</span> : null}
45
47
  {count > 0 && hub.hubConfig?.showUseCount !== false ? <span className={css.useCount}>{count}</span> : null}
46
48
  {dot}
49
+ {isDuplicate ? <span className={css.badge + ' ' + css.statusError} title={tt('row.duplicateHint')}>{tt('row.duplicate')}</span> : null}
47
50
  {lastUsed !== undefined && hub.hubConfig?.showUseTime !== false ? <span className={css.useTime}>{relativeTimeText(lastUsed)}</span> : null}
48
51
  </div>
49
- <div className={css.rowDesc}>{skill.description}</div>
52
+ <div className={css.rowDesc} title={skill.description}>{skill.shortDescription ?? skill.description}</div>
50
53
  </div>
51
54
  {skill.writable
52
55
  ? <>
@@ -61,21 +61,6 @@ export function SourcesView(props: { hub: SkillHubState }): JSX.Element {
61
61
  next.splice(to, 0, moved)
62
62
  void hub.reorderSourceGroups(next)
63
63
  }
64
- // 兼容旧逻辑:若用户拖的是 collection 且未使用顶层排序,回退到 collectionReorder(保留旧数据兼容)
65
- const handleCollectionDropLegacy = (targetName: string): void => {
66
- // 仅当顶层排序未启用时(storedTopOrder 为空)才用旧接口
67
- if ((groupsState?.sourceGroupOrder?.length ?? 0) > 0) { handleTopDrop('col:' + targetName); return }
68
- const dragCol = topDragKey !== null && topDragKey.startsWith('col:') ? topDragKey.slice(4) : null
69
- if (dragCol === null || dragCol === targetName || groupsState === null) return
70
- const names = groupsState.collections.map((c) => c.name)
71
- const from = names.indexOf(dragCol)
72
- const to = names.indexOf(targetName)
73
- if (from === -1 || to === -1) return
74
- const next = [...names]
75
- const [moved] = next.splice(from, 1)
76
- next.splice(to, 0, moved)
77
- void hub.reorderCollections(next)
78
- }
79
64
 
80
65
  // 空状态:没有任何分组时提示
81
66
  const isEmptyTop = !hasProject && collections.length === 0 && !hasPersonal
@@ -230,7 +215,7 @@ export function SourcesView(props: { hub: SkillHubState }): JSX.Element {
230
215
  <>
231
216
  {skills.map((skill) => <SkillRow key={skill.name} skill={skill} hub={hub} />)}
232
217
  {disabledMembers.map((record) => (
233
- <DisabledRow key={record.name} record={record} busy={busyNames.has(record.name)} onEnable={() => { void enableDisabled(record) }} />
218
+ <DisabledRow key={record.name} record={record} busy={busyNames.has(record.name)} duplicate={catalog?.duplicateNames?.includes(record.name) === true} onEnable={() => { void enableDisabled(record) }} onOpen={() => { void hub.openDetail(record.name) }} />
234
219
  ))}
235
220
  </>
236
221
  ) : null}
@@ -272,7 +257,7 @@ export function SourcesView(props: { hub: SkillHubState }): JSX.Element {
272
257
  {!collapsed ? (
273
258
  <>
274
259
  {uncategorized.map((skill) => <SkillRow key={skill.name} skill={skill} hub={hub} />)}
275
- {personalDisabled.map((record) => (<DisabledRow key={record.name} record={record} busy={busyNames.has(record.name)} onEnable={() => { void enableDisabled(record) }} />))}
260
+ {personalDisabled.map((record) => (<DisabledRow key={record.name} record={record} busy={busyNames.has(record.name)} duplicate={catalog?.duplicateNames?.includes(record.name) === true} onEnable={() => { void enableDisabled(record) }} onOpen={() => { void hub.openDetail(record.name) }} />))}
276
261
  </>
277
262
  ) : null}
278
263
  </section>
@@ -35,6 +35,17 @@ export interface BranchChoiceState {
35
35
  selected: string
36
36
  }
37
37
 
38
+ /** Version picker for a market source: releases + branches + custom ref. */
39
+ export interface VersionChoiceState {
40
+ repo: string
41
+ current?: string
42
+ releases: string[]
43
+ branches: string[]
44
+ selected: string
45
+ custom: string
46
+ loading: boolean
47
+ }
48
+
38
49
  /** Post-sync skill update dialog: which tracked skills to refresh. */
39
50
  export interface MarketSyncDialogState {
40
51
  repo: string
@@ -148,6 +159,56 @@ export function BranchChoiceDialog(props: {
148
159
  )
149
160
  }
150
161
 
162
+ /** Version picker: switch a market source's tracked ref (release / branch / custom). */
163
+ export function VersionChoiceDialog(props: {
164
+ choice: VersionChoiceState
165
+ busy: boolean
166
+ onSelect: (ref: string) => void
167
+ onCustom: (custom: string) => void
168
+ onCancel: () => void
169
+ onConfirm: () => void
170
+ }): JSX.Element {
171
+ const { choice, busy, onSelect, onCustom, onCancel, onConfirm } = props
172
+ const effective = choice.custom.trim() !== '' ? choice.custom.trim() : choice.selected
173
+ return (
174
+ <DialogShell onClose={onCancel}>
175
+ <h3 className={css.dialogTitle}>{tt('market.versionTitle')}</h3>
176
+ <p className={css.dialogText}>{tt('market.versionText', { repo: choice.repo })}{choice.current !== undefined ? ` (${tt('market.versionCurrent', { ref: choice.current })})` : ''}</p>
177
+ {choice.loading ? <p className={css.dialogText}>{tt('market.scanning')}</p> : (
178
+ <>
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
+ </>
196
+ ) : null}
197
+ <p className={css.dialogText} style={{ marginBottom: 4 }}>{tt('market.versionCustom')}</p>
198
+ <input className={css.input + ' ' + css.dialogSelect} value={choice.custom}
199
+ onChange={(event) => { onCustom(event.target.value) }} placeholder='v1.2.3 / main / abc1234' />
200
+ </>
201
+ )}
202
+ <div className={css.dialogActions}>
203
+ <button type='button' className={css.button} onClick={onCancel}>{tt('form.cancel')}</button>
204
+ <button type='button' className={css.button + ' ' + css.primary} disabled={busy || choice.loading || effective === ''} onClick={onConfirm}>
205
+ {busy ? tt('source.syncing') : tt('market.versionConfirm', { ref: effective })}
206
+ </button>
207
+ </div>
208
+ </DialogShell>
209
+ )
210
+ }
211
+
151
212
  /** Post-sync selection: which tracked skills to batch-update to the new version. */
152
213
  export function MarketSyncDialog(props: {
153
214
  dialog: MarketSyncDialogState
@@ -44,15 +44,9 @@
44
44
  .rowNameText { font-weight: 600; }
45
45
  .rowDesc { font-size: 12.5px; opacity: .52; margin-top: 1px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
46
46
 
47
- /* Quiet inline metadata (e.g. invocation count) — no chips. */
48
- .rowMeta { font-size: 11.5px; opacity: .45; flex: none; white-space: nowrap; }
49
-
50
47
  /* No badge chips on list rows; keep only for the detail head. */
51
48
  .badges { display: flex; gap: 6px; align-items: center; flex: none; }
52
49
  .badge { font-size: 10.5px; padding: 1px 7px; border-radius: 999px; border: 0.5px solid rgba(128,128,128,.3); opacity: .8; white-space: nowrap; }
53
- .badgeModel { color: var(--hub-model); border-color: currentColor; }
54
- .badgeUser { color: var(--hub-user); border-color: currentColor; }
55
- .badgeUses { color: #d29922; border-color: currentColor; }
56
50
  .badgeReadonly { opacity: .5; }
57
51
 
58
52
  /* iOS-style switch (kept, already matches the system look). */
@@ -102,6 +96,7 @@
102
96
  .dot { display: inline-block; width: 7px; height: 7px; border-radius: 50%; margin-left: 5px; vertical-align: middle; }
103
97
  .dotModel { background: var(--hub-model); }
104
98
  .dotUser { background: var(--hub-user); }
99
+ .displayName { font-size: 11px; opacity: .55; margin-left: 6px; font-weight: 400; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 160px; min-width: 0; flex-shrink: 1; }
105
100
  .legend { display: flex; align-items: center; gap: 12px; font-size: 11.5px; opacity: .55; flex-wrap: wrap; padding: 2px 2px 0; }
106
101
  .legendItem { display: inline-flex; align-items: center; gap: 4px; }
107
102
  .legendItem .dot { margin-left: 0; }
@@ -110,7 +105,6 @@
110
105
  /* Chips become quiet text pills (no colored borders). */
111
106
  .badgeSource { background: rgba(128,128,128,.1); border: none; opacity: .72; }
112
107
  .badgeCount { background: rgba(128,128,128,.1); border: none; opacity: .72; }
113
- .badgeDisabled { background: rgba(128,128,128,.1); border: none; opacity: .58; }
114
108
 
115
109
  /* Collapsible drawer headers (groups view). */
116
110
  .disclosure { flex: 1; min-width: 0; display: flex; align-items: center; gap: 7px; padding: 2px 0; border: none; background: none; color: inherit; cursor: pointer; text-align: left; min-height: 24px; }
@@ -210,7 +204,6 @@
210
204
  .rowMuted { cursor: default; opacity: .55; }
211
205
  .actionsPadded { padding: 8px 12px; }
212
206
  .actionsTop { margin-top: 8px; }
213
- .actionsBottom { margin-bottom: 8px; }
214
207
  /* Group-header last-used time: inline after the count chip (overrides useTime's right-align). */
215
208
  .groupTime { margin-left: 6px; }
216
209
  .sectionHeadRow { display: flex; align-items: center; gap: 8px; }