dsh-skill-hub 0.2.6 → 0.3.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.
@@ -6,7 +6,7 @@
6
6
  * "personal" card (project skills never count as personal).
7
7
  */
8
8
 
9
- import type { JSX } from 'react'
9
+ import { useState, type JSX } from 'react'
10
10
  import { tt } from '../helpers.ts'
11
11
  import { filterBySource, groupSwitchView, isProjectSource, PRIVATE_SOURCE } from '../grouping.ts'
12
12
  import { SourceStatusBadge } from './SourceStatusBadge.tsx'
@@ -101,17 +101,151 @@ function ProjectTree(props: { hub: SkillHubState }): JSX.Element | null {
101
101
 
102
102
  export function SourcesView(props: { hub: SkillHubState }): JSX.Element {
103
103
  const { hub } = props
104
- const { catalog, groupsState, skillView, sourceFilter, origins, sorted, normalized, collapsedGroups, viewNames, sourceCheck, actionNames, checkingSource, syncingSource, batchBusy, busyNames, toggleGroupCollapse, checkSources, requestSync, requestDelete, toggleGroup, enableDisabled } = hub
104
+ const { catalog, groupsState, skillView, sourceFilter, origins, sorted, normalized, collapsedGroups, viewNames, sourceCheck, actionNames, checkingSource, syncingSource, batchBusy, busyNames, toggleGroupCollapse, checkSources, requestSync, requestDelete, requestDeleteGroup, toggleGroup, enableDisabled } = hub
105
+ const [topDragKey, setTopDragKey] = useState<string | null>(null)
106
+ const [topOverKey, setTopOverKey] = useState<string | null>(null)
105
107
 
106
108
  if (skillView === 'flat') {
107
109
  return <>{filterBySource(sorted, sourceFilter, origins).map((skill) => <SkillRow key={skill.name} skill={skill} hub={hub} />)}</>
108
110
  }
109
111
 
112
+ // ----- 顶层分组统一拖拽(project / col:xxx / personal 全部可拖) -----
113
+ const projectSkillsAll = filterBySource(sorted, sourceFilter, origins).filter((skill) => isProjectSource(skill.source))
114
+ const hasProject = projectSkillsAll.length > 0
115
+ const collections = groupsState?.collections ?? []
116
+ const uncategorized = filterBySource(sorted, sourceFilter, origins).filter((skill) => origins[skill.name] === undefined && !isProjectSource(skill.source))
117
+ const personalDisabledAll = (catalog?.disabled ?? []).filter((record) => origins[record.name] === undefined)
118
+ .filter((record) => normalized.length === 0 || record.name.toLocaleLowerCase().includes(normalized) || record.description.toLocaleLowerCase().includes(normalized))
119
+ .filter((record) => sourceFilter === 'all' || sourceFilter === PRIVATE_SOURCE)
120
+ const allPersonalNamesAll = [...uncategorized.map((s) => s.name), ...personalDisabledAll.map((r) => r.name)]
121
+ const hasPersonal = allPersonalNamesAll.length > 0
122
+ const defaultTopKeys: string[] = [
123
+ ...(hasProject ? ['project'] : []),
124
+ ...collections.map((c) => 'col:' + c.name),
125
+ ...(hasPersonal ? ['uncategorized-source'] : []),
126
+ ]
127
+ const storedTopOrder = groupsState?.sourceGroupOrder ?? []
128
+ const topOrderedKeys = (() => {
129
+ if (storedTopOrder.length === 0) return defaultTopKeys
130
+ const set = new Set(storedTopOrder)
131
+ const result = storedTopOrder.filter((k) => defaultTopKeys.includes(k))
132
+ for (const k of defaultTopKeys) if (!set.has(k)) result.push(k)
133
+ // 兼容旧 collectionOrder:若 storedTopOrder 为空但旧 order 有值,已在 defaultTopKeys 中体现 collection 顺序
134
+ if (result.length === 0) return defaultTopKeys
135
+ return result
136
+ })()
137
+ const handleTopDrop = (targetKey: string): void => {
138
+ if (topDragKey === null || topDragKey === targetKey) return
139
+ const from = topOrderedKeys.indexOf(topDragKey)
140
+ const to = topOrderedKeys.indexOf(targetKey)
141
+ if (from === -1 || to === -1) return
142
+ const next = [...topOrderedKeys]
143
+ const [moved] = next.splice(from, 1)
144
+ next.splice(to, 0, moved)
145
+ void hub.reorderSourceGroups(next)
146
+ }
147
+ // 兼容旧逻辑:若用户拖的是 collection 且未使用顶层排序,回退到 collectionReorder(保留旧数据兼容)
148
+ const handleCollectionDropLegacy = (targetName: string): void => {
149
+ // 仅当顶层排序未启用时(storedTopOrder 为空)才用旧接口
150
+ if ((groupsState?.sourceGroupOrder?.length ?? 0) > 0) { handleTopDrop('col:' + targetName); return }
151
+ const dragCol = topDragKey !== null && topDragKey.startsWith('col:') ? topDragKey.slice(4) : null
152
+ if (dragCol === null || dragCol === targetName || groupsState === null) return
153
+ const names = groupsState.collections.map((c) => c.name)
154
+ const from = names.indexOf(dragCol)
155
+ const to = names.indexOf(targetName)
156
+ if (from === -1 || to === -1) return
157
+ const next = [...names]
158
+ const [moved] = next.splice(from, 1)
159
+ next.splice(to, 0, moved)
160
+ void hub.reorderCollections(next)
161
+ }
162
+
163
+ // 空状态:没有任何分组时提示
164
+ const isEmptyTop = !hasProject && collections.length === 0 && !hasPersonal
110
165
  return (
111
166
  <>
112
- <ProjectTree hub={hub} />
113
- {groupsState !== null && groupsState.collections.length === 0 ? <div className={css.empty}>{tt('groups.noCollections')}</div> : null}
114
- {groupsState?.collections.map((collection) => {
167
+ {isEmptyTop ? <div className={css.empty}>{tt('groups.noCollections')}</div> : null}
168
+ {topOrderedKeys.map((topKey) => {
169
+ // Project 顶层卡片(可拖)
170
+ if (topKey === 'project' && hasProject) {
171
+ const topCollapsed = collapsedGroups.has('project')
172
+ const isDragging = topDragKey === 'project'
173
+ const isOver = topOverKey === 'project' && topDragKey !== 'project'
174
+ // 按 workspace 聚合,与 ProjectTree 逻辑一致
175
+ const byProject = new Map<string, { title: string; skills: typeof projectSkillsAll }>()
176
+ for (const skill of projectSkillsAll) {
177
+ const key = skill.workspace ?? skill.source
178
+ const entry = byProject.get(key)
179
+ if (entry === undefined) byProject.set(key, { title: skill.workspaceTitle ?? skill.workspace ?? tt('groups.project'), skills: [skill] })
180
+ else entry.skills.push(skill)
181
+ }
182
+ return (
183
+ <section
184
+ key="project"
185
+ className={css.section + (isDragging ? ' ' + css.dragging : '') + (isOver ? ' ' + css.dragOver : '')}
186
+ draggable
187
+ onDragStart={(e) => { setTopDragKey('project'); e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', 'project') }}
188
+ onDragOver={(e) => { e.preventDefault(); if (topOverKey !== 'project') setTopOverKey('project') }}
189
+ onDragLeave={() => { if (topOverKey === 'project') setTopOverKey(null) }}
190
+ onDrop={(e) => { e.preventDefault(); handleTopDrop('project'); setTopOverKey(null) }}
191
+ onDragEnd={() => { setTopDragKey(null); setTopOverKey(null) }}
192
+ >
193
+ <div className={css.groupHead}>
194
+ <span className={css.dragHandle} aria-hidden title="拖拽调整顺序">⋮⋮</span>
195
+ <button type='button' className={css.disclosure} aria-expanded={!topCollapsed} onClick={() => { toggleGroupCollapse('project') }}>
196
+ <span className={css.chevron + (topCollapsed ? ' ' + css.chevronCollapsed : '')} />
197
+ <span className={css.groupTitle}>{tt('groups.project')} · {byProject.size}</span>
198
+ </button>
199
+ </div>
200
+ {!topCollapsed ? [...byProject.entries()].map(([key, proj]) => {
201
+ const projKey = 'project:' + key
202
+ const projCollapsed = collapsedGroups.has(projKey)
203
+ const subdivided = hub.subdividedProjects.has(key)
204
+ return (
205
+ <div key={projKey} className={css.projectNest}>
206
+ <div className={css.groupHead}>
207
+ <button type='button' className={css.disclosure} aria-expanded={!projCollapsed} onClick={() => { toggleGroupCollapse(projKey) }}>
208
+ <span className={css.chevron + (projCollapsed ? ' ' + css.chevronCollapsed : '')} />
209
+ <span className={css.groupTitle}>{proj.title} · {proj.skills.length}<GroupSummary members={proj.skills.map((s) => s.name)} hub={hub} /></span>
210
+ </button>
211
+ <span className={css.groupOps}>
212
+ <button type='button' className={css.opBtn} onClick={(event) => { event.stopPropagation(); hub.toggleSubdivide(key) }}>{subdivided ? tt('groups.merge') : tt('groups.subdivide')}</button>
213
+ </span>
214
+ </div>
215
+ {!projCollapsed ? (
216
+ subdivided ? (
217
+ <div className={css.projectNest}>
218
+ {(['project-dsh', 'project-agents'] as const).map((source) => {
219
+ const list = proj.skills.filter((skill) => skill.source === source)
220
+ if (list.length === 0) return null
221
+ const srcKey = projKey + ':' + source
222
+ const srcCollapsed = collapsedGroups.has(srcKey)
223
+ return (
224
+ <div key={srcKey} className={css.projectNest}>
225
+ <div className={css.groupHead}>
226
+ <button type='button' className={css.disclosure} aria-expanded={!srcCollapsed} onClick={() => { toggleGroupCollapse(srcKey) }}>
227
+ <span className={css.chevron + (srcCollapsed ? ' ' + css.chevronCollapsed : '')} />
228
+ <span className={css.groupTitle}>{tt(('badge.source.' + source) as 'badge.source.project-dsh' | 'badge.source.project-agents')} · {list.length}</span>
229
+ </button>
230
+ </div>
231
+ {!srcCollapsed ? list.map((skill) => <SkillRow key={skill.name} skill={skill} hub={hub} />) : null}
232
+ </div>
233
+ )
234
+ })}
235
+ </div>
236
+ ) : proj.skills.map((skill) => <SkillRow key={skill.name} skill={skill} hub={hub} />)
237
+ ) : null}
238
+ </div>
239
+ )
240
+ }) : null}
241
+ </section>
242
+ )
243
+ }
244
+ // Collection 卡片(可拖,归属顶层排序)
245
+ if (topKey.startsWith('col:')) {
246
+ const colName = topKey.slice(4)
247
+ const collection = collections.find((c) => c.name === colName)
248
+ if (collection === undefined) return null
115
249
  const skills = filterBySource(sorted, sourceFilter, origins).filter((skill) => collection.skillNames.includes(skill.name))
116
250
  const disabledMembers = (catalog?.disabled ?? []).filter((record) =>
117
251
  collection.skillNames.includes(record.name)
@@ -121,9 +255,21 @@ export function SourcesView(props: { hub: SkillHubState }): JSX.Element {
121
255
  const view = groupSwitchView(collection.skillNames, viewNames)
122
256
  const check = sourceCheck[collection.name]
123
257
  const hasWritable = collection.skillNames.some((name) => actionNames.has(name))
258
+ const isDragging = topDragKey === topKey
259
+ const isOver = topOverKey === topKey && topDragKey !== topKey
124
260
  return (
125
- <section key={'col:' + collection.name} className={css.section}>
261
+ <section
262
+ key={'col:' + collection.name}
263
+ className={css.section + (isDragging ? ' ' + css.dragging : '') + (isOver ? ' ' + css.dragOver : '')}
264
+ draggable
265
+ onDragStart={(e) => { setTopDragKey(topKey); e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', topKey) }}
266
+ onDragOver={(e) => { e.preventDefault(); if (topOverKey !== topKey) setTopOverKey(topKey) }}
267
+ onDragLeave={() => { if (topOverKey === topKey) setTopOverKey(null) }}
268
+ onDrop={(e) => { e.preventDefault(); handleTopDrop(topKey); setTopOverKey(null) }}
269
+ onDragEnd={() => { setTopDragKey(null); setTopOverKey(null) }}
270
+ >
126
271
  <div className={css.groupHead}>
272
+ <span className={css.dragHandle} aria-hidden title="拖拽调整顺序">⋮⋮</span>
127
273
  <button type='button' className={css.disclosure} aria-expanded={!collapsed} onClick={() => { toggleGroupCollapse('col:' + collection.name) }}>
128
274
  <span className={css.chevron + (collapsed ? ' ' + css.chevronCollapsed : '')} />
129
275
  <span className={css.groupTitle}>
@@ -153,6 +299,14 @@ export function SourcesView(props: { hub: SkillHubState }): JSX.Element {
153
299
  onClick={(event) => { event.stopPropagation(); toggleGroup('col:' + collection.name, collection.name, view.state) }}>
154
300
  <span className={css.switchThumb} />
155
301
  </button>
302
+ {hub.editMode ? <button
303
+ type='button'
304
+ className={css.opBtn + ' ' + css.opDanger}
305
+ title={tt('source.deleteGroupHint', { count: collection.skillNames.length })}
306
+ onClick={(event) => { event.stopPropagation(); requestDeleteGroup(collection.name, collection.skillNames) }}
307
+ >
308
+ {tt('source.deleteGroup')}
309
+ </button> : null}
156
310
  </span>
157
311
  </div>
158
312
  {!collapsed ? (
@@ -165,27 +319,50 @@ export function SourcesView(props: { hub: SkillHubState }): JSX.Element {
165
319
  ) : null}
166
320
  </section>
167
321
  )
168
- })}
169
- {(() => {
170
- // 「个人」组:无来源记录 且 非项目技能(项目技能归项目树)。
171
- const uncategorized = filterBySource(sorted, sourceFilter, origins).filter((skill) => origins[skill.name] === undefined && !isProjectSource(skill.source))
172
- if (uncategorized.length === 0) return null
173
- const collapsed = collapsedGroups.has('uncategorized-source')
174
- return (
175
- <section className={css.section}>
176
- <div className={css.groupHead}>
177
- <button type='button' className={css.disclosure} aria-expanded={!collapsed} onClick={() => { toggleGroupCollapse('uncategorized-source') }}>
178
- <span className={css.chevron + (collapsed ? ' ' + css.chevronCollapsed : '')} />
179
- <span className={css.groupTitle}>
180
- {tt('groups.personal')} · {uncategorized.length}
181
- <GroupSummary members={uncategorized.map((skill) => skill.name)} hub={hub} />
322
+ }
323
+ // Personal 顶层卡片(可拖)
324
+ if (topKey === 'uncategorized-source' && hasPersonal) {
325
+ const uncategorized = filterBySource(sorted, sourceFilter, origins).filter((skill) => origins[skill.name] === undefined && !isProjectSource(skill.source))
326
+ const personalDisabled = (catalog?.disabled ?? []).filter((record) => origins[record.name] === undefined)
327
+ .filter((record) => normalized.length === 0 || record.name.toLocaleLowerCase().includes(normalized) || record.description.toLocaleLowerCase().includes(normalized))
328
+ .filter((record) => sourceFilter === 'all' || sourceFilter === PRIVATE_SOURCE)
329
+ const allPersonalNames = [...uncategorized.map((s) => s.name), ...personalDisabled.map((r) => r.name)]
330
+ if (allPersonalNames.length === 0) return null
331
+ const collapsed = collapsedGroups.has('uncategorized-source')
332
+ const isDragging = topDragKey === 'uncategorized-source'
333
+ const isOver = topOverKey === 'uncategorized-source' && topDragKey !== 'uncategorized-source'
334
+ return (
335
+ <section
336
+ key="uncategorized-source"
337
+ className={css.section + (isDragging ? ' ' + css.dragging : '') + (isOver ? ' ' + css.dragOver : '')}
338
+ draggable
339
+ onDragStart={(e) => { setTopDragKey('uncategorized-source'); e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', 'uncategorized-source') }}
340
+ onDragOver={(e) => { e.preventDefault(); if (topOverKey !== 'uncategorized-source') setTopOverKey('uncategorized-source') }}
341
+ onDragLeave={() => { if (topOverKey === 'uncategorized-source') setTopOverKey(null) }}
342
+ onDrop={(e) => { e.preventDefault(); handleTopDrop('uncategorized-source'); setTopOverKey(null) }}
343
+ onDragEnd={() => { setTopDragKey(null); setTopOverKey(null) }}
344
+ >
345
+ <div className={css.groupHead}>
346
+ <span className={css.dragHandle} aria-hidden title="拖拽调整顺序">⋮⋮</span>
347
+ <button type='button' className={css.disclosure} aria-expanded={!collapsed} onClick={() => { toggleGroupCollapse('uncategorized-source') }}>
348
+ <span className={css.chevron + (collapsed ? ' ' + css.chevronCollapsed : '')} />
349
+ <span className={css.groupTitle}>{tt('groups.personal')} · {allPersonalNames.length}<GroupSummary members={allPersonalNames} hub={hub} /></span>
350
+ </button>
351
+ <span className={css.groupOps}>
352
+ {hub.editMode ? <button type='button' className={css.opBtn + ' ' + css.opDanger} title={tt('source.deleteGroupHint', { count: allPersonalNames.length })} onClick={(event) => { event.stopPropagation(); requestDeleteGroup(tt('groups.personal'), allPersonalNames) }}>{tt('source.deleteGroup')}</button> : null}
182
353
  </span>
183
- </button>
184
- </div>
185
- {!collapsed ? uncategorized.map((skill) => <SkillRow key={skill.name} skill={skill} hub={hub} />) : null}
186
- </section>
187
- )
188
- })()}
354
+ </div>
355
+ {!collapsed ? (
356
+ <>
357
+ {uncategorized.map((skill) => <SkillRow key={skill.name} skill={skill} hub={hub} />)}
358
+ {personalDisabled.map((record) => (<DisabledRow key={record.name} record={record} busy={busyNames.has(record.name)} onEnable={() => { void enableDisabled(record) }} />))}
359
+ </>
360
+ ) : null}
361
+ </section>
362
+ )
363
+ }
364
+ return null
365
+ })}
189
366
  </>
190
367
  )
191
368
  }
@@ -22,9 +22,9 @@
22
22
  /* Inset grouped list: one rounded card per group. */
23
23
  .section { display: flex; flex-direction: column; background: rgba(128,128,128,.06); border-radius: 12px; overflow: hidden; margin-top: 4px; box-shadow: 0 1px 2px rgba(0,0,0,.03), 0 4px 12px rgba(0,0,0,.04); }
24
24
  .sectionTitle { font-size: 13px; font-weight: 700; margin: 14px 16px 2px; }
25
- .groupHead { display: flex; align-items: center; gap: 8px; padding: 12px 14px 4px; }
26
- .groupTitle { font-size: 13px; font-weight: 600; opacity: .92; flex: 1; min-width: 0; }
27
- .groupOps { flex: none; display: inline-flex; gap: 6px; }
25
+ .groupHead { display: flex; align-items: center; gap: 8px; padding: 10px 14px; min-height: 44px; box-sizing: border-box; }
26
+ .groupTitle { font-size: 13px; font-weight: 600; opacity: .92; flex: 1; min-width: 0; display: inline-flex; align-items: center; gap: 6px; line-height: 1; }
27
+ .groupOps { flex: none; display: inline-flex; gap: 6px; align-items: center; }
28
28
 
29
29
  /* Small neutral pill buttons (group-level actions). */
30
30
  .opBtn { font-size: 11px; padding: 3px 11px; border-radius: 999px; border: none; background: rgba(128,128,128,.13); color: inherit; cursor: pointer; transition: background .15s ease; }
@@ -113,10 +113,10 @@
113
113
  .badgeDisabled { background: rgba(128,128,128,.1); border: none; opacity: .58; }
114
114
 
115
115
  /* Collapsible drawer headers (groups view). */
116
- .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; }
116
+ .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; }
117
117
  .disclosure:hover .groupTitle { opacity: 1; }
118
- .chevron { flex: none; width: 8px; height: 8px; border-right: 1.5px solid currentColor; border-bottom: 1.5px solid currentColor; transform: rotate(45deg); transition: transform .15s ease; opacity: .55; margin-right: 2px; }
119
- .chevronCollapsed { transform: rotate(-45deg); }
118
+ .chevron { flex: none; width: 8px; height: 8px; border-right: 1.5px solid currentColor; border-bottom: 1.5px solid currentColor; transform: rotate(45deg) translateY(-1px); transition: transform .15s ease; opacity: .55; margin-right: 2px; align-self: center; }
119
+ .chevronCollapsed { transform: rotate(-45deg) translateY(-1px); }
120
120
 
121
121
  /* Reduced header: title + quiet count on one line, controls on the next. */
122
122
  .headerCount { font-size: 12px; opacity: .5; margin-left: 2px; white-space: nowrap; }
@@ -226,3 +226,14 @@
226
226
  /* Project-level three-tier tree: outer card (the .section), inner levels are
227
227
  flat — indent + a hairline guide only, no nested card surfaces. */
228
228
  .projectNest { display: flex; flex-direction: column; margin: 2px 0 0 14px; border-left: 1px solid rgba(128,128,128,.14); }
229
+
230
+ /* Drag handle + reorder states */
231
+ .dragHandle { cursor: grab; opacity: .32; font-size: 11px; letter-spacing: 1px; padding: 0 4px; user-select: none; flex: none; display: inline-flex; align-items: center; justify-content: center; height: 20px; align-self: center; }
232
+ .dragHandle:active { cursor: grabbing; }
233
+ .dragging { opacity: .45; }
234
+ .dragOver { outline: 2px dashed var(--hub-model, #2f81f7); outline-offset: -2px; background: rgba(47,129,247,.07); }
235
+
236
+ /* Scan list for 276-skill import — theme-agnostic, replaces inline styles */
237
+ .scanList { max-height: 360px; overflow: auto; border: 0.5px solid rgba(128,128,128,.13); border-radius: 12px; margin-top: 8px; background: rgba(128,128,128,.06); }
238
+ .scanProgressTrack { flex: 1; height: 6px; background: rgba(128,128,128,.13); border-radius: 4px; overflow: hidden; }
239
+ .scanProgressFill { height: 100%; background: var(--hub-model, #2f81f7); transition: width .2s; }
@@ -6,7 +6,7 @@
6
6
  * and tag-editor views without violating the rules of hooks.
7
7
  */
8
8
 
9
- import { useCallback, useEffect, useMemo, useState, type FormEvent } from 'react'
9
+ import { useCallback, useEffect, useMemo, useRef, useState, type FormEvent } from 'react'
10
10
  import type {
11
11
  CatalogResponse,
12
12
  CatalogSkill,
@@ -16,6 +16,7 @@ import type {
16
16
  MarketCheckResponse,
17
17
  MarketSourceRecord,
18
18
  RepoDiscoverResponse,
19
+ RepoImportProgressResponse,
19
20
  RepoImportResponse,
20
21
  SkillDetail,
21
22
  SkillTag,
@@ -96,7 +97,9 @@ export function useSkillHub(api: SkillHubApi) {
96
97
  const [scanningRepo, setScanningRepo] = useState<string | null>(null)
97
98
  const [repoSelected, setRepoSelected] = useState<ReadonlySet<string>>(new Set())
98
99
  const [repoImporting, setRepoImporting] = useState(false)
99
- const [repoResult, setRepoResult] = useState<RepoImportResponse | null>(null)
100
+ const [repoResult, setRepoResult] = useState<RepoImportProgressResponse | null>(null)
101
+ const [importJobId, setImportJobId] = useState<string | null>(null)
102
+ const pollAbortRef = useRef<AbortController | null>(null)
100
103
  const [search, setSearch] = useState('')
101
104
  /** 工作区(项目)路径;空 = 只看用户级技能。 */
102
105
  const [workspace, setWorkspace] = useState('')
@@ -132,6 +135,7 @@ export function useSkillHub(api: SkillHubApi) {
132
135
  const [conflictDialog, setConflictDialog] = useState<ConflictDialogState | null>(null)
133
136
  const [confirmDialog, setConfirmDialog] = useState<ConfirmDialogState | null>(null)
134
137
  const [deleteSkillDialog, setDeleteSkillDialog] = useState<string | null>(null)
138
+ const [deleteGroupDialog, setDeleteGroupDialog] = useState<{ name: string; skillNames: string[] } | null>(null)
135
139
  const [confirmClearTrash, setConfirmClearTrash] = useState(false)
136
140
  /** 「全部更新」确认对话框(市场 tab)。 */
137
141
  const [updateAllDialog, setUpdateAllDialog] = useState(false)
@@ -146,6 +150,7 @@ export function useSkillHub(api: SkillHubApi) {
146
150
  /** 项目级三级树里已细分(按 .dsh/.agents)的项目键。 */
147
151
  const [subdividedProjects, setSubdividedProjects] = useState<ReadonlySet<string>>(new Set())
148
152
  const [showLegend, setShowLegend] = useState(false)
153
+ const [editMode, setEditMode] = useState(false)
149
154
 
150
155
  const toggleGroupCollapse = useCallback((key: string): void => {
151
156
  setCollapsedGroups((previous) => {
@@ -483,7 +488,37 @@ export function useSkillHub(api: SkillHubApi) {
483
488
  }
484
489
  }, [api, deleteSkillDialog, load, loadGroups, loadSources])
485
490
 
486
- // ----------------------------------------------------------- tag editing
491
+ /** 打开整组删除确认(来源分组一键删除)。 */
492
+ const requestDeleteGroup = useCallback((name: string, skillNames: string[]): void => {
493
+ setDeleteGroupDialog({ name, skillNames })
494
+ }, [])
495
+
496
+ /** 执行整组删除(逐个移入回收站,跳过只读)。 */
497
+ const runDeleteGroup = useCallback(async (): Promise<void> => {
498
+ const dialog = deleteGroupDialog
499
+ if (dialog === null) return
500
+ setDeleteGroupDialog(null)
501
+ setTagBusy(true)
502
+ setLoadError(null)
503
+ const failures: string[] = []
504
+ let done = 0
505
+ for (const name of dialog.skillNames) {
506
+ try {
507
+ await api.deleteSkill(name)
508
+ done += 1
509
+ } catch (error) {
510
+ // 只读/不存在的跳过并记录
511
+ failures.push(name + ': ' + errorMessage(error))
512
+ }
513
+ }
514
+ await Promise.all([load(), loadGroups(), loadSources()])
515
+ setTagBusy(false)
516
+ if (failures.length > 0) {
517
+ setLoadError(`删除整组 "${dialog.name}":成功 ${done} 个,失败 ${failures.length} 个:` + failures.join('; '))
518
+ } else if (done > 0) {
519
+ setSuccessBanner(`已删除整组 "${dialog.name}":${done} 个技能已移入回收站`)
520
+ }
521
+ }, [api, deleteGroupDialog, load, loadGroups, loadSources])
487
522
 
488
523
  /** 新建一个空 tag 分组。 */
489
524
  const createTag = useCallback(async (event: FormEvent): Promise<void> => {
@@ -535,6 +570,91 @@ export function useSkillHub(api: SkillHubApi) {
535
570
  }
536
571
  }, [api, applyTags])
537
572
 
573
+ /** 拖拽重排场景分组 */
574
+ const reorderTags = useCallback(async (orderedIds: string[]): Promise<void> => {
575
+ setTagBusy(true)
576
+ setLoadError(null)
577
+ try {
578
+ const tags = await api.reorderTags(orderedIds)
579
+ applyTags(tags)
580
+ } catch (error) {
581
+ const msg = errorMessage(error)
582
+ if (msg.includes('404') || msg.toLowerCase().includes('not found')) {
583
+ // 旧宿主无此路由:本地重排
584
+ const byId = new Map(groupsState?.tags.map((t) => [t.id, t] as const) ?? [])
585
+ const reordered = orderedIds.map((id) => byId.get(id)).filter((t): t is NonNullable<typeof t> => t !== undefined)
586
+ if (reordered.length === orderedIds.length) {
587
+ applyTags(reordered as typeof groupsState extends { tags: infer T } ? T : never)
588
+ setSuccessBanner('已临时调整顺序(本地生效,重启宿主后持久化)')
589
+ } else {
590
+ setLoadError(msg)
591
+ }
592
+ } else {
593
+ setLoadError(msg)
594
+ }
595
+ } finally {
596
+ setTagBusy(false)
597
+ }
598
+ }, [api, applyTags, groupsState])
599
+
600
+ /** 拖拽重排来源集合 */
601
+ const reorderCollections = useCallback(async (orderedNames: string[]): Promise<void> => {
602
+ setTagBusy(true)
603
+ setLoadError(null)
604
+ try {
605
+ const collections = await api.reorderCollections(orderedNames)
606
+ setGroupsState((prev) => prev === null ? prev : { ...prev, collections })
607
+ void loadGroups()
608
+ } catch (error) {
609
+ const msg = errorMessage(error)
610
+ // 404 说明宿主仍在跑旧版(需重启后才有新路由),降级为本地即时生效
611
+ if (msg.includes('404') || msg.toLowerCase().includes('not found')) {
612
+ setGroupsState((prev) => {
613
+ if (prev === null) return prev
614
+ const map = new Map(prev.collections.map((c) => [c.name, c] as const))
615
+ const reordered = orderedNames.map((n) => map.get(n)).filter((c): c is NonNullable<typeof c> => c !== undefined)
616
+ // 补上未在 orderedNames 中的集合(新出现的)
617
+ for (const c of prev.collections) if (!reordered.some((r) => r.name === c.name)) reordered.push(c)
618
+ return { ...prev, collections: reordered }
619
+ })
620
+ setSuccessBanner('已临时调整顺序(本地生效,重启宿主后持久化)')
621
+ } else {
622
+ setLoadError(msg)
623
+ }
624
+ } finally {
625
+ setTagBusy(false)
626
+ }
627
+ }, [api, loadGroups])
628
+
629
+ /** 拖拽重排来源顶层分组(project / col:xxx / personal 全量可拖) */
630
+ const reorderSourceGroups = useCallback(async (orderedKeys: string[]): Promise<void> => {
631
+ setTagBusy(true)
632
+ setLoadError(null)
633
+ try {
634
+ await api.reorderSourceGroups(orderedKeys)
635
+ await loadGroups()
636
+ } catch (error) {
637
+ const msg = errorMessage(error)
638
+ if (msg.includes('404') || msg.toLowerCase().includes('not found')) {
639
+ // 旧宿主无此路由:本地重排顶层顺序,提示重启后持久化
640
+ setGroupsState((prev) => {
641
+ if (prev === null) return prev
642
+ // 本地重排 collections 以匹配 topOrderedKeys 中的 col:xxx 顺序
643
+ const colOrder = orderedKeys.filter((k) => k.startsWith('col:')).map((k) => k.slice(4))
644
+ const map = new Map(prev.collections.map((c) => [c.name, c] as const))
645
+ const reordered = colOrder.map((n) => map.get(n)).filter((c): c is NonNullable<typeof c> => c !== undefined)
646
+ for (const c of prev.collections) if (!reordered.some((r) => r.name === c.name)) reordered.push(c)
647
+ return { ...prev, collections: reordered, sourceGroupOrder: orderedKeys }
648
+ })
649
+ setSuccessBanner('已临时调整顺序(本地生效,重启宿主后持久化)')
650
+ } else {
651
+ setLoadError(msg)
652
+ }
653
+ } finally {
654
+ setTagBusy(false)
655
+ }
656
+ }, [api, loadGroups])
657
+
538
658
  // -------------------------------------------------------------- market
539
659
 
540
660
  /** 添加一个市场源(内置市场目录与手动输入共用),并立即扫描它。 */
@@ -635,23 +755,110 @@ export function useSkillHub(api: SkillHubApi) {
635
755
  })
636
756
  }, [])
637
757
 
638
- /** Import every checked, non-existing repo skill, then refresh the hub. */
758
+ /** Import every checked, non-existing repo skill (B方案:job+轮询+进度). */
639
759
  const importRepo = useCallback(async (): Promise<void> => {
640
760
  if (repoDiscoverState.status !== 'ready') return
641
761
  setRepoImporting(true)
642
762
  setRepoResult(null)
763
+ setImportJobId(null)
643
764
  setLoadError(null)
765
+ let finalProgress: RepoImportProgressResponse | null = null
644
766
  try {
645
- const result = await api.repoImport(repoDiscoverState.data.repo, [...repoSelected], repoDiscoverState.data.ref ?? undefined)
646
- setRepoResult(result)
767
+ const created = await api.repoImport(repoDiscoverState.data.repo, [...repoSelected], repoDiscoverState.data.ref ?? undefined)
768
+ setImportJobId(created.jobId)
769
+ // 初始占位,让进度卡片立刻出现(带真实 totalBytes)
770
+ setRepoResult({ ok: true, jobId: created.jobId, status: 'running', total: created.total, done: 0, totalBytes: created.totalBytes, downloadedBytes: 0, imported: [], skipped: [], failed: [] })
771
+ // 轮询直到 done/cancelled/error(800ms 起步 + 退避,避免 276 技能 900 次请求)
772
+ pollAbortRef.current?.abort()
773
+ pollAbortRef.current = new AbortController()
774
+ const signal = pollAbortRef.current.signal
775
+ let attempt = 0
776
+ for (;;) {
777
+ if (signal.aborted) break
778
+ const delay = Math.min(2000, 800 + attempt * 200)
779
+ await new Promise((r) => setTimeout(r, delay))
780
+ if (signal.aborted) break
781
+ try {
782
+ const progress = await api.repoImportProgress(created.jobId)
783
+ setRepoResult(progress)
784
+ finalProgress = progress
785
+ if (progress.status !== 'running') {
786
+ break
787
+ }
788
+ attempt = 0
789
+ } catch (pollError) {
790
+ // 轮询失败退避重试
791
+ const msg = errorMessage(pollError)
792
+ if (msg.includes('not found')) break
793
+ attempt += 1
794
+ if (attempt > 8) break
795
+ }
796
+ }
647
797
  await Promise.all([load(), loadMarket(), loadGroups(), loadSources()])
648
798
  } catch (error) {
649
799
  setLoadError(errorMessage(error))
650
800
  } finally {
801
+ // 导入完成后把已导入/已跳过的项从勾选中移除,并把扫描快照里的 existing 置为 true,避免“仍可勾选已导入”的错觉
802
+ if (finalProgress !== null && (finalProgress.imported.length > 0 || finalProgress.skipped.length > 0)) {
803
+ const doneNames = new Set([...finalProgress.imported.map((r) => r.name), ...finalProgress.skipped.map((r) => r.name)])
804
+ const donePaths = new Set(
805
+ repoDiscoverState.data.entries.filter((e) => doneNames.has(e.name)).map((e) => e.path),
806
+ )
807
+ setRepoSelected((prev) => {
808
+ const next = new Set(prev)
809
+ for (const p of donePaths) next.delete(p)
810
+ return next
811
+ })
812
+ setRepoDiscoverState((prev) => {
813
+ if (prev.status !== 'ready') return prev
814
+ return {
815
+ ...prev,
816
+ data: {
817
+ ...prev.data,
818
+ entries: prev.data.entries.map((e) => doneNames.has(e.name) ? { ...e, existing: true } : e),
819
+ },
820
+ }
821
+ })
822
+ }
651
823
  setRepoImporting(false)
652
824
  }
653
825
  }, [api, repoDiscoverState, repoSelected, load, loadMarket, loadGroups, loadSources])
654
826
 
827
+ // 卸载时中断轮询,避免泄露
828
+ useEffect(() => {
829
+ return () => { pollAbortRef.current?.abort() }
830
+ }, [])
831
+
832
+ /** 取消正在进行的导入(选项2:唯有取消才停) */
833
+ const cancelImport = useCallback(async (): Promise<void> => {
834
+ if (importJobId === null) return
835
+ pollAbortRef.current?.abort()
836
+ try {
837
+ const res = await api.repoImportCancel(importJobId)
838
+ // 立刻刷新一次进度
839
+ try {
840
+ const progress = await api.repoImportProgress(importJobId)
841
+ setRepoResult(progress)
842
+ } catch {
843
+ setRepoResult((prev) => prev !== null ? { ...prev, status: res.status as 'cancelled' } : prev)
844
+ }
845
+ } catch (error) {
846
+ setLoadError(errorMessage(error))
847
+ } finally {
848
+ setRepoImporting(false)
849
+ }
850
+ }, [api, importJobId])
851
+
852
+ /** 清空扫描结果(关闭归属卡片) */
853
+ const clearScan = useCallback((): void => {
854
+ setRepoDiscoverState({ status: 'idle' })
855
+ setScanningRepo(null)
856
+ setRepoResult(null)
857
+ setRepoSelected(new Set())
858
+ setImportJobId(null)
859
+ pollAbortRef.current?.abort()
860
+ }, [])
861
+
655
862
  /** 检查所有市场源的上游更新(服务端节流)。 */
656
863
  const checkMarket = useCallback(async (): Promise<void> => {
657
864
  try {
@@ -790,22 +997,22 @@ export function useSkillHub(api: SkillHubApi) {
790
997
 
791
998
  return {
792
999
  // state
793
- catalog, loading, loadError, successBanner, updateState, repoDiscoverState, scanningRepo, repoSelected, repoImporting, repoResult,
1000
+ catalog, loading, loadError, successBanner, updateState, repoDiscoverState, scanningRepo, repoSelected, repoImporting, repoResult, importJobId,
794
1001
  search, workspace, detail, detailLoading, busyNames, batchBusy, showForm, formName, formDesc, formRoot, formBusy, formMessage,
795
1002
  uses, hubConfig, tab, skillView, sourceFilter, sortKey, marketState, marketCheck, branchChoice, branchBusy,
796
1003
  marketSyncDialog, syncingMarket, syncBusy, newSourceName, groupsState, sourcesState, sourceCheck, checkingSource, syncingSource,
797
- conflictDialog, confirmDialog, deleteSkillDialog, confirmClearTrash, updateAllDialog, editingTag, editName, membersDraft, newTagName, tagBusy,
798
- editSearch, collapsedGroups, subdividedProjects, showLegend,
1004
+ conflictDialog, confirmDialog, deleteSkillDialog, deleteGroupDialog, confirmClearTrash, updateAllDialog, editingTag, editName, membersDraft, newTagName, tagBusy,
1005
+ editSearch, collapsedGroups, subdividedProjects, showLegend, editMode,
799
1006
  // derived
800
1007
  actionNames, viewNames, normalized, origins, sourceOptions, filtered, sorted,
801
1008
  // actions + setters
802
1009
  setLoadError, setSuccessBanner, setSearch, setWorkspace, setDetail, setShowForm, setFormName, setFormDesc, setFormRoot, setFormMessage,
803
1010
  setRepoSelected, setTab, setSkillView,
804
1011
  setSourceFilter, setSortKey, setBranchChoice, setMarketSyncDialog, setNewSourceName, setConflictDialog, setConfirmDialog,
805
- setDeleteSkillDialog, setConfirmClearTrash, setUpdateAllDialog, setEditingTag, setEditName, setMembersDraft, setNewTagName, setEditSearch, setShowLegend,
1012
+ setDeleteSkillDialog, setDeleteGroupDialog, setConfirmClearTrash, setUpdateAllDialog, setEditingTag, setEditName, setMembersDraft, setNewTagName, setEditSearch, setShowLegend, setEditMode,
806
1013
  toggleGroupCollapse, toggleSubdivide, checkUpdate, loadMarket, openDetail, toggle, enableDisabled, batchToggleNames, toggleGroup, resolveConflict,
807
- runConfirmed, checkSources, requestSync, requestDelete, restoreTrash, clearTrash, requestDeleteSkill, runDeleteSkill, createTag,
808
- deleteTag, saveTag, addSource, addMarketSource, removeMarketSource, scanRepo, confirmBranchChoice, toggleRepoSelected, importRepo,
1014
+ runConfirmed, checkSources, requestSync, requestDelete, restoreTrash, clearTrash, requestDeleteSkill, runDeleteSkill, requestDeleteGroup, runDeleteGroup, createTag,
1015
+ deleteTag, saveTag, reorderTags, reorderCollections, reorderSourceGroups, addSource, addMarketSource, removeMarketSource, scanRepo, confirmBranchChoice, toggleRepoSelected, importRepo, cancelImport, clearScan,
809
1016
  checkMarket, syncMarketSource, confirmMarketSync, updateAll, create,
810
1017
  }
811
1018
  }