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.
- package/README.md +3 -1
- package/README.zh.md +3 -1
- package/lib/client.js +1249 -407
- package/lib/client.js.map +1 -1
- package/lib/index.js +439 -81
- package/lib/types/client/api.d.ts +12 -2
- package/lib/types/client/locales.d.ts +13 -0
- package/lib/types/client/panel/ScenesView.d.ts +1 -1
- package/lib/types/client/panel/SourcesView.d.ts +1 -1
- package/lib/types/client/panel/useSkillHub.d.ts +20 -2
- package/lib/types/protocol.d.ts +93 -6
- package/lib/types/repo.d.ts +18 -11
- package/lib/types/store.d.ts +14 -2
- package/package.json +1 -1
- package/src/client/api.ts +61 -1
- package/src/client/locales.ts +26 -0
- package/src/client/panel/MarketView.tsx +201 -47
- package/src/client/panel/ScenesView.tsx +27 -2
- package/src/client/panel/SkillHubPanel.tsx +18 -19
- package/src/client/panel/SkillRow.tsx +8 -8
- package/src/client/panel/SourcesView.tsx +203 -26
- package/src/client/panel/panel.module.css +17 -6
- package/src/client/panel/useSkillHub.ts +219 -12
- package/src/index.ts +18 -0
- package/src/protocol.ts +89 -6
- package/src/repo.test.ts +61 -19
- package/src/repo.ts +86 -23
- package/src/routes.test.ts +18 -4
- package/src/routes.ts +269 -47
- package/src/skillfs.ts +17 -4
- package/src/store.ts +72 -4
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* "personal" card (project skills never count as personal).
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import type
|
|
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
|
-
<
|
|
113
|
-
{
|
|
114
|
-
|
|
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
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
-
</
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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:
|
|
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<
|
|
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
|
-
|
|
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
|
|
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
|
|
646
|
-
|
|
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
|
}
|