Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
237 changes: 230 additions & 7 deletions apps/desktop/src/App.import-candidates.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,10 @@ import {
collectionEligibleGroupIds,
collectionSelectionState,
collectionSkillCountLabel,
collectionTypeChoiceState,
importCandidateGroupLocationCount,
importCandidateGroupTabs,
importReviewSelectableGroups,
normalizeGithubSkillCollectionPreviewResult,
normalizeImportCollections,
normalizeImportCandidateGroup,
Expand All @@ -19,9 +21,12 @@ import {
selectedImportCandidates,
selectedImportCollectionRequests,
selectImportCandidateVariant,
toggleImportCandidateGroup,
toggleImportCandidateGroupSelection,
toggleImportCollectionSelection,
toggleImportReviewSelection,
updateImportCandidateGroupType,
updateImportCollectionType,
visibleImportCandidates,
workspaceSkillTabs
} from './importCandidates.js';
Expand Down Expand Up @@ -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'
}]
},
{
Expand Down Expand Up @@ -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' }
]
Expand Down Expand Up @@ -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',
Expand All @@ -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',
Expand Down Expand Up @@ -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);

Expand Down
13 changes: 10 additions & 3 deletions apps/desktop/src/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,8 +57,9 @@ import {
selectImportCandidateVariant,
toggleImportCollectionSelection,
toggleImportCandidateGroup,
toggleImportCandidateGroupSelection,
updateImportCandidateGroupType
toggleImportReviewSelection,
updateImportCandidateGroupType,
updateImportCollectionType
} from './importCandidates.js';
import {
browserImportScanOptions,
Expand Down Expand Up @@ -1465,7 +1466,7 @@ export default function App() {
function toggleAllImportCandidates() {
setImportReview((current) => ({
...current,
candidates: toggleImportCandidateGroupSelection(current.candidates)
candidates: toggleImportReviewSelection(current.candidates, current.collections)
}));
}

Expand Down Expand Up @@ -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))
}
Expand Down
Loading