feat(ui): diff before importing to the catalog, undo pending changes in the review
- Add to catalog: select items, compare an item that differs from the catalog, clearer "replace the catalog version" option and submit label - Item drawer: diff against the catalog for items that differ - Review drawer: Undo per added/removed item and "Discard all"; Cancel becomes Close - "No longer enabled" rows get a switch to enable the item again - Checkboxes: stronger outline and a solid background Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
e234cf5 parent: 607e619 modified
packages/ui/app/app.config.ts +1 -1 | @@ -63,7 +63,7 @@ export default defineAppConfig({ | ||
| 63 | 63 | }, |
| 64 | 64 | |
| 65 | 65 | checkbox: { |
| 66 | - slots: { base: 'rounded-[4px] ring-sk-line', indicator: 'text-sk-onink' }, | |
| 66 | + slots: { base: 'rounded-[4px] bg-sk-panel ring-sk-faint', indicator: 'text-sk-onink' }, | |
| 67 | 67 | defaultVariants: { color: 'neutral' } |
| 68 | 68 | }, |
| 69 | 69 | |
| @@ -63,7 +63,7 @@ export default defineAppConfig({ | |||
| 63 | }, | 63 | }, |
| 64 | 64 | ||
| 65 | checkbox: { | 65 | checkbox: { |
| 66 | - slots: { base: 'rounded-[4px] ring-sk-line', indicator: 'text-sk-onink' }, | 66 | + slots: { base: 'rounded-[4px] bg-sk-panel ring-sk-faint', indicator: 'text-sk-onink' }, |
| 67 | defaultVariants: { color: 'neutral' } | 67 | defaultVariants: { color: 'neutral' } |
| 68 | }, | 68 | }, |
| 69 | 69 | ||
modified
packages/ui/app/components/AddToCatalogModal.vue +41 -11 | @@ -19,10 +19,20 @@ const result = ref<ImportResult | null>(null) | ||
| 19 | 19 | const error = ref<string | null>(null) |
| 20 | 20 | /** Ids of the items left checked: all of them when the modal opens. */ |
| 21 | 21 | const selected = ref<Set<string>>(new Set()) |
| 22 | +/** Item whose diff against the catalog is expanded. */ | |
| 23 | +const comparing = ref<string | null>(null) | |
| 24 | +const localLabel = computed(() => (props.scope === 'home' ? 'Global' : 'This project')) | |
| 22 | 25 | |
| 23 | 26 | const picked = computed(() => props.items.filter(i => selected.value.has(i.id))) |
| 24 | 27 | const differing = computed(() => picked.value.filter(i => props.statuses[i.id]?.state === 'differs-from-catalog')) |
| 25 | 28 | const toSend = computed(() => picked.value.filter(i => overwrite.value || props.statuses[i.id]?.state !== 'differs-from-catalog')) |
| 29 | +const submitLabel = computed(() => { | |
| 30 | + const n = toSend.value.length | |
| 31 | + const replaced = overwrite.value ? differing.value.length : 0 | |
| 32 | + if (!n) return differing.value.length ? 'Nothing to add' : 'Add to catalog' | |
| 33 | + if (replaced === n) return `Replace ${n} in catalog` | |
| 34 | + return replaced ? `Add ${n} to catalog (${replaced} replaced)` : `Add ${n} to catalog` | |
| 35 | +}) | |
| 26 | 36 | const allSelected = computed<boolean | 'indeterminate'>({ |
| 27 | 37 | get: () => (picked.value.length === props.items.length ? true : picked.value.length ? 'indeterminate' : false), |
| 28 | 38 | set: (v) => { selected.value = new Set(v === true ? props.items.map(i => i.id) : []) } |
| @@ -42,6 +52,7 @@ watch(open, (v) => { | ||
| 42 | 52 | result.value = null |
| 43 | 53 | error.value = null |
| 44 | 54 | selected.value = new Set(props.items.map(i => i.id)) |
| 55 | + comparing.value = null | |
| 45 | 56 | } |
| 46 | 57 | }) |
| 47 | 58 | |
| @@ -70,7 +81,7 @@ async function submit() { | ||
| 70 | 81 | </script> |
| 71 | 82 | |
| 72 | 83 | <template> |
| 73 | - <SkModal v-model:open="open" title="Add to catalog" description="Copy these items into the catalog so they can be reused in other projects. They stay enabled here, managed by skilly."> | |
| 84 | + <SkModal v-model:open="open" :width="items.some(i => statuses[i.id]?.state === 'differs-from-catalog') ? 'lg' : 'md'" title="Add to catalog" description="Copy these items into the catalog so they can be reused in other projects. They stay enabled here, managed by skilly."> | |
| 74 | 85 | <div v-if="result" class="flex flex-col gap-3"> |
| 75 | 86 | <SkNote v-if="result.imported.length" tone="ok"> |
| 76 | 87 | <span class="font-medium">{{ result.imported.length }} added</span> |
| @@ -82,18 +93,37 @@ async function submit() { | ||
| 82 | 93 | <div v-else class="flex flex-col gap-4"> |
| 83 | 94 | <SkNote v-if="error" tone="accent"><span class="font-medium">Import failed.</span> {{ error }}</SkNote> |
| 84 | 95 | <UCheckbox v-if="items.length > 1" v-model="allSelected" :label="`Select all (${picked.length}/${items.length})`" class="text-sk-text" /> |
| 85 | - <ul class="m-0 max-h-64 list-none overflow-auto rounded-[10px] border border-sk-line p-0"> | |
| 86 | - <li v-for="i in items" :key="i.id" class="flex items-center gap-2.5 border-t border-sk-line2 px-3 py-2 first:border-t-0"> | |
| 87 | - <UCheckbox v-if="items.length > 1" :model-value="selected.has(i.id)" :aria-label="`Select ${itemDisplayName(i)}`" @update:model-value="(v) => toggle(i.id, v)" /> | |
| 88 | - <span class="truncate font-medium">{{ itemDisplayName(i) }}</span> | |
| 89 | - <SkTypeTag :type="i.type" /> | |
| 90 | - <SkAgentBadge :agent="i.agent" class="text-xs" /> | |
| 91 | - <SkStatus v-if="statuses[i.id]?.state === 'differs-from-catalog'" tone="accent" label="Differs from catalog" small class="ml-auto text-xs text-sk-muted" /> | |
| 96 | + <ul class="m-0 max-h-[50vh] list-none overflow-auto rounded-[10px] border border-sk-line p-0"> | |
| 97 | + <li v-for="i in items" :key="i.id" class="border-t border-sk-line2 first:border-t-0"> | |
| 98 | + <div class="flex items-center gap-2.5 px-3 py-2"> | |
| 99 | + <UCheckbox v-if="items.length > 1" :model-value="selected.has(i.id)" :aria-label="`Select ${itemDisplayName(i)}`" @update:model-value="(v) => toggle(i.id, v)" /> | |
| 100 | + <span class="truncate font-medium">{{ itemDisplayName(i) }}</span> | |
| 101 | + <SkTypeTag :type="i.type" /> | |
| 102 | + <SkAgentBadge :agent="i.agent" class="text-xs" /> | |
| 103 | + <template v-if="statuses[i.id]?.state === 'differs-from-catalog'"> | |
| 104 | + <SkStatus tone="accent" label="Differs from catalog" small class="ml-auto text-xs text-sk-muted" /> | |
| 105 | + <SkButton | |
| 106 | + variant="ghost" | |
| 107 | + size="sm" | |
| 108 | + :label="comparing === i.id ? 'Hide diff' : 'Compare'" | |
| 109 | + @click="comparing = comparing === i.id ? null : i.id" | |
| 110 | + /> | |
| 111 | + </template> | |
| 112 | + </div> | |
| 113 | + <CatalogItemDiff v-if="comparing === i.id" :item="i" :local-label="localLabel" class="px-3 pb-3" /> | |
| 92 | 114 | </li> |
| 93 | 115 | </ul> |
| 94 | 116 | <SkNote v-if="differing.length" tone="accent"> |
| 95 | - <p class="m-0">{{ differing.length }} item{{ differing.length === 1 ? ' already exists' : 's already exist' }} in the catalog with different content.</p> | |
| 96 | - <UCheckbox v-model="overwrite" label="Overwrite the catalog version" description="Unchecked, these items are left out." class="mt-2 text-sk-text" /> | |
| 117 | + <p class="m-0 text-pretty"> | |
| 118 | + {{ differing.length }} item{{ differing.length === 1 ? ' already exists' : 's already exist' }} in the catalog with different content. | |
| 119 | + Nothing is deleted unless you overwrite: by default these items are left out and the catalog keeps its version. | |
| 120 | + </p> | |
| 121 | + <UCheckbox | |
| 122 | + v-model="overwrite" | |
| 123 | + label="Replace the catalog version with this one" | |
| 124 | + :description="`The catalog takes the content of ${localLabel === 'Global' ? 'Global' : 'this project'}. Other projects that use it see an update to apply.`" | |
| 125 | + class="mt-2 text-sk-text" | |
| 126 | + /> | |
| 97 | 127 | </SkNote> |
| 98 | 128 | <div class="flex flex-col gap-1.5"> |
| 99 | 129 | <SkSectionLabel>Tags</SkSectionLabel> |
| @@ -103,7 +133,7 @@ async function submit() { | ||
| 103 | 133 | |
| 104 | 134 | <template #footer> |
| 105 | 135 | <SkButton :label="result ? 'Close' : 'Cancel'" @click="open = false" /> |
| 106 | - <SkButton v-if="!result" variant="primary" :label="`Add ${toSend.length} to catalog`" :loading="busy" :disabled="!toSend.length" @click="submit" /> | |
| 136 | + <SkButton v-if="!result" variant="primary" :label="submitLabel" :loading="busy" :disabled="!toSend.length" @click="submit" /> | |
| 107 | 137 | </template> |
| 108 | 138 | </SkModal> |
| 109 | 139 | </template> |
| @@ -19,10 +19,20 @@ const result = ref<ImportResult | null>(null) | |||
| 19 | const error = ref<string | null>(null) | 19 | const error = ref<string | null>(null) |
| 20 | /** Ids of the items left checked: all of them when the modal opens. */ | 20 | /** Ids of the items left checked: all of them when the modal opens. */ |
| 21 | const selected = ref<Set<string>>(new Set()) | 21 | const selected = ref<Set<string>>(new Set()) |
| 22 | +/** Item whose diff against the catalog is expanded. */ | ||
| 23 | +const comparing = ref<string | null>(null) | ||
| 24 | +const localLabel = computed(() => (props.scope === 'home' ? 'Global' : 'This project')) | ||
| 22 | 25 | ||
| 23 | const picked = computed(() => props.items.filter(i => selected.value.has(i.id))) | 26 | const picked = computed(() => props.items.filter(i => selected.value.has(i.id))) |
| 24 | const differing = computed(() => picked.value.filter(i => props.statuses[i.id]?.state === 'differs-from-catalog')) | 27 | const differing = computed(() => picked.value.filter(i => props.statuses[i.id]?.state === 'differs-from-catalog')) |
| 25 | const toSend = computed(() => picked.value.filter(i => overwrite.value || props.statuses[i.id]?.state !== 'differs-from-catalog')) | 28 | const toSend = computed(() => picked.value.filter(i => overwrite.value || props.statuses[i.id]?.state !== 'differs-from-catalog')) |
| 29 | +const submitLabel = computed(() => { | ||
| 30 | + const n = toSend.value.length | ||
| 31 | + const replaced = overwrite.value ? differing.value.length : 0 | ||
| 32 | + if (!n) return differing.value.length ? 'Nothing to add' : 'Add to catalog' | ||
| 33 | + if (replaced === n) return `Replace ${n} in catalog` | ||
| 34 | + return replaced ? `Add ${n} to catalog (${replaced} replaced)` : `Add ${n} to catalog` | ||
| 35 | +}) | ||
| 26 | const allSelected = computed<boolean | 'indeterminate'>({ | 36 | const allSelected = computed<boolean | 'indeterminate'>({ |
| 27 | get: () => (picked.value.length === props.items.length ? true : picked.value.length ? 'indeterminate' : false), | 37 | get: () => (picked.value.length === props.items.length ? true : picked.value.length ? 'indeterminate' : false), |
| 28 | set: (v) => { selected.value = new Set(v === true ? props.items.map(i => i.id) : []) } | 38 | set: (v) => { selected.value = new Set(v === true ? props.items.map(i => i.id) : []) } |
| @@ -42,6 +52,7 @@ watch(open, (v) => { | |||
| 42 | result.value = null | 52 | result.value = null |
| 43 | error.value = null | 53 | error.value = null |
| 44 | selected.value = new Set(props.items.map(i => i.id)) | 54 | selected.value = new Set(props.items.map(i => i.id)) |
| 55 | + comparing.value = null | ||
| 45 | } | 56 | } |
| 46 | }) | 57 | }) |
| 47 | 58 | ||
| @@ -70,7 +81,7 @@ async function submit() { | |||
| 70 | </script> | 81 | </script> |
| 71 | 82 | ||
| 72 | <template> | 83 | <template> |
| 73 | - <SkModal v-model:open="open" title="Add to catalog" description="Copy these items into the catalog so they can be reused in other projects. They stay enabled here, managed by skilly."> | 84 | + <SkModal v-model:open="open" :width="items.some(i => statuses[i.id]?.state === 'differs-from-catalog') ? 'lg' : 'md'" title="Add to catalog" description="Copy these items into the catalog so they can be reused in other projects. They stay enabled here, managed by skilly."> |
| 74 | <div v-if="result" class="flex flex-col gap-3"> | 85 | <div v-if="result" class="flex flex-col gap-3"> |
| 75 | <SkNote v-if="result.imported.length" tone="ok"> | 86 | <SkNote v-if="result.imported.length" tone="ok"> |
| 76 | <span class="font-medium">{{ result.imported.length }} added</span> | 87 | <span class="font-medium">{{ result.imported.length }} added</span> |
| @@ -82,18 +93,37 @@ async function submit() { | |||
| 82 | <div v-else class="flex flex-col gap-4"> | 93 | <div v-else class="flex flex-col gap-4"> |
| 83 | <SkNote v-if="error" tone="accent"><span class="font-medium">Import failed.</span> {{ error }}</SkNote> | 94 | <SkNote v-if="error" tone="accent"><span class="font-medium">Import failed.</span> {{ error }}</SkNote> |
| 84 | <UCheckbox v-if="items.length > 1" v-model="allSelected" :label="`Select all (${picked.length}/${items.length})`" class="text-sk-text" /> | 95 | <UCheckbox v-if="items.length > 1" v-model="allSelected" :label="`Select all (${picked.length}/${items.length})`" class="text-sk-text" /> |
| 85 | - <ul class="m-0 max-h-64 list-none overflow-auto rounded-[10px] border border-sk-line p-0"> | 96 | + <ul class="m-0 max-h-[50vh] list-none overflow-auto rounded-[10px] border border-sk-line p-0"> |
| 86 | - <li v-for="i in items" :key="i.id" class="flex items-center gap-2.5 border-t border-sk-line2 px-3 py-2 first:border-t-0"> | 97 | + <li v-for="i in items" :key="i.id" class="border-t border-sk-line2 first:border-t-0"> |
| 87 | - <UCheckbox v-if="items.length > 1" :model-value="selected.has(i.id)" :aria-label="`Select ${itemDisplayName(i)}`" @update:model-value="(v) => toggle(i.id, v)" /> | 98 | + <div class="flex items-center gap-2.5 px-3 py-2"> |
| 88 | - <span class="truncate font-medium">{{ itemDisplayName(i) }}</span> | 99 | + <UCheckbox v-if="items.length > 1" :model-value="selected.has(i.id)" :aria-label="`Select ${itemDisplayName(i)}`" @update:model-value="(v) => toggle(i.id, v)" /> |
| 89 | - <SkTypeTag :type="i.type" /> | 100 | + <span class="truncate font-medium">{{ itemDisplayName(i) }}</span> |
| 90 | - <SkAgentBadge :agent="i.agent" class="text-xs" /> | 101 | + <SkTypeTag :type="i.type" /> |
| 91 | - <SkStatus v-if="statuses[i.id]?.state === 'differs-from-catalog'" tone="accent" label="Differs from catalog" small class="ml-auto text-xs text-sk-muted" /> | 102 | + <SkAgentBadge :agent="i.agent" class="text-xs" /> |
| 103 | + <template v-if="statuses[i.id]?.state === 'differs-from-catalog'"> | ||
| 104 | + <SkStatus tone="accent" label="Differs from catalog" small class="ml-auto text-xs text-sk-muted" /> | ||
| 105 | + <SkButton | ||
| 106 | + variant="ghost" | ||
| 107 | + size="sm" | ||
| 108 | + :label="comparing === i.id ? 'Hide diff' : 'Compare'" | ||
| 109 | + @click="comparing = comparing === i.id ? null : i.id" | ||
| 110 | + /> | ||
| 111 | + </template> | ||
| 112 | + </div> | ||
| 113 | + <CatalogItemDiff v-if="comparing === i.id" :item="i" :local-label="localLabel" class="px-3 pb-3" /> | ||
| 92 | </li> | 114 | </li> |
| 93 | </ul> | 115 | </ul> |
| 94 | <SkNote v-if="differing.length" tone="accent"> | 116 | <SkNote v-if="differing.length" tone="accent"> |
| 95 | - <p class="m-0">{{ differing.length }} item{{ differing.length === 1 ? ' already exists' : 's already exist' }} in the catalog with different content.</p> | 117 | + <p class="m-0 text-pretty"> |
| 96 | - <UCheckbox v-model="overwrite" label="Overwrite the catalog version" description="Unchecked, these items are left out." class="mt-2 text-sk-text" /> | 118 | + {{ differing.length }} item{{ differing.length === 1 ? ' already exists' : 's already exist' }} in the catalog with different content. |
| 119 | + Nothing is deleted unless you overwrite: by default these items are left out and the catalog keeps its version. | ||
| 120 | + </p> | ||
| 121 | + <UCheckbox | ||
| 122 | + v-model="overwrite" | ||
| 123 | + label="Replace the catalog version with this one" | ||
| 124 | + :description="`The catalog takes the content of ${localLabel === 'Global' ? 'Global' : 'this project'}. Other projects that use it see an update to apply.`" | ||
| 125 | + class="mt-2 text-sk-text" | ||
| 126 | + /> | ||
| 97 | </SkNote> | 127 | </SkNote> |
| 98 | <div class="flex flex-col gap-1.5"> | 128 | <div class="flex flex-col gap-1.5"> |
| 99 | <SkSectionLabel>Tags</SkSectionLabel> | 129 | <SkSectionLabel>Tags</SkSectionLabel> |
| @@ -103,7 +133,7 @@ async function submit() { | |||
| 103 | 133 | ||
| 104 | <template #footer> | 134 | <template #footer> |
| 105 | <SkButton :label="result ? 'Close' : 'Cancel'" @click="open = false" /> | 135 | <SkButton :label="result ? 'Close' : 'Cancel'" @click="open = false" /> |
| 106 | - <SkButton v-if="!result" variant="primary" :label="`Add ${toSend.length} to catalog`" :loading="busy" :disabled="!toSend.length" @click="submit" /> | 136 | + <SkButton v-if="!result" variant="primary" :label="submitLabel" :loading="busy" :disabled="!toSend.length" @click="submit" /> |
| 107 | </template> | 137 | </template> |
| 108 | </SkModal> | 138 | </SkModal> |
| 109 | </template> | 139 | </template> |
added
packages/ui/app/components/CatalogItemDiff.vue +108 -0 | new file mode 100644 | ||
| @@ -0,0 +1,108 @@ | ||
| 1 | +<script setup lang="ts"> | |
| 2 | +import type { CatalogItemDetail, FileContent, InventoryItem, SkillDetails } from '@skilly/core' | |
| 3 | + | |
| 4 | +/** | |
| 5 | + * Diff of a local item (not managed by skilly) against the catalog item of the same name, shown before an import | |
| 6 | + * replaces the catalog version. Compares the main file (SKILL.md, rule or subagent markdown) and, for skill folders, | |
| 7 | + * the file lists. A legacy command (single markdown file) is compared with the catalog SKILL.md it would become. Codex subagents (TOML rendering) and entries of shared configuration files have no standalone diff. | |
| 8 | + */ | |
| 9 | +const props = withDefaults(defineProps<{ item: InventoryItem, localLabel?: string, mode?: 'unified' | 'split' }>(), { | |
| 10 | + localLabel: 'This project', | |
| 11 | + mode: 'unified' | |
| 12 | +}) | |
| 13 | + | |
| 14 | +const { request } = useApi() | |
| 15 | + | |
| 16 | +const loading = ref(true) | |
| 17 | +const error = ref<string | null>(null) | |
| 18 | +const note = ref<string | null>(null) | |
| 19 | +const local = ref<string | undefined>() | |
| 20 | +const catalogText = ref<string | undefined>() | |
| 21 | +const onlyLocal = ref<string[]>([]) | |
| 22 | +const onlyCatalog = ref<string[]>([]) | |
| 23 | + | |
| 24 | +const stats = computed(() => (local.value !== undefined && catalogText.value !== undefined | |
| 25 | + ? diffStats(diffLines(catalogText.value, local.value)) | |
| 26 | + : null)) | |
| 27 | +const sameMain = computed(() => !!stats.value && !stats.value.added && !stats.value.removed) | |
| 28 | +/** Legacy Claude command (`commands/<name>.md`): the import turns it into SKILL.md. */ | |
| 29 | +const legacy = computed(() => props.item.type === 'skill' && props.item.source.kind === 'file' && !/[\\/]SKILL\.md$/i.test(props.item.source.path)) | |
| 30 | + | |
| 31 | +function localFile(i: InventoryItem): string | null { | |
| 32 | + if (i.type === 'skill') return i.source.kind === 'file' ? i.source.path : joinPath(i.source.path, 'SKILL.md') | |
| 33 | + if (i.type === 'rule' && i.source.kind === 'file') return i.source.path | |
| 34 | + if (i.type === 'agent' && i.source.kind === 'file' && i.agent === 'claude') return i.source.path | |
| 35 | + return null | |
| 36 | +} | |
| 37 | + | |
| 38 | +async function load() { | |
| 39 | + loading.value = true | |
| 40 | + error.value = null | |
| 41 | + note.value = null | |
| 42 | + local.value = undefined | |
| 43 | + catalogText.value = undefined | |
| 44 | + onlyLocal.value = [] | |
| 45 | + onlyCatalog.value = [] | |
| 46 | + const file = localFile(props.item) | |
| 47 | + if (!file) { | |
| 48 | + note.value = props.item.type === 'agent' | |
| 49 | + ? 'Codex subagents are rendered as TOML from the catalog markdown: no line diff.' | |
| 50 | + : 'Lives in a shared configuration file: no standalone diff.' | |
| 51 | + loading.value = false | |
| 52 | + return | |
| 53 | + } | |
| 54 | + const errors: string[] = [] | |
| 55 | + const [l, c] = await Promise.allSettled([ | |
| 56 | + request<FileContent>('/api/files', { query: { path: file }, silent: true }), | |
| 57 | + request<CatalogItemDetail>(`/api/catalog/items/${props.item.type}/${encodeURIComponent(props.item.name)}`, { silent: true }) | |
| 58 | + ]) | |
| 59 | + if (l.status === 'fulfilled') local.value = l.value.content | |
| 60 | + else errors.push(`File: ${(l.reason as Error).message}`) | |
| 61 | + if (c.status === 'fulfilled') { | |
| 62 | + catalogText.value = c.value.content | |
| 63 | + if (props.item.type === 'skill' && props.item.source.kind === 'dir') { | |
| 64 | + const mine = new Set((props.item.details as SkillDetails).files.map(f => f.replace(/\\/g, '/'))) | |
| 65 | + const theirs = new Set(c.value.files.map(f => f.path.replace(/\\/g, '/'))) | |
| 66 | + onlyLocal.value = [...mine].filter(f => !theirs.has(f)).sort() | |
| 67 | + onlyCatalog.value = [...theirs].filter(f => !mine.has(f)).sort() | |
| 68 | + } | |
| 69 | + } else errors.push(`Catalog: ${(c.reason as Error).message}`) | |
| 70 | + error.value = errors.join(' · ') || null | |
| 71 | + loading.value = false | |
| 72 | +} | |
| 73 | + | |
| 74 | +watch(() => props.item.id, load, { immediate: true }) | |
| 75 | +</script> | |
| 76 | + | |
| 77 | +<template> | |
| 78 | + <div class="flex flex-col gap-2"> | |
| 79 | + <USkeleton v-if="loading" class="h-20 w-full rounded-[10px]" /> | |
| 80 | + <template v-else> | |
| 81 | + <SkNote v-if="note">{{ note }}</SkNote> | |
| 82 | + <SkNote v-if="error" tone="accent">{{ error }}</SkNote> | |
| 83 | + <p v-if="legacy" class="m-0 text-pretty text-sk-muted"> | |
| 84 | + Legacy command (a single <code class="font-mono text-xs">.md</code> file): the import turns it into the skill's SKILL.md, | |
| 85 | + with <code class="font-mono text-xs">name</code> and <code class="font-mono text-xs">description</code> in the frontmatter. | |
| 86 | + </p> | |
| 87 | + <p v-if="stats" class="m-0 text-pretty text-sk-muted"> | |
| 88 | + <template v-if="!sameMain">{{ item.type === 'skill' && !legacy ? 'SKILL.md' : 'The file' }} differs from the catalog: {{ describeDiff(stats) }}.</template> | |
| 89 | + <template v-else-if="onlyLocal.length || onlyCatalog.length">SKILL.md is identical: the difference is in the other files of the skill.</template> | |
| 90 | + <template v-else-if="item.type === 'skill'">SKILL.md and the file list are identical: the difference is in the content of other files of the skill.</template> | |
| 91 | + <template v-else>Same text as the catalog: only the line endings or the encoding differ.</template> | |
| 92 | + </p> | |
| 93 | + <ul v-if="onlyLocal.length || onlyCatalog.length" class="m-0 flex list-none flex-col gap-0.5 p-0 font-mono text-xs"> | |
| 94 | + <li v-for="f in onlyLocal" :key="`+${f}`" class="text-sk-ok">+ {{ f }} <span class="font-sans text-sk-faint">only in {{ localLabel === 'Global' ? 'Global' : 'this project' }}</span></li> | |
| 95 | + <li v-for="f in onlyCatalog" :key="`-${f}`" class="text-sk-accent-text">- {{ f }} <span class="font-sans text-sk-faint">only in the catalog</span></li> | |
| 96 | + </ul> | |
| 97 | + <SkDiff | |
| 98 | + v-if="stats && !sameMain" | |
| 99 | + :old-text="catalogText!" | |
| 100 | + :new-text="local!" | |
| 101 | + old-label="Catalog" | |
| 102 | + :new-label="localLabel" | |
| 103 | + :mode="mode" | |
| 104 | + :context="3" | |
| 105 | + /> | |
| 106 | + </template> | |
| 107 | + </div> | |
| 108 | +</template> | |
| new file mode 100644 | |||
| @@ -0,0 +1,108 @@ | |||
| 1 | +<script setup lang="ts"> | ||
| 2 | +import type { CatalogItemDetail, FileContent, InventoryItem, SkillDetails } from '@skilly/core' | ||
| 3 | + | ||
| 4 | +/** | ||
| 5 | + * Diff of a local item (not managed by skilly) against the catalog item of the same name, shown before an import | ||
| 6 | + * replaces the catalog version. Compares the main file (SKILL.md, rule or subagent markdown) and, for skill folders, | ||
| 7 | + * the file lists. A legacy command (single markdown file) is compared with the catalog SKILL.md it would become. Codex subagents (TOML rendering) and entries of shared configuration files have no standalone diff. | ||
| 8 | + */ | ||
| 9 | +const props = withDefaults(defineProps<{ item: InventoryItem, localLabel?: string, mode?: 'unified' | 'split' }>(), { | ||
| 10 | + localLabel: 'This project', | ||
| 11 | + mode: 'unified' | ||
| 12 | +}) | ||
| 13 | + | ||
| 14 | +const { request } = useApi() | ||
| 15 | + | ||
| 16 | +const loading = ref(true) | ||
| 17 | +const error = ref<string | null>(null) | ||
| 18 | +const note = ref<string | null>(null) | ||
| 19 | +const local = ref<string | undefined>() | ||
| 20 | +const catalogText = ref<string | undefined>() | ||
| 21 | +const onlyLocal = ref<string[]>([]) | ||
| 22 | +const onlyCatalog = ref<string[]>([]) | ||
| 23 | + | ||
| 24 | +const stats = computed(() => (local.value !== undefined && catalogText.value !== undefined | ||
| 25 | + ? diffStats(diffLines(catalogText.value, local.value)) | ||
| 26 | + : null)) | ||
| 27 | +const sameMain = computed(() => !!stats.value && !stats.value.added && !stats.value.removed) | ||
| 28 | +/** Legacy Claude command (`commands/<name>.md`): the import turns it into SKILL.md. */ | ||
| 29 | +const legacy = computed(() => props.item.type === 'skill' && props.item.source.kind === 'file' && !/[\\/]SKILL\.md$/i.test(props.item.source.path)) | ||
| 30 | + | ||
| 31 | +function localFile(i: InventoryItem): string | null { | ||
| 32 | + if (i.type === 'skill') return i.source.kind === 'file' ? i.source.path : joinPath(i.source.path, 'SKILL.md') | ||
| 33 | + if (i.type === 'rule' && i.source.kind === 'file') return i.source.path | ||
| 34 | + if (i.type === 'agent' && i.source.kind === 'file' && i.agent === 'claude') return i.source.path | ||
| 35 | + return null | ||
| 36 | +} | ||
| 37 | + | ||
| 38 | +async function load() { | ||
| 39 | + loading.value = true | ||
| 40 | + error.value = null | ||
| 41 | + note.value = null | ||
| 42 | + local.value = undefined | ||
| 43 | + catalogText.value = undefined | ||
| 44 | + onlyLocal.value = [] | ||
| 45 | + onlyCatalog.value = [] | ||
| 46 | + const file = localFile(props.item) | ||
| 47 | + if (!file) { | ||
| 48 | + note.value = props.item.type === 'agent' | ||
| 49 | + ? 'Codex subagents are rendered as TOML from the catalog markdown: no line diff.' | ||
| 50 | + : 'Lives in a shared configuration file: no standalone diff.' | ||
| 51 | + loading.value = false | ||
| 52 | + return | ||
| 53 | + } | ||
| 54 | + const errors: string[] = [] | ||
| 55 | + const [l, c] = await Promise.allSettled([ | ||
| 56 | + request<FileContent>('/api/files', { query: { path: file }, silent: true }), | ||
| 57 | + request<CatalogItemDetail>(`/api/catalog/items/${props.item.type}/${encodeURIComponent(props.item.name)}`, { silent: true }) | ||
| 58 | + ]) | ||
| 59 | + if (l.status === 'fulfilled') local.value = l.value.content | ||
| 60 | + else errors.push(`File: ${(l.reason as Error).message}`) | ||
| 61 | + if (c.status === 'fulfilled') { | ||
| 62 | + catalogText.value = c.value.content | ||
| 63 | + if (props.item.type === 'skill' && props.item.source.kind === 'dir') { | ||
| 64 | + const mine = new Set((props.item.details as SkillDetails).files.map(f => f.replace(/\\/g, '/'))) | ||
| 65 | + const theirs = new Set(c.value.files.map(f => f.path.replace(/\\/g, '/'))) | ||
| 66 | + onlyLocal.value = [...mine].filter(f => !theirs.has(f)).sort() | ||
| 67 | + onlyCatalog.value = [...theirs].filter(f => !mine.has(f)).sort() | ||
| 68 | + } | ||
| 69 | + } else errors.push(`Catalog: ${(c.reason as Error).message}`) | ||
| 70 | + error.value = errors.join(' · ') || null | ||
| 71 | + loading.value = false | ||
| 72 | +} | ||
| 73 | + | ||
| 74 | +watch(() => props.item.id, load, { immediate: true }) | ||
| 75 | +</script> | ||
| 76 | + | ||
| 77 | +<template> | ||
| 78 | + <div class="flex flex-col gap-2"> | ||
| 79 | + <USkeleton v-if="loading" class="h-20 w-full rounded-[10px]" /> | ||
| 80 | + <template v-else> | ||
| 81 | + <SkNote v-if="note">{{ note }}</SkNote> | ||
| 82 | + <SkNote v-if="error" tone="accent">{{ error }}</SkNote> | ||
| 83 | + <p v-if="legacy" class="m-0 text-pretty text-sk-muted"> | ||
| 84 | + Legacy command (a single <code class="font-mono text-xs">.md</code> file): the import turns it into the skill's SKILL.md, | ||
| 85 | + with <code class="font-mono text-xs">name</code> and <code class="font-mono text-xs">description</code> in the frontmatter. | ||
| 86 | + </p> | ||
| 87 | + <p v-if="stats" class="m-0 text-pretty text-sk-muted"> | ||
| 88 | + <template v-if="!sameMain">{{ item.type === 'skill' && !legacy ? 'SKILL.md' : 'The file' }} differs from the catalog: {{ describeDiff(stats) }}.</template> | ||
| 89 | + <template v-else-if="onlyLocal.length || onlyCatalog.length">SKILL.md is identical: the difference is in the other files of the skill.</template> | ||
| 90 | + <template v-else-if="item.type === 'skill'">SKILL.md and the file list are identical: the difference is in the content of other files of the skill.</template> | ||
| 91 | + <template v-else>Same text as the catalog: only the line endings or the encoding differ.</template> | ||
| 92 | + </p> | ||
| 93 | + <ul v-if="onlyLocal.length || onlyCatalog.length" class="m-0 flex list-none flex-col gap-0.5 p-0 font-mono text-xs"> | ||
| 94 | + <li v-for="f in onlyLocal" :key="`+${f}`" class="text-sk-ok">+ {{ f }} <span class="font-sans text-sk-faint">only in {{ localLabel === 'Global' ? 'Global' : 'this project' }}</span></li> | ||
| 95 | + <li v-for="f in onlyCatalog" :key="`-${f}`" class="text-sk-accent-text">- {{ f }} <span class="font-sans text-sk-faint">only in the catalog</span></li> | ||
| 96 | + </ul> | ||
| 97 | + <SkDiff | ||
| 98 | + v-if="stats && !sameMain" | ||
| 99 | + :old-text="catalogText!" | ||
| 100 | + :new-text="local!" | ||
| 101 | + old-label="Catalog" | ||
| 102 | + :new-label="localLabel" | ||
| 103 | + :mode="mode" | ||
| 104 | + :context="3" | ||
| 105 | + /> | ||
| 106 | + </template> | ||
| 107 | + </div> | ||
| 108 | +</template> | ||
modified
packages/ui/app/components/scope/ScopeItemDrawer.vue +6 -1 | @@ -138,6 +138,11 @@ function move() { | ||
| 138 | 138 | </ul> |
| 139 | 139 | </div> |
| 140 | 140 | |
| 141 | + <div v-if="status?.state === 'differs-from-catalog' && !status.managed" class="flex flex-col gap-2"> | |
| 142 | + <SkSectionLabel>Compared with the catalog</SkSectionLabel> | |
| 143 | + <CatalogItemDiff :item="item" :local-label="home ? 'Global' : 'This project'" /> | |
| 144 | + </div> | |
| 145 | + | |
| 141 | 146 | <ScopeInstructionsViewer v-if="item.type === 'instructions'" :path="item.source.path" /> |
| 142 | 147 | <MarkdownView v-else-if="shownPath" :path="shownPath" :frontmatter="frontmatter" /> |
| 143 | 148 | <p v-else class="m-0 text-sk-faint">This item is an entry of a shared configuration file: there is no separate file to show.</p> |
| @@ -146,7 +151,7 @@ function move() { | ||
| 146 | 151 | <template v-if="status?.catalogRef || canCatalog || canMove" #footer> |
| 147 | 152 | <div class="flex w-full flex-wrap items-center justify-end gap-2"> |
| 148 | 153 | <SkButton v-if="status?.catalogRef" label="Open in catalog" @click="openInCatalog" /> |
| 149 | - <SkButton v-if="canCatalog" label="Add to catalog" @click="addToCatalog" /> | |
| 154 | + <SkButton v-if="canCatalog" :label="status?.state === 'differs-from-catalog' ? 'Import to catalog…' : 'Add to catalog'" @click="addToCatalog" /> | |
| 150 | 155 | <SkButton v-if="canMove" variant="primary" label="Move to projects…" @click="move" /> |
| 151 | 156 | </div> |
| 152 | 157 | </template> |
| @@ -138,6 +138,11 @@ function move() { | |||
| 138 | </ul> | 138 | </ul> |
| 139 | </div> | 139 | </div> |
| 140 | 140 | ||
| 141 | + <div v-if="status?.state === 'differs-from-catalog' && !status.managed" class="flex flex-col gap-2"> | ||
| 142 | + <SkSectionLabel>Compared with the catalog</SkSectionLabel> | ||
| 143 | + <CatalogItemDiff :item="item" :local-label="home ? 'Global' : 'This project'" /> | ||
| 144 | + </div> | ||
| 145 | + | ||
| 141 | <ScopeInstructionsViewer v-if="item.type === 'instructions'" :path="item.source.path" /> | 146 | <ScopeInstructionsViewer v-if="item.type === 'instructions'" :path="item.source.path" /> |
| 142 | <MarkdownView v-else-if="shownPath" :path="shownPath" :frontmatter="frontmatter" /> | 147 | <MarkdownView v-else-if="shownPath" :path="shownPath" :frontmatter="frontmatter" /> |
| 143 | <p v-else class="m-0 text-sk-faint">This item is an entry of a shared configuration file: there is no separate file to show.</p> | 148 | <p v-else class="m-0 text-sk-faint">This item is an entry of a shared configuration file: there is no separate file to show.</p> |
| @@ -146,7 +151,7 @@ function move() { | |||
| 146 | <template v-if="status?.catalogRef || canCatalog || canMove" #footer> | 151 | <template v-if="status?.catalogRef || canCatalog || canMove" #footer> |
| 147 | <div class="flex w-full flex-wrap items-center justify-end gap-2"> | 152 | <div class="flex w-full flex-wrap items-center justify-end gap-2"> |
| 148 | <SkButton v-if="status?.catalogRef" label="Open in catalog" @click="openInCatalog" /> | 153 | <SkButton v-if="status?.catalogRef" label="Open in catalog" @click="openInCatalog" /> |
| 149 | - <SkButton v-if="canCatalog" label="Add to catalog" @click="addToCatalog" /> | 154 | + <SkButton v-if="canCatalog" :label="status?.state === 'differs-from-catalog' ? 'Import to catalog…' : 'Add to catalog'" @click="addToCatalog" /> |
| 150 | <SkButton v-if="canMove" variant="primary" label="Move to projects…" @click="move" /> | 155 | <SkButton v-if="canMove" variant="primary" label="Move to projects…" @click="move" /> |
| 151 | </div> | 156 | </div> |
| 152 | </template> | 157 | </template> |
modified
packages/ui/app/components/scope/ScopeItemRow.vue +2 -1 | @@ -24,10 +24,11 @@ const menu = computed(() => [[{ label: 'Move to projects…', icon: 'i-lucide-fo | ||
| 24 | 24 | |
| 25 | 25 | const open = ref(false) |
| 26 | 26 | const faded = computed(() => props.row.excluded || props.row.kind === 'removal') |
| 27 | -const switchable = computed(() => props.row.kind === 'entry') | |
| 27 | +const switchable = computed(() => props.row.kind === 'entry' || (props.row.kind === 'removal' && props.row.inCatalog)) | |
| 28 | 28 | const switchDisabled = computed(() => props.row.excluded && !props.row.excludedExact) |
| 29 | 29 | const switchTitle = computed(() => { |
| 30 | 30 | if (switchDisabled.value) return `Excluded by the pattern ${props.row.excludePattern}` |
| 31 | + if (props.row.kind === 'removal') return 'Enable again (removed on Apply otherwise)' | |
| 31 | 32 | if (props.row.excluded) return 'Include this item' |
| 32 | 33 | return props.row.viaPreset ? 'Exclude from this project' : 'Remove from this project' |
| 33 | 34 | }) |
| @@ -24,10 +24,11 @@ const menu = computed(() => [[{ label: 'Move to projects…', icon: 'i-lucide-fo | |||
| 24 | 24 | ||
| 25 | const open = ref(false) | 25 | const open = ref(false) |
| 26 | const faded = computed(() => props.row.excluded || props.row.kind === 'removal') | 26 | const faded = computed(() => props.row.excluded || props.row.kind === 'removal') |
| 27 | -const switchable = computed(() => props.row.kind === 'entry') | 27 | +const switchable = computed(() => props.row.kind === 'entry' || (props.row.kind === 'removal' && props.row.inCatalog)) |
| 28 | const switchDisabled = computed(() => props.row.excluded && !props.row.excludedExact) | 28 | const switchDisabled = computed(() => props.row.excluded && !props.row.excludedExact) |
| 29 | const switchTitle = computed(() => { | 29 | const switchTitle = computed(() => { |
| 30 | if (switchDisabled.value) return `Excluded by the pattern ${props.row.excludePattern}` | 30 | if (switchDisabled.value) return `Excluded by the pattern ${props.row.excludePattern}` |
| 31 | + if (props.row.kind === 'removal') return 'Enable again (removed on Apply otherwise)' | ||
| 31 | if (props.row.excluded) return 'Include this item' | 32 | if (props.row.excluded) return 'Include this item' |
| 32 | return props.row.viaPreset ? 'Exclude from this project' : 'Remove from this project' | 33 | return props.row.viaPreset ? 'Exclude from this project' : 'Remove from this project' |
| 33 | }) | 34 | }) |
modified
packages/ui/app/components/scope/ScopeItemsSection.vue +4 -0 | @@ -106,6 +106,10 @@ function onOpen(r: ScopeRow) { | ||
| 106 | 106 | } |
| 107 | 107 | |
| 108 | 108 | function onToggle(row: ScopeRow, on: boolean) { |
| 109 | + if (row.kind === 'removal') { | |
| 110 | + if (on) scope.manage([{ type: row.type, name: row.name }]) | |
| 111 | + return | |
| 112 | + } | |
| 109 | 113 | scope.edit((c) => { |
| 110 | 114 | const t = row.type |
| 111 | 115 | if (row.excluded || row.viaPreset) { |
| @@ -106,6 +106,10 @@ function onOpen(r: ScopeRow) { | |||
| 106 | } | 106 | } |
| 107 | 107 | ||
| 108 | function onToggle(row: ScopeRow, on: boolean) { | 108 | function onToggle(row: ScopeRow, on: boolean) { |
| 109 | + if (row.kind === 'removal') { | ||
| 110 | + if (on) scope.manage([{ type: row.type, name: row.name }]) | ||
| 111 | + return | ||
| 112 | + } | ||
| 109 | scope.edit((c) => { | 113 | scope.edit((c) => { |
| 110 | const t = row.type | 114 | const t = row.type |
| 111 | if (row.excluded || row.viaPreset) { | 115 | if (row.excluded || row.viaPreset) { |
modified
packages/ui/app/components/scope/ScopeReviewDrawer.vue +34 -1 | @@ -5,6 +5,7 @@ import type { HomeNames } from '../../utils/scopeRows' | ||
| 5 | 5 | /** |
| 6 | 6 | * Review & apply drawer (brief 4.1, 4.2): actions grouped by kind, files and config keys, plan warnings, missing |
| 7 | 7 | * items, conflicts with the staged resolution and a side by side diff. Nothing is written until Apply. |
| 8 | + * Changes that come from a config edit (an item enabled or disabled) can be undone here, one by one or all at once. | |
| 8 | 9 | * The result (applied / skipped / errors) is shown in place. |
| 9 | 10 | */ |
| 10 | 11 | const props = defineProps<{ scopeId: string, plan: SyncPlan, home: boolean }>() |
| @@ -62,6 +63,18 @@ function toggle(id: string) { | ||
| 62 | 63 | |
| 63 | 64 | const filesOf = (a: PlanAction) => a.files.length + a.configKeys.length |
| 64 | 65 | const isHide = (a: PlanAction) => hideIds.value.has(a.id) |
| 66 | +const canUndo = (a: PlanAction) => !isHide(a) && undoKind(a, scope.inCatalog(a.type, a.name)) !== null | |
| 67 | +const undoable = computed(() => actions.value.filter(canUndo)) | |
| 68 | +const undoing = ref(false) | |
| 69 | + | |
| 70 | +async function undo(list: PlanAction[]) { | |
| 71 | + undoing.value = true | |
| 72 | + try { | |
| 73 | + await scope.undo(list) | |
| 74 | + } finally { | |
| 75 | + undoing.value = false | |
| 76 | + } | |
| 77 | +} | |
| 65 | 78 | const reasonOf = (a: PlanAction) => { |
| 66 | 79 | if (isHide(a)) return a.kind === 'remove' ? 'Stops hiding this Global item in the project' : 'Hides this Global item in the project (Claude Code only)' |
| 67 | 80 | return a.conflict ? `${CONFLICT_HEADLINE[a.conflict]}${a.catalogUpdated ? ' · the catalog changed too' : ''}` : a.reason |
| @@ -158,6 +171,16 @@ const kindLabel = (k: PlanActionKind) => PLAN_GROUPS.find(g => g.kind === k)?.la | ||
| 158 | 171 | </div> |
| 159 | 172 | <p class="m-0 text-xs text-pretty text-sk-muted">{{ reasonOf(a) }}</p> |
| 160 | 173 | </div> |
| 174 | + <SkButton | |
| 175 | + v-if="canUndo(a)" | |
| 176 | + variant="ghost" | |
| 177 | + size="xs" | |
| 178 | + icon="i-lucide-undo-2" | |
| 179 | + label="Undo" | |
| 180 | + :title="a.kind === 'add' ? 'Disable this item again (every agent)' : 'Keep this item enabled (every agent)'" | |
| 181 | + :disabled="undoing || busy" | |
| 182 | + @click="undo([a])" | |
| 183 | + /> | |
| 161 | 184 | <SkButton |
| 162 | 185 | v-if="filesOf(a) || a.kind === 'conflict'" |
| 163 | 186 | variant="ghost" |
| @@ -196,7 +219,17 @@ const kindLabel = (k: PlanActionKind) => PLAN_GROUPS.find(g => g.kind === k)?.la | ||
| 196 | 219 | </template> |
| 197 | 220 | <template v-else> |
| 198 | 221 | <span class="mr-auto font-mono text-[11px] text-sk-faint">Nothing is written until you apply.</span> |
| 199 | - <SkButton label="Cancel" @click="open = false" /> | |
| 222 | + <SkButton | |
| 223 | + v-if="undoable.length" | |
| 224 | + variant="ghost" | |
| 225 | + icon="i-lucide-undo-2" | |
| 226 | + :label="undoable.length === actions.length ? 'Discard all' : `Discard ${undoable.length}`" | |
| 227 | + title="Undo the items enabled or disabled since the last apply" | |
| 228 | + :loading="undoing" | |
| 229 | + :disabled="busy" | |
| 230 | + @click="undo(undoable)" | |
| 231 | + /> | |
| 232 | + <SkButton label="Close" @click="open = false" /> | |
| 200 | 233 | <SkButton variant="primary" :loading="busy" :disabled="!actions.length" :label="actions.length ? `Apply ${actions.length} change${actions.length === 1 ? '' : 's'}` : 'Apply'" @click="apply" /> |
| 201 | 234 | </template> |
| 202 | 235 | </template> |
| @@ -5,6 +5,7 @@ import type { HomeNames } from '../../utils/scopeRows' | |||
| 5 | /** | 5 | /** |
| 6 | * Review & apply drawer (brief 4.1, 4.2): actions grouped by kind, files and config keys, plan warnings, missing | 6 | * Review & apply drawer (brief 4.1, 4.2): actions grouped by kind, files and config keys, plan warnings, missing |
| 7 | * items, conflicts with the staged resolution and a side by side diff. Nothing is written until Apply. | 7 | * items, conflicts with the staged resolution and a side by side diff. Nothing is written until Apply. |
| 8 | + * Changes that come from a config edit (an item enabled or disabled) can be undone here, one by one or all at once. | ||
| 8 | * The result (applied / skipped / errors) is shown in place. | 9 | * The result (applied / skipped / errors) is shown in place. |
| 9 | */ | 10 | */ |
| 10 | const props = defineProps<{ scopeId: string, plan: SyncPlan, home: boolean }>() | 11 | const props = defineProps<{ scopeId: string, plan: SyncPlan, home: boolean }>() |
| @@ -62,6 +63,18 @@ function toggle(id: string) { | |||
| 62 | 63 | ||
| 63 | const filesOf = (a: PlanAction) => a.files.length + a.configKeys.length | 64 | const filesOf = (a: PlanAction) => a.files.length + a.configKeys.length |
| 64 | const isHide = (a: PlanAction) => hideIds.value.has(a.id) | 65 | const isHide = (a: PlanAction) => hideIds.value.has(a.id) |
| 66 | +const canUndo = (a: PlanAction) => !isHide(a) && undoKind(a, scope.inCatalog(a.type, a.name)) !== null | ||
| 67 | +const undoable = computed(() => actions.value.filter(canUndo)) | ||
| 68 | +const undoing = ref(false) | ||
| 69 | + | ||
| 70 | +async function undo(list: PlanAction[]) { | ||
| 71 | + undoing.value = true | ||
| 72 | + try { | ||
| 73 | + await scope.undo(list) | ||
| 74 | + } finally { | ||
| 75 | + undoing.value = false | ||
| 76 | + } | ||
| 77 | +} | ||
| 65 | const reasonOf = (a: PlanAction) => { | 78 | const reasonOf = (a: PlanAction) => { |
| 66 | if (isHide(a)) return a.kind === 'remove' ? 'Stops hiding this Global item in the project' : 'Hides this Global item in the project (Claude Code only)' | 79 | if (isHide(a)) return a.kind === 'remove' ? 'Stops hiding this Global item in the project' : 'Hides this Global item in the project (Claude Code only)' |
| 67 | return a.conflict ? `${CONFLICT_HEADLINE[a.conflict]}${a.catalogUpdated ? ' · the catalog changed too' : ''}` : a.reason | 80 | return a.conflict ? `${CONFLICT_HEADLINE[a.conflict]}${a.catalogUpdated ? ' · the catalog changed too' : ''}` : a.reason |
| @@ -158,6 +171,16 @@ const kindLabel = (k: PlanActionKind) => PLAN_GROUPS.find(g => g.kind === k)?.la | |||
| 158 | </div> | 171 | </div> |
| 159 | <p class="m-0 text-xs text-pretty text-sk-muted">{{ reasonOf(a) }}</p> | 172 | <p class="m-0 text-xs text-pretty text-sk-muted">{{ reasonOf(a) }}</p> |
| 160 | </div> | 173 | </div> |
| 174 | + <SkButton | ||
| 175 | + v-if="canUndo(a)" | ||
| 176 | + variant="ghost" | ||
| 177 | + size="xs" | ||
| 178 | + icon="i-lucide-undo-2" | ||
| 179 | + label="Undo" | ||
| 180 | + :title="a.kind === 'add' ? 'Disable this item again (every agent)' : 'Keep this item enabled (every agent)'" | ||
| 181 | + :disabled="undoing || busy" | ||
| 182 | + @click="undo([a])" | ||
| 183 | + /> | ||
| 161 | <SkButton | 184 | <SkButton |
| 162 | v-if="filesOf(a) || a.kind === 'conflict'" | 185 | v-if="filesOf(a) || a.kind === 'conflict'" |
| 163 | variant="ghost" | 186 | variant="ghost" |
| @@ -196,7 +219,17 @@ const kindLabel = (k: PlanActionKind) => PLAN_GROUPS.find(g => g.kind === k)?.la | |||
| 196 | </template> | 219 | </template> |
| 197 | <template v-else> | 220 | <template v-else> |
| 198 | <span class="mr-auto font-mono text-[11px] text-sk-faint">Nothing is written until you apply.</span> | 221 | <span class="mr-auto font-mono text-[11px] text-sk-faint">Nothing is written until you apply.</span> |
| 199 | - <SkButton label="Cancel" @click="open = false" /> | 222 | + <SkButton |
| 223 | + v-if="undoable.length" | ||
| 224 | + variant="ghost" | ||
| 225 | + icon="i-lucide-undo-2" | ||
| 226 | + :label="undoable.length === actions.length ? 'Discard all' : `Discard ${undoable.length}`" | ||
| 227 | + title="Undo the items enabled or disabled since the last apply" | ||
| 228 | + :loading="undoing" | ||
| 229 | + :disabled="busy" | ||
| 230 | + @click="undo(undoable)" | ||
| 231 | + /> | ||
| 232 | + <SkButton label="Close" @click="open = false" /> | ||
| 200 | <SkButton variant="primary" :loading="busy" :disabled="!actions.length" :label="actions.length ? `Apply ${actions.length} change${actions.length === 1 ? '' : 's'}` : 'Apply'" @click="apply" /> | 233 | <SkButton variant="primary" :loading="busy" :disabled="!actions.length" :label="actions.length ? `Apply ${actions.length} change${actions.length === 1 ? '' : 's'}` : 'Apply'" @click="apply" /> |
| 201 | </template> | 234 | </template> |
| 202 | </template> | 235 | </template> |
modified
packages/ui/app/composables/useScopeState.ts +13 -2 | @@ -1,4 +1,4 @@ | ||
| 1 | -import type { AgentId, CatalogItemType, ConflictResolution, ImportResult, InventoryItem, ScopeConfig, ScopeState, SyncPlan } from '@skilly/core' | |
| 1 | +import type { AgentId, CatalogItemType, ConflictResolution, ImportResult, InventoryItem, PlanAction, ScopeConfig, ScopeState, SyncPlan } from '@skilly/core' | |
| 2 | 2 | import type { InjectionKey } from 'vue' |
| 3 | 3 | |
| 4 | 4 | /** |
| @@ -183,6 +183,17 @@ export function createScope(kind: 'home' | 'project', projectId: () => string | | ||
| 183 | 183 | return true |
| 184 | 184 | } |
| 185 | 185 | |
| 186 | + const inCatalog = (t: CatalogItemType, n: string) => catalog.items.value.some(i => i.type === t && i.name === n) | |
| 187 | + | |
| 188 | + /** Reverts the config edits behind pending actions (review drawer "Undo" / "Discard all") and refreshes the plan. */ | |
| 189 | + async function undo(actions: PlanAction[]) { | |
| 190 | + if (!config.value) return | |
| 191 | + const next = cloneConfig(config.value) | |
| 192 | + if (!undoInConfig(next, actions, catalog.presets.value, inCatalog)) return | |
| 193 | + saveConfig(next, true) | |
| 194 | + await flush() | |
| 195 | + } | |
| 196 | + | |
| 186 | 197 | /** After "Add to catalog" from this scope: the imported items stay here, now managed by skilly. */ |
| 187 | 198 | async function afterImport(res: ImportResult | null) { |
| 188 | 199 | await catalog.refresh() |
| @@ -206,7 +217,7 @@ export function createScope(kind: 'home' | 'project', projectId: () => string | | ||
| 206 | 217 | resolutions, decided, resolutionFor, setResolution, resolutionBody, |
| 207 | 218 | pending, conflictActions, openConflicts, targets, |
| 208 | 219 | selectedItem, itemOpen, reviewOpen, importItems, importOpen, moveItems, moveOpen, |
| 209 | - openItem, askImport, askMove, openReview, afterCatalogChange, afterImport, manage, adoptState | |
| 220 | + openItem, askImport, askMove, openReview, afterCatalogChange, afterImport, manage, undo, inCatalog, adoptState | |
| 210 | 221 | } |
| 211 | 222 | } |
| 212 | 223 | |
| @@ -1,4 +1,4 @@ | |||
| 1 | -import type { AgentId, CatalogItemType, ConflictResolution, ImportResult, InventoryItem, ScopeConfig, ScopeState, SyncPlan } from '@skilly/core' | 1 | +import type { AgentId, CatalogItemType, ConflictResolution, ImportResult, InventoryItem, PlanAction, ScopeConfig, ScopeState, SyncPlan } from '@skilly/core' |
| 2 | import type { InjectionKey } from 'vue' | 2 | import type { InjectionKey } from 'vue' |
| 3 | 3 | ||
| 4 | /** | 4 | /** |
| @@ -183,6 +183,17 @@ export function createScope(kind: 'home' | 'project', projectId: () => string | | |||
| 183 | return true | 183 | return true |
| 184 | } | 184 | } |
| 185 | 185 | ||
| 186 | + const inCatalog = (t: CatalogItemType, n: string) => catalog.items.value.some(i => i.type === t && i.name === n) | ||
| 187 | + | ||
| 188 | + /** Reverts the config edits behind pending actions (review drawer "Undo" / "Discard all") and refreshes the plan. */ | ||
| 189 | + async function undo(actions: PlanAction[]) { | ||
| 190 | + if (!config.value) return | ||
| 191 | + const next = cloneConfig(config.value) | ||
| 192 | + if (!undoInConfig(next, actions, catalog.presets.value, inCatalog)) return | ||
| 193 | + saveConfig(next, true) | ||
| 194 | + await flush() | ||
| 195 | + } | ||
| 196 | + | ||
| 186 | /** After "Add to catalog" from this scope: the imported items stay here, now managed by skilly. */ | 197 | /** After "Add to catalog" from this scope: the imported items stay here, now managed by skilly. */ |
| 187 | async function afterImport(res: ImportResult | null) { | 198 | async function afterImport(res: ImportResult | null) { |
| 188 | await catalog.refresh() | 199 | await catalog.refresh() |
| @@ -206,7 +217,7 @@ export function createScope(kind: 'home' | 'project', projectId: () => string | | |||
| 206 | resolutions, decided, resolutionFor, setResolution, resolutionBody, | 217 | resolutions, decided, resolutionFor, setResolution, resolutionBody, |
| 207 | pending, conflictActions, openConflicts, targets, | 218 | pending, conflictActions, openConflicts, targets, |
| 208 | selectedItem, itemOpen, reviewOpen, importItems, importOpen, moveItems, moveOpen, | 219 | selectedItem, itemOpen, reviewOpen, importItems, importOpen, moveItems, moveOpen, |
| 209 | - openItem, askImport, askMove, openReview, afterCatalogChange, afterImport, manage, adoptState | 220 | + openItem, askImport, askMove, openReview, afterCatalogChange, afterImport, manage, undo, inCatalog, adoptState |
| 210 | } | 221 | } |
| 211 | } | 222 | } |
| 212 | 223 | ||
modified
packages/ui/app/utils/scopeConfig.ts +49 -1 | @@ -1,4 +1,4 @@ | ||
| 1 | -import type { AgentId, CatalogItemType, Preset, ScopeConfig } from '@skilly/core' | |
| 1 | +import type { AgentId, CatalogItemType, PlanAction, Preset, ScopeConfig } from '@skilly/core' | |
| 2 | 2 | import { resolveItems } from './resolve' |
| 3 | 3 | |
| 4 | 4 | /** Types Global's config (home.json) can hold: its MCP servers and hooks are read-only in v1. */ |
| @@ -47,3 +47,51 @@ export function manageInConfig(c: ScopeConfig, refs: { type: CatalogItemType, na | ||
| 47 | 47 | } |
| 48 | 48 | return changed |
| 49 | 49 | } |
| 50 | + | |
| 51 | +/** | |
| 52 | + * Opposite of `manageInConfig`: the items are no longer enabled. Removed from `add`; an item still provided by a | |
| 53 | + * preset gets an exact exclusion. Mutates `c`; returns false when nothing changed. | |
| 54 | + */ | |
| 55 | +export function disableInConfig(c: ScopeConfig, refs: { type: CatalogItemType, name: string }[], presets: Preset[]): boolean { | |
| 56 | + let changed = false | |
| 57 | + const viaPresets = new Set(resolveItems(presets, c.presets, {}).entries.map(e => `${e.type}/${e.name}`)) | |
| 58 | + for (const r of refs) { | |
| 59 | + const add = c.add[r.type] ?? [] | |
| 60 | + if (add.includes(r.name)) { | |
| 61 | + const list = add.filter(x => x !== r.name) | |
| 62 | + if (list.length) c.add[r.type] = list | |
| 63 | + else delete c.add[r.type] | |
| 64 | + changed = true | |
| 65 | + } | |
| 66 | + if (viaPresets.has(`${r.type}/${r.name}`) && !(c.exclude[r.type] ?? []).includes(r.name)) { | |
| 67 | + c.exclude[r.type] = [...(c.exclude[r.type] ?? []), r.name] | |
| 68 | + changed = true | |
| 69 | + } | |
| 70 | + } | |
| 71 | + return changed | |
| 72 | +} | |
| 73 | + | |
| 74 | +/** | |
| 75 | + * Pending plan actions that come from a config edit and can be undone before Apply: an item newly enabled ("add", | |
| 76 | + * undone by disabling it) or no longer enabled ("remove", undone by enabling it again while the catalog still has it). | |
| 77 | + * Updates and conflicts come from the catalog or the disk, not from the config. | |
| 78 | + */ | |
| 79 | +export function undoKind(a: PlanAction, inCatalog: boolean): 'disable' | 'enable' | null { | |
| 80 | + if (a.kind === 'add') return 'disable' | |
| 81 | + if (a.kind === 'remove' && inCatalog) return 'enable' | |
| 82 | + return null | |
| 83 | +} | |
| 84 | + | |
| 85 | +/** Reverts the config edits behind these actions (see `undoKind`). Mutates `c`; returns false when nothing changed. */ | |
| 86 | +export function undoInConfig(c: ScopeConfig, actions: PlanAction[], presets: Preset[], inCatalog: (t: CatalogItemType, n: string) => boolean): boolean { | |
| 87 | + const refs = (k: 'disable' | 'enable') => { | |
| 88 | + const seen = new Map<string, { type: CatalogItemType, name: string }>() | |
| 89 | + for (const a of actions) { | |
| 90 | + if (undoKind(a, inCatalog(a.type, a.name)) === k) seen.set(`${a.type}/${a.name}`, { type: a.type, name: a.name }) | |
| 91 | + } | |
| 92 | + return [...seen.values()] | |
| 93 | + } | |
| 94 | + const disabled = disableInConfig(c, refs('disable'), presets) | |
| 95 | + const enabled = manageInConfig(c, refs('enable'), presets) | |
| 96 | + return disabled || enabled | |
| 97 | +} | |
| @@ -1,4 +1,4 @@ | |||
| 1 | -import type { AgentId, CatalogItemType, Preset, ScopeConfig } from '@skilly/core' | 1 | +import type { AgentId, CatalogItemType, PlanAction, Preset, ScopeConfig } from '@skilly/core' |
| 2 | import { resolveItems } from './resolve' | 2 | import { resolveItems } from './resolve' |
| 3 | 3 | ||
| 4 | /** Types Global's config (home.json) can hold: its MCP servers and hooks are read-only in v1. */ | 4 | /** Types Global's config (home.json) can hold: its MCP servers and hooks are read-only in v1. */ |
| @@ -47,3 +47,51 @@ export function manageInConfig(c: ScopeConfig, refs: { type: CatalogItemType, na | |||
| 47 | } | 47 | } |
| 48 | return changed | 48 | return changed |
| 49 | } | 49 | } |
| 50 | + | ||
| 51 | +/** | ||
| 52 | + * Opposite of `manageInConfig`: the items are no longer enabled. Removed from `add`; an item still provided by a | ||
| 53 | + * preset gets an exact exclusion. Mutates `c`; returns false when nothing changed. | ||
| 54 | + */ | ||
| 55 | +export function disableInConfig(c: ScopeConfig, refs: { type: CatalogItemType, name: string }[], presets: Preset[]): boolean { | ||
| 56 | + let changed = false | ||
| 57 | + const viaPresets = new Set(resolveItems(presets, c.presets, {}).entries.map(e => `${e.type}/${e.name}`)) | ||
| 58 | + for (const r of refs) { | ||
| 59 | + const add = c.add[r.type] ?? [] | ||
| 60 | + if (add.includes(r.name)) { | ||
| 61 | + const list = add.filter(x => x !== r.name) | ||
| 62 | + if (list.length) c.add[r.type] = list | ||
| 63 | + else delete c.add[r.type] | ||
| 64 | + changed = true | ||
| 65 | + } | ||
| 66 | + if (viaPresets.has(`${r.type}/${r.name}`) && !(c.exclude[r.type] ?? []).includes(r.name)) { | ||
| 67 | + c.exclude[r.type] = [...(c.exclude[r.type] ?? []), r.name] | ||
| 68 | + changed = true | ||
| 69 | + } | ||
| 70 | + } | ||
| 71 | + return changed | ||
| 72 | +} | ||
| 73 | + | ||
| 74 | +/** | ||
| 75 | + * Pending plan actions that come from a config edit and can be undone before Apply: an item newly enabled ("add", | ||
| 76 | + * undone by disabling it) or no longer enabled ("remove", undone by enabling it again while the catalog still has it). | ||
| 77 | + * Updates and conflicts come from the catalog or the disk, not from the config. | ||
| 78 | + */ | ||
| 79 | +export function undoKind(a: PlanAction, inCatalog: boolean): 'disable' | 'enable' | null { | ||
| 80 | + if (a.kind === 'add') return 'disable' | ||
| 81 | + if (a.kind === 'remove' && inCatalog) return 'enable' | ||
| 82 | + return null | ||
| 83 | +} | ||
| 84 | + | ||
| 85 | +/** Reverts the config edits behind these actions (see `undoKind`). Mutates `c`; returns false when nothing changed. */ | ||
| 86 | +export function undoInConfig(c: ScopeConfig, actions: PlanAction[], presets: Preset[], inCatalog: (t: CatalogItemType, n: string) => boolean): boolean { | ||
| 87 | + const refs = (k: 'disable' | 'enable') => { | ||
| 88 | + const seen = new Map<string, { type: CatalogItemType, name: string }>() | ||
| 89 | + for (const a of actions) { | ||
| 90 | + if (undoKind(a, inCatalog(a.type, a.name)) === k) seen.set(`${a.type}/${a.name}`, { type: a.type, name: a.name }) | ||
| 91 | + } | ||
| 92 | + return [...seen.values()] | ||
| 93 | + } | ||
| 94 | + const disabled = disableInConfig(c, refs('disable'), presets) | ||
| 95 | + const enabled = manageInConfig(c, refs('enable'), presets) | ||
| 96 | + return disabled || enabled | ||
| 97 | +} | ||
modified
packages/ui/test/scope-views.test.ts +25 -1 | @@ -1,7 +1,7 @@ | ||
| 1 | 1 | import { describe, expect, it } from 'vitest' |
| 2 | 2 | import type { InventoryItem, ScopeConfig, SyncPlan } from '@skilly/core' |
| 3 | 3 | import { buildRows, matchIn, mergeStatuses } from '../app/utils/scopeRows' |
| 4 | -import { manageInConfig } from '../app/utils/scopeConfig' | |
| 4 | +import { disableInConfig, manageInConfig, undoInConfig, undoKind } from '../app/utils/scopeConfig' | |
| 5 | 5 | import { projectSpecific, splitManagedBlock, homeDirOf } from '../app/utils/inventoryView' |
| 6 | 6 | import { summarizePlan } from '../app/utils/planView' |
| 7 | 7 | |
| @@ -126,3 +126,27 @@ describe('plan view helpers', () => { | ||
| 126 | 126 | expect(summarizePlan(null).label).toBe('Up to date') |
| 127 | 127 | }) |
| 128 | 128 | }) |
| 129 | + | |
| 130 | +describe('undo config edits', () => { | |
| 131 | + it('disables an added item, and excludes an item still provided by a preset', () => { | |
| 132 | + const c = config({ add: { skill: ['extra', 'seo-audit'] } }) | |
| 133 | + expect(disableInConfig(c, [{ type: 'skill', name: 'extra' }, { type: 'skill', name: 'seo-audit' }], presets)).toBe(true) | |
| 134 | + expect(c.add).toEqual({}) | |
| 135 | + expect(c.exclude).toEqual({ skill: ['seo-audit'] }) | |
| 136 | + expect(disableInConfig(c, [{ type: 'skill', name: 'seo-audit' }], presets)).toBe(false) | |
| 137 | + }) | |
| 138 | + | |
| 139 | + it('undoes "add" by disabling and "remove" by enabling again, once per item', () => { | |
| 140 | + expect(undoKind(act('add', 'x'), false)).toBe('disable') | |
| 141 | + expect(undoKind(act('remove', 'x'), true)).toBe('enable') | |
| 142 | + expect(undoKind(act('remove', 'x'), false)).toBeNull() | |
| 143 | + expect(undoKind(act('update', 'x'), true)).toBeNull() | |
| 144 | + | |
| 145 | + const c = config({ exclude: { skill: ['pr-review'] } }) | |
| 146 | + const codex = { ...act('add', 'extra'), id: 'add:codex:skill/extra', agent: 'codex' } as SyncPlan['actions'][number] | |
| 147 | + const actions = [act('add', 'extra'), codex, act('remove', 'pr-review'), act('remove', 'old'), act('remove', 'gone')] | |
| 148 | + expect(undoInConfig(c, actions, presets, (_t, n) => n !== 'gone')).toBe(true) | |
| 149 | + expect(c.add).toEqual({ skill: ['old'] }) | |
| 150 | + expect(c.exclude).toEqual({}) | |
| 151 | + }) | |
| 152 | +}) | |
| @@ -1,7 +1,7 @@ | |||
| 1 | import { describe, expect, it } from 'vitest' | 1 | import { describe, expect, it } from 'vitest' |
| 2 | import type { InventoryItem, ScopeConfig, SyncPlan } from '@skilly/core' | 2 | import type { InventoryItem, ScopeConfig, SyncPlan } from '@skilly/core' |
| 3 | import { buildRows, matchIn, mergeStatuses } from '../app/utils/scopeRows' | 3 | import { buildRows, matchIn, mergeStatuses } from '../app/utils/scopeRows' |
| 4 | -import { manageInConfig } from '../app/utils/scopeConfig' | 4 | +import { disableInConfig, manageInConfig, undoInConfig, undoKind } from '../app/utils/scopeConfig' |
| 5 | import { projectSpecific, splitManagedBlock, homeDirOf } from '../app/utils/inventoryView' | 5 | import { projectSpecific, splitManagedBlock, homeDirOf } from '../app/utils/inventoryView' |
| 6 | import { summarizePlan } from '../app/utils/planView' | 6 | import { summarizePlan } from '../app/utils/planView' |
| 7 | 7 | ||
| @@ -126,3 +126,27 @@ describe('plan view helpers', () => { | |||
| 126 | expect(summarizePlan(null).label).toBe('Up to date') | 126 | expect(summarizePlan(null).label).toBe('Up to date') |
| 127 | }) | 127 | }) |
| 128 | }) | 128 | }) |
| 129 | + | ||
| 130 | +describe('undo config edits', () => { | ||
| 131 | + it('disables an added item, and excludes an item still provided by a preset', () => { | ||
| 132 | + const c = config({ add: { skill: ['extra', 'seo-audit'] } }) | ||
| 133 | + expect(disableInConfig(c, [{ type: 'skill', name: 'extra' }, { type: 'skill', name: 'seo-audit' }], presets)).toBe(true) | ||
| 134 | + expect(c.add).toEqual({}) | ||
| 135 | + expect(c.exclude).toEqual({ skill: ['seo-audit'] }) | ||
| 136 | + expect(disableInConfig(c, [{ type: 'skill', name: 'seo-audit' }], presets)).toBe(false) | ||
| 137 | + }) | ||
| 138 | + | ||
| 139 | + it('undoes "add" by disabling and "remove" by enabling again, once per item', () => { | ||
| 140 | + expect(undoKind(act('add', 'x'), false)).toBe('disable') | ||
| 141 | + expect(undoKind(act('remove', 'x'), true)).toBe('enable') | ||
| 142 | + expect(undoKind(act('remove', 'x'), false)).toBeNull() | ||
| 143 | + expect(undoKind(act('update', 'x'), true)).toBeNull() | ||
| 144 | + | ||
| 145 | + const c = config({ exclude: { skill: ['pr-review'] } }) | ||
| 146 | + const codex = { ...act('add', 'extra'), id: 'add:codex:skill/extra', agent: 'codex' } as SyncPlan['actions'][number] | ||
| 147 | + const actions = [act('add', 'extra'), codex, act('remove', 'pr-review'), act('remove', 'old'), act('remove', 'gone')] | ||
| 148 | + expect(undoInConfig(c, actions, presets, (_t, n) => n !== 'gone')).toBe(true) | ||
| 149 | + expect(c.add).toEqual({ skill: ['old'] }) | ||
| 150 | + expect(c.exclude).toEqual({}) | ||
| 151 | + }) | ||
| 152 | +}) | ||