diff --git a/CHANGELOG.md b/CHANGELOG.md index bfe9920..ee5096a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ version tags such as `v0.3.0`. ## Unreleased +- Move User/Remote classification for collection imports into one collection-header control that applies to all actionable pending children, blocks mixed-type collection requests until explicitly resolved, and preserves per-child selection and import requests. - Add collection-level Import Review selection that selects or clears every eligible child in one collection without changing blocked, imported, system, conflicted, or unresolved-type children. - Recognize copied installer skills as one Installed Source Collection when their valid v3 GitHub provenance uses a shared package-level `pluginName`. diff --git a/apps/desktop/src/App.import-candidates.test.js b/apps/desktop/src/App.import-candidates.test.js index 95e9d4f..7a16985 100644 --- a/apps/desktop/src/App.import-candidates.test.js +++ b/apps/desktop/src/App.import-candidates.test.js @@ -9,8 +9,10 @@ import { collectionEligibleGroupIds, collectionSelectionState, collectionSkillCountLabel, + collectionTypeChoiceState, importCandidateGroupLocationCount, importCandidateGroupTabs, + importReviewSelectableGroups, normalizeGithubSkillCollectionPreviewResult, normalizeImportCollections, normalizeImportCandidateGroup, @@ -19,9 +21,12 @@ import { selectedImportCandidates, selectedImportCollectionRequests, selectImportCandidateVariant, + toggleImportCandidateGroup, toggleImportCandidateGroupSelection, toggleImportCollectionSelection, + toggleImportReviewSelection, updateImportCandidateGroupType, + updateImportCollectionType, visibleImportCandidates, workspaceSkillTabs } from './importCandidates.js'; @@ -468,7 +473,7 @@ test('toggles every eligible child in one complete collection without changing b variants: [{ id: 'variant-beta', candidate: { name: 'beta', import_status: 'importable', is_selected: true }, - selected_type: 'remote' + selected_type: 'user' }] }, { @@ -519,7 +524,6 @@ test('toggles every eligible child in one complete collection without changing b children: [ { group_id: 'skill-alpha', variant_id: 'variant-alpha', import_status: 'importable' }, { group_id: 'skill-beta', variant_id: 'variant-beta', import_status: 'importable' }, - { group_id: 'skill-needs-type', variant_id: 'variant-needs-type', import_status: 'importable', requires_type_review: true }, { group_id: 'skill-conflict', variant_id: 'variant-conflict', import_status: 'importable', conflict: 'managed target' }, { group_id: 'skill-imported', variant_id: 'variant-imported', import_status: 'imported' } ] @@ -558,7 +562,7 @@ test('collection selection uses the complete collection rather than a filtered c { id: 'skill-hidden-by-filter', selected_variant_id: 'variant-hidden', - variants: [{ id: 'variant-hidden', candidate: { name: 'hidden', import_status: 'importable', is_selected: false }, selected_type: 'remote' }] + variants: [{ id: 'variant-hidden', candidate: { name: 'hidden', import_status: 'importable', is_selected: false }, selected_type: 'user' }] }, { id: 'skill-standalone', @@ -581,6 +585,227 @@ test('collection selection uses the complete collection rather than a filtered c assert.equal(collectionSelectionState(toggled, completeCollection).selectedCount, 2); }); +test('collection type choice resolves and selects every actionable child without changing excluded or unrelated groups', () => { + const groups = normalizeImportCandidateGroups([ + { + id: 'skill-unresolved', + name: 'unresolved', + selected_variant_id: 'variant-unresolved', + variants: [{ + id: 'variant-unresolved', + candidate: { name: 'unresolved', import_status: 'importable', is_selected: false }, + requires_type_review: true, + selected_type: null + }] + }, + { + id: 'skill-explicit', + name: 'explicit', + selected_variant_id: 'variant-explicit', + variants: [{ + id: 'variant-explicit', + candidate: { name: 'explicit', import_status: 'importable', is_selected: false }, + selected_type: 'remote' + }] + }, + { + id: 'skill-imported', + name: 'imported', + selected_variant_id: 'variant-imported', + variants: [{ + id: 'variant-imported', + candidate: { name: 'imported', import_status: 'imported', is_selected: false }, + selected_type: 'remote' + }] + }, + { + id: 'skill-system', + name: 'system', + selected_variant_id: 'variant-system', + variants: [{ + id: 'variant-system', + candidate: { name: 'system', import_status: 'system', is_selected: false }, + selected_type: null + }] + }, + { + id: 'skill-conflict', + name: 'conflict', + selected_variant_id: 'variant-conflict', + variants: [{ + id: 'variant-conflict', + candidate: { name: 'conflict', import_status: 'importable', conflict: 'managed target', is_selected: false }, + selected_type: 'user' + }] + }, + { + id: 'skill-other-collection', + name: 'other', + selected_variant_id: 'variant-other', + variants: [{ + id: 'variant-other', + candidate: { name: 'other', import_status: 'importable', is_selected: false }, + selected_type: 'user' + }] + }, + { + id: 'skill-standalone', + name: 'standalone', + selected_variant_id: 'variant-standalone', + variants: [{ + id: 'variant-standalone', + candidate: { name: 'standalone', import_status: 'importable', is_selected: false }, + selected_type: 'remote' + }] + } + ]); + const collection = normalizeImportCollections([{ + id: 'collection-one', + source_kind: 'git_worktree', + preview_id: 'preview-one', + canonical_worktree_root: '/Users/example/collection-one', + children: [ + { group_id: 'skill-unresolved', variant_id: 'variant-unresolved', relative_path: 'skills/unresolved', import_status: 'importable', requires_type_review: true }, + { group_id: 'skill-explicit', variant_id: 'variant-explicit', relative_path: 'skills/explicit', import_status: 'importable', selected_type: 'remote' }, + { group_id: 'skill-imported', variant_id: 'variant-imported', relative_path: 'skills/imported', import_status: 'imported', selected_type: 'user' }, + { group_id: 'skill-system', variant_id: 'variant-system', relative_path: 'skills/system', import_status: 'system' }, + { group_id: 'skill-conflict', variant_id: 'variant-conflict', relative_path: 'skills/conflict', import_status: 'importable', conflict: 'managed target' } + ] + }])[0]; + + assert.deepEqual(collectionTypeChoiceState(groups, collection), { + actionableGroupIds: new Set(['skill-unresolved', 'skill-explicit']), + actionableCount: 2, + selectedType: null, + required: true + }); + const mixedExplicitGroups = toggleImportCandidateGroup( + updateImportCandidateGroupType(groups, 'skill-unresolved', 'user'), + 'skill-explicit' + ); + assert.deepEqual(collectionTypeChoiceState(mixedExplicitGroups, collection), { + actionableGroupIds: new Set(['skill-unresolved', 'skill-explicit']), + actionableCount: 2, + selectedType: null, + required: true + }); + assert.deepEqual(collectionSelectionState(mixedExplicitGroups, collection), { + eligibleGroupIds: new Set(), + eligibleCount: 0, + selectedCount: 0, + allSelected: false, + indeterminate: false + }); + assert.deepEqual(selectedImportCollectionRequests(mixedExplicitGroups, [collection]), []); + assert.deepEqual( + importReviewSelectableGroups(mixedExplicitGroups, [collection]).map((group) => group.id), + ['skill-other-collection', 'skill-standalone'] + ); + const toggledReview = toggleImportReviewSelection(mixedExplicitGroups, [collection]); + assert.equal(toggledReview[0].isSelected, true); + assert.equal(toggledReview[1].isSelected, true); + assert.equal(toggledReview[5].isSelected, true); + assert.equal(toggledReview[6].isSelected, true); + assert.deepEqual(selectedImportCandidates(mixedExplicitGroups, [{ + ...collection, + sourceKind: 'installed_source' + }]), []); + + const asUser = updateImportCollectionType(groups, collection, 'user'); + assert.deepEqual(asUser.slice(0, 2).map((group) => ({ + selected: group.isSelected, + type: group.variants[0].selectedType + })), [ + { selected: true, type: 'user' }, + { selected: true, type: 'user' } + ]); + assert.deepEqual(asUser.slice(2).map((group) => ({ + selected: group.isSelected, + type: group.variants[0].selectedType + })), [ + { selected: false, type: 'remote' }, + { selected: false, type: 'user' }, + { selected: false, type: 'user' }, + { selected: false, type: 'user' }, + { selected: false, type: 'remote' } + ]); + assert.deepEqual(collectionTypeChoiceState(asUser, collection), { + actionableGroupIds: new Set(['skill-unresolved', 'skill-explicit']), + actionableCount: 2, + selectedType: 'user', + required: false + }); + assert.deepEqual(collectionSelectionState(asUser, collection), { + eligibleGroupIds: new Set(['skill-unresolved', 'skill-explicit']), + eligibleCount: 2, + selectedCount: 2, + allSelected: true, + indeterminate: false + }); + + const manuallyDeselected = toggleImportCandidateGroup(asUser, 'skill-unresolved'); + assert.equal(collectionTypeChoiceState(manuallyDeselected, collection).selectedType, 'user'); + assert.equal(collectionSelectionState(manuallyDeselected, collection).selectedCount, 1); + + const asRemote = updateImportCollectionType(manuallyDeselected, collection, 'remote'); + assert.deepEqual(asRemote.slice(0, 2).map((group) => ({ + selected: group.isSelected, + type: group.variants[0].selectedType + })), [ + { selected: true, type: 'remote' }, + { selected: true, type: 'remote' } + ]); + assert.deepEqual(selectedImportCollectionRequests(asRemote, [collection])[0].selections, [ + { relativePath: 'skills/unresolved', groupId: 'skill-unresolved', variantId: 'variant-unresolved', skillType: 'remote' }, + { relativePath: 'skills/explicit', groupId: 'skill-explicit', variantId: 'variant-explicit', skillType: 'remote' } + ]); +}); + +test('collection type choice uses the complete collection and preserves Rust managed child types', () => { + const groups = normalizeImportCandidateGroups([ + { + id: 'skill-visible', + selected_variant_id: 'variant-visible', + variants: [{ id: 'variant-visible', candidate: { name: 'visible', import_status: 'importable', is_selected: false }, selected_type: null }] + }, + { + id: 'skill-hidden', + selected_variant_id: 'variant-hidden', + variants: [{ id: 'variant-hidden', candidate: { name: 'hidden', import_status: 'importable', is_selected: false }, selected_type: null }] + }, + { + id: 'skill-imported', + selected_variant_id: 'variant-imported', + variants: [{ id: 'variant-imported', candidate: { name: 'imported', import_status: 'imported', is_selected: false }, selected_type: 'remote' }] + } + ]); + const completeCollection = normalizeImportCollections([{ + id: 'collection-filtered', + children: [ + { group_id: 'skill-visible', variant_id: 'variant-visible', import_status: 'importable' }, + { group_id: 'skill-hidden', variant_id: 'variant-hidden', import_status: 'importable' }, + { group_id: 'skill-imported', variant_id: 'variant-imported', import_status: 'imported', selected_type: 'user' } + ] + }])[0]; + + const updated = updateImportCollectionType(groups, completeCollection, 'remote'); + assert.deepEqual(updated.slice(0, 2).map((group) => group.variants[0].selectedType), ['remote', 'remote']); + assert.deepEqual(updated.slice(0, 2).map((group) => group.isSelected), [true, true]); + assert.equal(updated[2].variants[0].selectedType, 'remote'); + + const importedState = collectionChildTypeState(updated[2], completeCollection.children[2]); + assert.equal(importedState.childType, 'user'); + assert.equal(importedState.readOnlyLabel, 'Managed as User'); + assert.deepEqual(collectionTypeChoiceState([], { + children: [completeCollection.children[2]] + }), { + actionableGroupIds: new Set(), + actionableCount: 0, + selectedType: null, + required: false + }); +}); + test('normalizes a GitHub collection without inventing a local worktree root', () => { const collections = normalizeImportCollections([{ id: 'github-collection-demo', @@ -758,10 +983,8 @@ test('collection child type state keeps mixed importable review actionable and b assert.equal(mixedState.canSelect, false); assert.equal(mixedState.needsTypeChoice, true); - const resolvedState = collectionChildTypeState(mixedGroup, { - ...mixedChild, - selectedType: 'remote' - }); + const resolvedGroup = updateImportCandidateGroupType([mixedGroup], 'skill-mixed', 'remote')[0]; + const resolvedState = collectionChildTypeState(resolvedGroup, mixedChild); assert.equal(resolvedState.canSelect, true); assert.equal(resolvedState.needsTypeChoice, false); diff --git a/apps/desktop/src/App.jsx b/apps/desktop/src/App.jsx index 89cfe54..0cfcc5f 100644 --- a/apps/desktop/src/App.jsx +++ b/apps/desktop/src/App.jsx @@ -57,8 +57,9 @@ import { selectImportCandidateVariant, toggleImportCollectionSelection, toggleImportCandidateGroup, - toggleImportCandidateGroupSelection, - updateImportCandidateGroupType + toggleImportReviewSelection, + updateImportCandidateGroupType, + updateImportCollectionType } from './importCandidates.js'; import { browserImportScanOptions, @@ -1465,7 +1466,7 @@ export default function App() { function toggleAllImportCandidates() { setImportReview((current) => ({ ...current, - candidates: toggleImportCandidateGroupSelection(current.candidates) + candidates: toggleImportReviewSelection(current.candidates, current.collections) })); } @@ -4537,6 +4538,12 @@ export default function App() { candidates: toggleImportCollectionSelection(current.candidates, collection) })) } + onCollectionTypeChange={(collection, skillType) => + setImportReview((current) => ({ + ...current, + candidates: updateImportCollectionType(current.candidates, collection, skillType) + })) + } onToggleSelected={(group) => updateImportCandidateGroup(group.id, (groups) => toggleImportCandidateGroup(groups, group.id)) } diff --git a/apps/desktop/src/cardLayout.test.js b/apps/desktop/src/cardLayout.test.js index 6b29b09..e792c66 100644 --- a/apps/desktop/src/cardLayout.test.js +++ b/apps/desktop/src/cardLayout.test.js @@ -544,7 +544,7 @@ test('import review opens before scanning and exposes staged accessible progress assert.match(progressSource, /importScanProgressDetail/); }); -test('collection review keeps child selection and type controls inside one expandable card', () => { +test('collection review keeps child selection and collection type controls inside one expandable card', () => { const collectionSource = appSource.match( /function CollectionReviewCard\(\{(?[\s\S]*?)\n\}\n\nfunction WorkspaceSkillTabs/ )?.groups.body || ''; @@ -557,28 +557,58 @@ test('collection review keeps child selection and type controls inside one expan const collectionRule = css.match(/\.collectionReviewCard\s*\{(?[^}]*)\}/s)?.groups.body || ''; const childRule = css.match(/\.collectionChildRow\s*\{(?[^}]*)\}/s)?.groups.body || ''; const collectionActionsRule = css.match(/\.collectionReviewActions\s*\{(?[^}]*)\}/s)?.groups.body || ''; + const collectionTypeRule = css.match(/\.collectionReviewTypeAction\s*\{(?[^}]*)\}/s)?.groups.body || ''; + const requiredCollectionTypeRule = css.match( + /\.collectionReviewTypeAction\.required\s*\{(?[^}]*)\}/s + )?.groups.body || ''; assert.match(collectionSource, /aria-controls={disclosureId}/); assert.match(collectionSource, /collection\.children\.map/); assert.match(collectionSource, /collectionSelectionState\(groups, selectionCollection\)/); + assert.match(collectionSource, /collectionTypeChoiceState\(groups, selectionCollection\)/); assert.match(collectionSource, /Import as/); + assert.match(collectionSource, /Required<\/strong>/); + assert.match(collectionSource, /Choose one type for every pending skill in this collection/); + assert.match(collectionSource, /aria-describedby=\{typeState\.required \? typeHelpId : undefined\}/); + assert.match(collectionSource, /aria-labelledby=\{typeLabelId\}/); + assert.match(collectionSource, /aria-required=\{typeState\.required\}/); + assert.match(collectionSource, /role="radiogroup"/); + assert.match(collectionSource, /role="radio"/); + assert.match(collectionSource, /aria-checked=\{typeState\.selectedType === 'user'\}/); + assert.match(collectionSource, /aria-checked=\{typeState\.selectedType === 'remote'\}/); + assert.match(collectionSource, /onCollectionTypeChange\(selectionCollection, 'user'\)/); + assert.match(collectionSource, /onCollectionTypeChange\(selectionCollection, 'remote'\)/); + assert.match(collectionSource, /typeState\.actionableCount > 0/); + assert.doesNotMatch(collectionSource, /No pending skills/); + assert.match(collectionSource, /const selected = !typeState\.required/); + assert.match(collectionSource, /disabled=\{!canSelect \|\| typeState\.required \|\| selectionDisabled\}/); + assert.match(appSource, /const selectableCount = importReviewSelectableGroups\(groups, collections\)\.length/); + assert.match(appSource, /selectedImportCollectionRequests\(groups, collections\)/); assert.match(collectionCheckboxSource, /checkboxRef\.current\.indeterminate = indeterminate/); assert.match(collectionCheckboxSource, /aria-label=\{`Select all eligible skills in \$\{collectionName\}`\}/); assert.match(collectionCheckboxSource, /aria-describedby=\{ariaDescribedBy\}/); assert.match(collectionCheckboxSource, /disabled=\{disabled\}/); assert.match(candidateReviewListSource, /selectionCollection=\{collections\.find/); assert.match(candidateReviewListSource, /onToggleCollectionSelected/); + assert.match(candidateReviewListSource, /onCollectionTypeChange/); + assert.match(appSource, /updateImportCollectionType\(current\.candidates, collection, skillType\)/); + assert.match(appComponentSource, /toggleImportReviewSelection\(current\.candidates, current\.collections\)/); assert.match(collectionSource, /onToggleSelected\(group\)/); - assert.match(collectionSource, /onTypeChange\(group, 'user'\)/); - assert.match(collectionSource, /onTypeChange\(group, 'remote'\)/); - assert.match(collectionSource, /collectionChildTypeState\(group, child\)/); assert.match(collectionSource, /collectionChildTypeState\(group, child\)/); assert.match(collectionSource, /collection\.sourceKind === 'installed_source'/); assert.doesNotMatch(collectionSource, /disabled=\{!canClassifyImportCandidateGroup\(group\)\}/); + assert.doesNotMatch(collectionSource, /aria-label=\{`\$\{child\.name\} skill type`\}/); + assert.doesNotMatch(collectionSource, /onTypeChange\(group/); + assert.match(collectionSource, /readOnlyLabel \?/); assert.match(collectionSource, /relativePath/); assert.match(collectionRule, /min-width:\s*0;/); assert.match(collectionActionsRule, /display:\s*flex;/); + assert.match(collectionTypeRule, /width:\s*248px;/); + assert.match(collectionTypeRule, /box-sizing:\s*border-box;/); + assert.match(requiredCollectionTypeRule, /border-color:\s*var\(--skillbox-amber-border\);/); + assert.match(requiredCollectionTypeRule, /background:\s*var\(--skillbox-surface-orange\);/); assert.match(css, /\.candidateCheck input:indeterminate \+ span/); assert.match(childRule, /grid-template-columns:\s*28px minmax\(0,\s*1fr\) auto;/); }); diff --git a/apps/desktop/src/components/importReview.jsx b/apps/desktop/src/components/importReview.jsx index 1711358..825e290 100644 --- a/apps/desktop/src/components/importReview.jsx +++ b/apps/desktop/src/components/importReview.jsx @@ -7,6 +7,7 @@ import { collectionChildTypeState, collectionSelectionState, collectionSkillCountLabel, + collectionTypeChoiceState, filterImportCandidateGroups, filterImportCandidateGroupsByQuery, filterImportCollectionsByQuery, @@ -14,9 +15,12 @@ import { importCandidateGroupLocationCount, importCandidateGroupStatus, importCandidateGroupTabs, + importReviewSelectableGroups, isSelectableImportCandidateGroup, selectedImportCandidate, - selectedImportCandidateVariant + selectedImportCandidateVariant, + selectedImportCandidates, + selectedImportCollectionRequests } from '../importCandidates.js'; import { candidateImportSourcePaths, @@ -209,6 +213,7 @@ export function ImportReview({ scanProgress = null, onClose, onImport, + onCollectionTypeChange, onRetry, onToggleAll, onToggleCollectionSelected, @@ -219,10 +224,10 @@ export function ImportReview({ subtitle = 'Confirm each skill type before SkillBox copies it into the managed store.', title = 'Import Review' }) { - const selectableCount = groups.filter(isSelectableImportCandidateGroup).length; - const selectedCount = groups.filter( - (group) => group.isSelected && isSelectableImportCandidateGroup(group) - ).length; + const selectableCount = importReviewSelectableGroups(groups, collections).length; + const selectedCount = selectedImportCandidates(groups, collections).length + + selectedImportCollectionRequests(groups, collections) + .reduce((count, request) => count + request.selections.length, 0); const isAllSelected = selectableCount > 0 && selectedCount === selectableCount; return ( @@ -267,6 +272,7 @@ export function ImportReview({ candidate.id === collection.id) || collection} selectionDisabled={selectionDisabled} /> @@ -440,10 +447,10 @@ function CollectionSelectionCheckbox({ function CollectionReviewCard({ collection, groups, + onCollectionTypeChange, onSelectVariant, onToggleCollectionSelected, onToggleSelected, - onTypeChange, selectionCollection, selectionDisabled }) { @@ -452,7 +459,10 @@ function CollectionReviewCard({ const isGithubRemote = collection.sourceKind === 'github_remote'; const disclosureId = `${collection.id}-children`; const selectionSummaryId = `${collection.id}-selection-summary`; + const typeLabelId = `${collection.id}-type-label`; + const typeHelpId = `${collection.id}-type-help`; const selectionState = collectionSelectionState(groups, selectionCollection); + const typeState = collectionTypeChoiceState(groups, selectionCollection); const shortSha = collection.reviewedHeadSha ? collection.reviewedHeadSha.slice(0, 8) : 'uncommitted'; return ( @@ -486,19 +496,60 @@ function CollectionReviewCard({ )}
- onToggleCollectionSelected(selectionCollection)} - /> - - {selectionState.eligibleCount === 0 - ? 'No eligible skills' - : `${selectionState.selectedCount} of ${selectionState.eligibleCount} eligible selected`} - + {typeState.actionableCount > 0 ? ( +
+
+ Import as + {typeState.required ? Required : null} +
+ {typeState.required ? ( +

Choose one type for every pending skill in this collection.

+ ) : null} +
+ + +
+
+ ) : null} +
+ onToggleCollectionSelected(selectionCollection)} + /> + + {selectionState.eligibleCount === 0 + ? 'No eligible skills' + : `${selectionState.selectedCount} of ${selectionState.eligibleCount} eligible selected`} + +
- - - - ) : ( + {readOnlyLabel ? ( +
{readOnlyLabel} - )} -
+ + ) : null} ); })} diff --git a/apps/desktop/src/importCandidates.js b/apps/desktop/src/importCandidates.js index 3ef7f14..2e36c7e 100644 --- a/apps/desktop/src/importCandidates.js +++ b/apps/desktop/src/importCandidates.js @@ -198,6 +198,7 @@ export function filterImportCollectionsByQuery(collections = [], query = '') { export function selectedImportCollectionRequests(groups = [], collections = []) { return collections.filter((collection) => ['git_worktree', 'github_remote'].includes(collection.sourceKind)).map((collection) => { + if (collectionTypeChoiceState(groups, collection).required) return null; const selections = collection.children .map((child) => { const group = groups.find((candidateGroup) => candidateGroup.id === child.groupId); @@ -255,7 +256,9 @@ export function collectionChildTypeState(group, child) { ...group, selectedVariantId: child.variantId }); - const childType = child.selectedType || (selectedVariant ? variant?.selectedType : null); + const childType = importableChild && selectedVariant && variant + ? variant.selectedType + : child.selectedType || (selectedVariant ? variant?.selectedType : null); const canSelect = canClassify && Boolean(childType); const needsTypeChoice = canClassify && child.requiresTypeReview && !childType; const readOnlyLabel = child.importStatus === 'imported' && childType @@ -264,9 +267,11 @@ export function collectionChildTypeState(group, child) { ? 'Resolve conflict before import' : child.importStatus === 'system' ? 'System skill' - : childType - ? `${childType === 'remote' ? 'Remote' : 'User'} suggestion` - : 'Choose a variant first'; + : !selectedVariant + ? 'Choose a variant first' + : child.importStatus !== 'importable' && childType + ? `${childType === 'remote' ? 'Remote' : 'User'} (read only)` + : ''; return { childType, @@ -279,6 +284,82 @@ export function collectionChildTypeState(group, child) { }; } +function collectionActionableGroups(groups = [], collection = {}) { + const groupById = new Map(groups.map((group) => [group.id, group])); + const actionable = []; + const seenGroupIds = new Set(); + + for (const child of collection.children || []) { + const group = groupById.get(child.groupId); + if ( + !group + || seenGroupIds.has(group.id) + || group.selectedVariantId !== child.variantId + || child.importStatus !== 'importable' + || child.conflict + || !canClassifyImportCandidateGroup(group) + ) { + continue; + } + const variant = selectedImportCandidateVariant(group); + if (!variant || variant.id !== child.variantId) continue; + seenGroupIds.add(group.id); + actionable.push({ group, variant }); + } + + return actionable; +} + +export function collectionTypeChoiceState(groups = [], collection = {}) { + const actionable = collectionActionableGroups(groups, collection); + const selectedTypes = new Set( + actionable + .map(({ variant }) => variant.selectedType) + .filter((type) => ['user', 'remote'].includes(type)) + ); + const allHaveType = actionable.length > 0 + && actionable.every(({ variant }) => ['user', 'remote'].includes(variant.selectedType)); + const selectedType = allHaveType && selectedTypes.size === 1 + ? [...selectedTypes][0] + : null; + + return { + actionableGroupIds: new Set(actionable.map(({ group }) => group.id)), + actionableCount: actionable.length, + selectedType, + required: actionable.length > 0 && !selectedType + }; +} + +export function collectionTypeReviewGroupIds(groups = [], collections = []) { + const groupIds = new Set(); + for (const collection of collections) { + const typeState = collectionTypeChoiceState(groups, collection); + if (!typeState.required) continue; + for (const groupId of typeState.actionableGroupIds) groupIds.add(groupId); + } + return groupIds; +} + +export function updateImportCollectionType(groups = [], collection = {}, skillType) { + if (!['user', 'remote'].includes(skillType)) return groups; + const { actionableGroupIds } = collectionTypeChoiceState(groups, collection); + + return groups.map((group) => actionableGroupIds.has(group.id) + ? { + ...group, + isSelected: true, + variants: group.variants.map((variant) => variant.id === group.selectedVariantId + ? { + ...variant, + selectedType: skillType, + candidate: { ...variant.candidate, skillType } + } + : variant) + } + : group); +} + export function collectionSkillCountLabel(count) { const normalized = Math.max(0, Math.trunc(Number(count) || 0)); return `${normalized} ${normalized === 1 ? 'skill' : 'skills'}`; @@ -336,6 +417,7 @@ export function toggleImportCandidateGroupSelection(groups, targetGroups = group } export function collectionEligibleGroupIds(groups = [], collection = {}) { + if (collectionTypeChoiceState(groups, collection).required) return new Set(); const groupById = new Map(groups.map((group) => [group.id, group])); const eligibleIds = new Set(); @@ -379,13 +461,30 @@ export function toggleImportCollectionSelection(groups = [], collection = {}) { ); } +export function importReviewSelectableGroups(groups = [], collections = []) { + const typeReviewGroupIds = collectionTypeReviewGroupIds(groups, collections); + return groups.filter((group) => ( + isSelectableImportCandidateGroup(group) + && !typeReviewGroupIds.has(group.id) + )); +} + +export function toggleImportReviewSelection(groups = [], collections = []) { + return toggleImportCandidateGroupSelection( + groups, + importReviewSelectableGroups(groups, collections) + ); +} + export function selectedImportCandidates(groups = [], collections = []) { const liveCollectionGroupIds = importCollectionGroupIds(collections, { liveOnly: true }); + const typeReviewGroupIds = collectionTypeReviewGroupIds(groups, collections); return groups .filter((group) => ( group.isSelected && isSelectableImportCandidateGroup(group) && !liveCollectionGroupIds.has(group.id) + && !typeReviewGroupIds.has(group.id) )) .map(selectedImportCandidate); } diff --git a/apps/desktop/src/styles.css b/apps/desktop/src/styles.css index 8f5663e..944e92e 100644 --- a/apps/desktop/src/styles.css +++ b/apps/desktop/src/styles.css @@ -6332,10 +6332,67 @@ dd { .collectionReviewActions { display: flex; flex: 0 0 auto; - align-items: center; + align-items: flex-start; justify-content: flex-end; + gap: 12px; + min-width: 0; +} + +.collectionReviewTypeAction { + display: grid; + width: 248px; + box-sizing: border-box; + gap: 6px; + border: 1px solid transparent; + border-radius: 8px; + padding: 8px; +} + +.collectionReviewTypeAction.required { + border-color: var(--skillbox-amber-border); + background: var(--skillbox-surface-orange); +} + +.collectionReviewTypeHeader { + display: flex; + align-items: center; + justify-content: space-between; gap: 8px; + color: var(--skillbox-text-secondary); + font-size: 12px; + font-weight: 700; +} + +.collectionReviewTypeHeader strong, +.collectionReviewTypeAction p { + color: var(--skillbox-orange-text); +} + +.collectionReviewTypeHeader strong { + font-size: 10px; + text-transform: uppercase; +} + +.collectionReviewTypeAction p { + margin: 0; + font-size: 11px; + line-height: 1.35; +} + +.collectionReviewTypeAction .candidateTypeSwitch { + width: 100%; +} + +.collectionReviewTypeAction .candidateTypeSwitch button { min-width: 0; + flex: 1 1 0; +} + +.collectionReviewSelectionAction { + display: flex; + min-height: 38px; + align-items: center; + gap: 8px; } .collectionReviewSelectAll { @@ -6455,22 +6512,6 @@ dd { font-weight: 650; } -.collectionChildType .candidateTypeSwitch { - width: 156px; -} - -.collectionChildType .candidateTypeSwitch button { - min-width: 0; - flex: 1 1 0; -} - -.collectionChildType.required { - border: 1px solid var(--skillbox-amber-border); - border-radius: 8px; - background: var(--skillbox-surface-orange); - padding: 6px; -} - .collectionChildTypeReadOnly { max-width: 156px; color: var(--skillbox-text-muted); @@ -7025,6 +7066,18 @@ dd { } @media (max-width: 1100px) { + .collectionReviewHeader { + align-items: stretch; + flex-direction: column; + gap: 10px; + } + + .collectionReviewActions { + width: 100%; + align-items: flex-end; + justify-content: space-between; + } + .usageRankingControls { grid-template-columns: repeat(3, minmax(0, 1fr)); } @@ -7123,7 +7176,16 @@ dd { } .collectionReviewActions { + align-items: stretch; + flex-direction: column; + justify-content: flex-end; + } + + .collectionReviewTypeAction { width: 100%; + } + + .collectionReviewSelectionAction { justify-content: flex-end; } @@ -7143,10 +7205,6 @@ dd { text-align: left; } - .collectionChildType .candidateTypeSwitch { - width: min(100%, 240px); - } - .importScanError { align-items: flex-start; flex-direction: column; diff --git a/docs/workflows.md b/docs/workflows.md index e6a2eea..c8afd55 100644 --- a/docs/workflows.md +++ b/docs/workflows.md @@ -62,6 +62,7 @@ Claude、OpenClaw、Cursor、Claude Code、Copilot 等需要通过 agent adapter - 已 imported 的多个 runtime symlink 只有解析到同一个 managed `real_path` 时才作为 alias 合并。 - Rust 先按规范化 skill name 输出一组,再按完整目录快照、状态和冲突划分 variants。等价副本是一个 variant 的 locations;User/Remote 类型建议不参与内容 identity。同名但有实质差异的来源仍在同一张卡片内,并标记 `Needs review`。 - Import Review 默认折叠 locations;展开后显示每个路径、symlink source、location 级类型建议、状态和冲突。搜索匹配任一 variant/location,tab、Select all 和结果摘要按 group 计数。 +- 每张 collection card 的 header 统一提供 `Import as` User/Remote radiogroup。选择后会对完整 collection(不受搜索、Tab 或折叠状态影响)中所有 importable、无冲突且 selected variant 匹配的 pending child 设置同一类型并选中;用户随后仍可逐项取消。只要这些 actionable child 尚未形成统一类型(包括都已有类型但 User/Remote 混合),collection checkbox、child selection 和最终 apply request 都保持不可用,必须先明确选择 header type。imported、system、conflict、invalid 和 read-only child 不会被重分类。child row 不再重复类型控件,只保留必要的只读状态说明;standalone group 仍使用自身的 User/Remote control。 - 每张 collection card 的 header 都有原生 Select all eligible checkbox。它按完整 collection 而非当前搜索/Tab 可见子集切换:全部 eligible child 已选时清空,否则选中全部。imported、system、conflict、invalid、未选择对应 variant 或尚未完成 User/Remote 类型选择的 child 不计入且不会被改变;部分选择以原生 indeterminate 状态和 `selected / eligible` 计数表达。 - 只有唯一安全 importable variant 时才预选。多个 material variants 必须由用户用 radio 明确选择一个;一个 variant 内的类型建议混合时显示 `Mixed type suggestions`,并在导入前要求明确选择 User 或 Remote。Desktop 只提交所选 primary `source_path` 和分类,core 拒绝同一批次为同名 skill 提交多个来源;其它 locations 不会被修改。 @@ -119,8 +120,8 @@ GitHub multi-skill install,也不改变 child skill 的独立 managed/deploy/C Review/apply: - Collection card 显示 repository name、child count、branch/short HEAD、可选 remote 和 - compact root;展开后可搜索 child、逐项选择或 Select all applicable,并继续使用既有 - variant、User/Remote type control 和 Calls 展示。不会自动 deploy。 + compact root;header 统一选择 User/Remote type,展开后可搜索 child、逐项选择或 + Select all applicable,并继续使用既有 variant 和 Calls 展示。不会自动 deploy。 - CLI `collection-apply --collection-id --preview-id --select [--managed-root ]` 与 Tauri `apply_import_collection` 只接收结构化 child identities。`collections` CLI/Tauri