hlassiege/panopliepublic Fork
⑂ e234cf5
Commits
⬇ Clone ▾
git clone https://git.rickub.com/hlassiege/panoplie.git
git clone ssh://git@rickub.com/hlassiege/panoplie.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Download ZIP

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>
hlassiege committed 2026-10-23T16:55:42+02:00 Browse files
e234cf5 parent: 607e619
modified packages/ui/app/app.config.ts +1 -1
@@ -63,7 +63,7 @@ export default defineAppConfig({
6363 },
6464
6565 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' },
6767 defaultVariants: { color: 'neutral' }
6868 },
6969
@@ -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)
1919 const error = ref<string | null>(null)
2020 /** Ids of the items left checked: all of them when the modal opens. */
2121 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'))
2225
2326 const picked = computed(() => props.items.filter(i => selected.value.has(i.id)))
2427 const differing = computed(() => picked.value.filter(i => props.statuses[i.id]?.state === 'differs-from-catalog'))
2528 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+})
2636 const allSelected = computed<boolean | 'indeterminate'>({
2737 get: () => (picked.value.length === props.items.length ? true : picked.value.length ? 'indeterminate' : false),
2838 set: (v) => { selected.value = new Set(v === true ? props.items.map(i => i.id) : []) }
@@ -42,6 +52,7 @@ watch(open, (v) => {
4252 result.value = null
4353 error.value = null
4454 selected.value = new Set(props.items.map(i => i.id))
55+ comparing.value = null
4556 }
4657 })
4758
@@ -70,7 +81,7 @@ async function submit() {
7081 </script>
7182
7283 <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.">
7485 <div v-if="result" class="flex flex-col gap-3">
7586 <SkNote v-if="result.imported.length" tone="ok">
7687 <span class="font-medium">{{ result.imported.length }} added</span>
@@ -82,18 +93,37 @@ async function submit() {
8293 <div v-else class="flex flex-col gap-4">
8394 <SkNote v-if="error" tone="accent"><span class="font-medium">Import failed.</span> {{ error }}</SkNote>
8495 <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" />
92114 </li>
93115 </ul>
94116 <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+ />
97127 </SkNote>
98128 <div class="flex flex-col gap-1.5">
99129 <SkSectionLabel>Tags</SkSectionLabel>
@@ -103,7 +133,7 @@ async function submit() {
103133
104134 <template #footer>
105135 <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" />
107137 </template>
108138 </SkModal>
109139 </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 = null52 result.value = null
43 error.value = null53 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() {
138138 </ul>
139139 </div>
140140
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+
141146 <ScopeInstructionsViewer v-if="item.type === 'instructions'" :path="item.source.path" />
142147 <MarkdownView v-else-if="shownPath" :path="shownPath" :frontmatter="frontmatter" />
143148 <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() {
146151 <template v-if="status?.catalogRef || canCatalog || canMove" #footer>
147152 <div class="flex w-full flex-wrap items-center justify-end gap-2">
148153 <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" />
150155 <SkButton v-if="canMove" variant="primary" label="Move to projects…" @click="move" />
151156 </div>
152157 </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
2424
2525 const open = ref(false)
2626 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))
2828 const switchDisabled = computed(() => props.row.excluded && !props.row.excludedExact)
2929 const switchTitle = computed(() => {
3030 if (switchDisabled.value) return `Excluded by the pattern ${props.row.excludePattern}`
31+ if (props.row.kind === 'removal') return 'Enable again (removed on Apply otherwise)'
3132 if (props.row.excluded) return 'Include this item'
3233 return props.row.viaPreset ? 'Exclude from this project' : 'Remove from this project'
3334 })
@@ -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) {
106106 }
107107
108108 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+ }
109113 scope.edit((c) => {
110114 const t = row.type
111115 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.type114 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'
55 /**
66 * Review & apply drawer (brief 4.1, 4.2): actions grouped by kind, files and config keys, plan warnings, missing
77 * 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.
89 * The result (applied / skipped / errors) is shown in place.
910 */
1011 const props = defineProps<{ scopeId: string, plan: SyncPlan, home: boolean }>()
@@ -62,6 +63,18 @@ function toggle(id: string) {
6263
6364 const filesOf = (a: PlanAction) => a.files.length + a.configKeys.length
6465 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+}
6578 const reasonOf = (a: PlanAction) => {
6679 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)'
6780 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
158171 </div>
159172 <p class="m-0 text-xs text-pretty text-sk-muted">{{ reasonOf(a) }}</p>
160173 </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+ />
161184 <SkButton
162185 v-if="filesOf(a) || a.kind === 'conflict'"
163186 variant="ghost"
@@ -196,7 +219,17 @@ const kindLabel = (k: PlanActionKind) => PLAN_GROUPS.find(g => g.kind === k)?.la
196219 </template>
197220 <template v-else>
198221 <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" />
200233 <SkButton variant="primary" :loading="busy" :disabled="!actions.length" :label="actions.length ? `Apply ${actions.length} change${actions.length === 1 ? '' : 's'}` : 'Apply'" @click="apply" />
201234 </template>
202235 </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, missing6 * 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.length64 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.reason80 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 <SkButton184 <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'
22 import type { InjectionKey } from 'vue'
33
44 /**
@@ -183,6 +183,17 @@ export function createScope(kind: 'home' | 'project', projectId: () => string |
183183 return true
184184 }
185185
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+
186197 /** After "Add to catalog" from this scope: the imported items stay here, now managed by skilly. */
187198 async function afterImport(res: ImportResult | null) {
188199 await catalog.refresh()
@@ -206,7 +217,7 @@ export function createScope(kind: 'home' | 'project', projectId: () => string |
206217 resolutions, decided, resolutionFor, setResolution, resolutionBody,
207218 pending, conflictActions, openConflicts, targets,
208219 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
210221 }
211222 }
212223
@@ -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 true183 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, adoptState220+ 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'
22 import { resolveItems } from './resolve'
33
44 /** 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
4747 }
4848 return changed
4949 }
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 changed48 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 @@
11 import { describe, expect, it } from 'vitest'
22 import type { InventoryItem, ScopeConfig, SyncPlan } from '@skilly/core'
33 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'
55 import { projectSpecific, splitManagedBlock, homeDirOf } from '../app/utils/inventoryView'
66 import { summarizePlan } from '../app/utils/planView'
77
@@ -126,3 +126,27 @@ describe('plan view helpers', () => {
126126 expect(summarizePlan(null).label).toBe('Up to date')
127127 })
128128 })
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+})