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
@@ -24,7 +24,6 @@ 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 { DisabledRow } from './DisabledRow.tsx'
28
27
  import { BranchChoiceDialog, ConfirmDialog, ConflictDialog, MarketSyncDialog } from './dialogs.tsx'
29
28
  import { useSkillHub } from './useSkillHub.ts'
30
29
  import css from './panel.module.css'
@@ -42,14 +41,14 @@ export function SkillHubPanel(props: SkillHubPanelProps): React.JSX.Element {
42
41
  formRoot, formBusy, formMessage, hubConfig, tab, skillView, sourceFilter, sortKey, search,
43
42
  workspace, setWorkspace,
44
43
  sourcesState, tagBusy, busyNames, normalized, origins, sourceOptions, filtered,
45
- conflictDialog, confirmDialog, deleteSkillDialog, confirmClearTrash, branchChoice, branchBusy, marketSyncDialog,
44
+ conflictDialog, confirmDialog, deleteSkillDialog, deleteGroupDialog, confirmClearTrash, branchChoice, branchBusy, marketSyncDialog,
46
45
  syncBusy, editingTag, editName, membersDraft, editSearch, uses, groupsState, sourceCheck, checkingSource, syncingSource,
47
- showLegend,
46
+ showLegend, editMode,
48
47
  setLoadError, setSuccessBanner, setDetail, setShowForm, setFormName, setFormDesc, setFormRoot, setFormMessage, setTab,
49
- setSkillView, setSourceFilter, setSortKey, setSearch, setConflictDialog, setConfirmDialog, setDeleteSkillDialog,
48
+ setSkillView, setSourceFilter, setSortKey, setSearch, setConflictDialog, setConfirmDialog, setDeleteSkillDialog, setDeleteGroupDialog,
50
49
  setConfirmClearTrash, setBranchChoice, setMarketSyncDialog, setEditingTag, setEditName, setMembersDraft, setEditSearch,
51
- setShowLegend,
52
- checkUpdate, loadMarket, checkSources, requestSync, requestDelete, restoreTrash, clearTrash, runDeleteSkill,
50
+ setShowLegend, setEditMode,
51
+ checkUpdate, loadMarket, checkSources, requestSync, requestDelete, restoreTrash, clearTrash, runDeleteSkill, runDeleteGroup,
53
52
  runConfirmed, resolveConflict, confirmBranchChoice, confirmMarketSync, create, saveTag, deleteTag, enableDisabled,
54
53
  } = hub
55
54
 
@@ -236,6 +235,7 @@ export function SkillHubPanel(props: SkillHubPanelProps): React.JSX.Element {
236
235
  <option value='uses'>{tt('sort.uses')}</option>
237
236
  </select>
238
237
  <input className={css.search} value={search} onChange={(event) => { setSearch(event.target.value) }} placeholder={tt('panel.search')} />
238
+ <button type='button' className={css.button + (editMode ? ' ' + css.primary : '')} style={{ marginLeft:'auto' }} onClick={() => setEditMode((v) => !v)}>{editMode ? '完成' : '编辑'}</button>
239
239
  </div>
240
240
 
241
241
  {filtered.length === 0 && search.trim() !== '' ? <div className={css.empty}>{tt('panel.empty')}</div> : null}
@@ -263,19 +263,6 @@ export function SkillHubPanel(props: SkillHubPanelProps): React.JSX.Element {
263
263
  </section>
264
264
  ) : null}
265
265
 
266
- {catalog.disabled.length > 0 ? (
267
- <section className={css.section}>
268
- <div className={css.sectionTitle}>{tt('panel.disabled')}</div>
269
- {catalog.disabled
270
- .filter((record) =>
271
- (normalized.length === 0 || record.name.toLocaleLowerCase().includes(normalized) || record.description.toLocaleLowerCase().includes(normalized))
272
- && (sourceFilter === 'all' || (origins[record.name] ?? PRIVATE_SOURCE) === sourceFilter))
273
- .map((record) => (
274
- <DisabledRow key={record.name} record={record} busy={busyNames.has(record.name)} onEnable={() => { void enableDisabled(record) }} />
275
- ))}
276
- </section>
277
- ) : null}
278
-
279
266
  {catalog.diagnostics.length > 0 ? (
280
267
  <section className={css.section}>
281
268
  <div className={css.sectionTitle}>{tt('panel.diagnostics')}</div>
@@ -349,6 +336,18 @@ export function SkillHubPanel(props: SkillHubPanelProps): React.JSX.Element {
349
336
  />
350
337
  ) : null}
351
338
 
339
+ {deleteGroupDialog !== null ? (
340
+ <ConfirmDialog
341
+ title={tt('source.deleteGroupTitle')}
342
+ text={tt('source.deleteGroupText', { name: deleteGroupDialog.name, count: deleteGroupDialog.skillNames.length })}
343
+ items={deleteGroupDialog.skillNames}
344
+ confirmLabel={tt('source.deleteGroup')}
345
+ danger
346
+ onCancel={() => { setDeleteGroupDialog(null) }}
347
+ onConfirm={() => { void runDeleteGroup() }}
348
+ />
349
+ ) : null}
350
+
352
351
  {confirmClearTrash ? (
353
352
  <ConfirmDialog
354
353
  title={tt('source.clearTrashConfirmTitle')}
@@ -50,14 +50,6 @@ export function SkillRow(props: { skill: CatalogSkill; hub: SkillHubState }): JS
50
50
  </div>
51
51
  {skill.writable
52
52
  ? <>
53
- <button
54
- type='button'
55
- className={css.opBtn + ' ' + css.opDanger + ' ' + css.iconBtn}
56
- disabled={hub.busyNames.has(skill.name) || hub.tagBusy}
57
- title={tt('row.delete')}
58
- aria-label={tt('row.delete')}
59
- onClick={(event) => { event.stopPropagation(); hub.requestDeleteSkill(skill.name) }}
60
- ><IconTrashOutline16 size={14} /></button>
61
53
  <button
62
54
  type='button'
63
55
  role='switch'
@@ -67,6 +59,14 @@ export function SkillRow(props: { skill: CatalogSkill; hub: SkillHubState }): JS
67
59
  disabled={hub.busyNames.has(skill.name)}
68
60
  onClick={(event) => { event.stopPropagation(); void hub.toggle(skill, false) }}
69
61
  ><span className={css.switchThumb} /></button>
62
+ {hub.editMode ? <button
63
+ type='button'
64
+ className={css.opBtn + ' ' + css.opDanger + ' ' + css.iconBtn}
65
+ disabled={hub.busyNames.has(skill.name) || hub.tagBusy}
66
+ title={tt('row.delete')}
67
+ aria-label={tt('row.delete')}
68
+ onClick={(event) => { event.stopPropagation(); hub.requestDeleteSkill(skill.name) }}
69
+ ><IconTrashOutline16 size={14} /></button> : null}
70
70
  </>
71
71
  : <span className={css.badge + ' ' + css.badgeReadonly}>{tt('row.readonly')}</span>}
72
72
  </div>
@@ -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; }