feat(ui): catalog editor, presets, scope configuration and sync review, git panel, marketplaces, settings
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
77c51e8 parent: 8d4155a added
packages/ui/app/app.config.ts +6 -0 | new file mode 100644 | ||
| @@ -0,0 +1,6 @@ | ||
| 1 | +export default defineAppConfig({ | |
| 2 | + ui: { | |
| 3 | + // `secondary` is used for Codex so that agents never share a color with status badges (success/warning/info). | |
| 4 | + colors: { secondary: 'violet' } | |
| 5 | + } | |
| 6 | +}) | |
| new file mode 100644 | |||
| @@ -0,0 +1,6 @@ | |||
| 1 | +export default defineAppConfig({ | ||
| 2 | + ui: { | ||
| 3 | + // `secondary` is used for Codex so that agents never share a color with status badges (success/warning/info). | ||
| 4 | + colors: { secondary: 'violet' } | ||
| 5 | + } | ||
| 6 | +}) | ||
modified
packages/ui/app/components/AddProjectModal.vue +3 -85 | @@ -1,38 +1,15 @@ | ||
| 1 | 1 | <script setup lang="ts"> |
| 2 | -import type { FsListing } from '@skilly/core' | |
| 3 | - | |
| 4 | 2 | const open = defineModel<boolean>('open', { default: false }) |
| 5 | 3 | |
| 6 | -const { request } = useApi() | |
| 7 | 4 | const { add } = useProjects() |
| 8 | 5 | const router = useRouter() |
| 9 | 6 | const toast = useToast() |
| 10 | 7 | |
| 11 | -const listing = ref<FsListing | null>(null) | |
| 12 | -const loading = ref(false) | |
| 13 | -const listError = ref<string | null>(null) | |
| 14 | 8 | const pathInput = ref('') |
| 15 | 9 | const submitting = ref(false) |
| 16 | 10 | const submitError = ref<string | null>(null) |
| 17 | -const highlighted = ref<string | null>(null) | |
| 18 | - | |
| 19 | 11 | // The highlighted entry wins over the folder being browsed, so "Select" adds what the user clicked. |
| 20 | -const selectedPath = computed(() => highlighted.value ?? listing.value?.path ?? null) | |
| 21 | - | |
| 22 | -const crumbs = computed(() => (listing.value?.path ? pathSegments(listing.value.path) : [])) | |
| 23 | - | |
| 24 | -async function browse(path?: string) { | |
| 25 | - loading.value = true | |
| 26 | - listError.value = null | |
| 27 | - highlighted.value = null | |
| 28 | - try { | |
| 29 | - listing.value = await request<FsListing>('/api/fs/list', { query: { path }, silent: true }) | |
| 30 | - } catch (e) { | |
| 31 | - listError.value = (e as Error).message | |
| 32 | - } finally { | |
| 33 | - loading.value = false | |
| 34 | - } | |
| 35 | -} | |
| 12 | +const selectedPath = ref<string | null>(null) | |
| 36 | 13 | |
| 37 | 14 | async function submit(path: string) { |
| 38 | 15 | const p = path.trim() |
| @@ -52,16 +29,11 @@ async function submit(path: string) { | ||
| 52 | 29 | } |
| 53 | 30 | } |
| 54 | 31 | |
| 55 | -function highlight(path: string) { | |
| 56 | - highlighted.value = path | |
| 57 | - pathInput.value = path | |
| 58 | -} | |
| 59 | - | |
| 32 | +watch(selectedPath, (p) => { if (p) pathInput.value = p }) | |
| 60 | 33 | watch(open, (v) => { |
| 61 | 34 | if (v) { |
| 62 | 35 | pathInput.value = '' |
| 63 | 36 | submitError.value = null |
| 64 | - browse() | |
| 65 | 37 | } |
| 66 | 38 | }) |
| 67 | 39 | </script> |
| @@ -75,7 +47,6 @@ watch(open, (v) => { | ||
| 75 | 47 | > |
| 76 | 48 | <template #body> |
| 77 | 49 | <div class="space-y-4"> |
| 78 | - <!-- Paste a path --> | |
| 79 | 50 | <form class="flex gap-2" @submit.prevent="submit(pathInput)"> |
| 80 | 51 | <UInput |
| 81 | 52 | v-model="pathInput" |
| @@ -89,60 +60,7 @@ watch(open, (v) => { | ||
| 89 | 60 | </form> |
| 90 | 61 | <UAlert v-if="submitError" color="error" variant="subtle" icon="i-lucide-circle-alert" :title="submitError" /> |
| 91 | 62 | |
| 92 | - <!-- Breadcrumb --> | |
| 93 | - <div class="flex flex-wrap items-center gap-1 text-sm"> | |
| 94 | - <UButton label="This PC" icon="i-lucide-hard-drive" size="xs" color="neutral" variant="ghost" @click="browse()" /> | |
| 95 | - <template v-for="c in crumbs" :key="c.path"> | |
| 96 | - <UIcon name="i-lucide-chevron-right" class="size-3.5 text-dimmed" /> | |
| 97 | - <UButton :label="c.name" size="xs" color="neutral" variant="ghost" @click="browse(c.path)" /> | |
| 98 | - </template> | |
| 99 | - </div> | |
| 100 | - | |
| 101 | - <!-- Folder list --> | |
| 102 | - <div class="h-72 overflow-y-auto rounded-lg border border-default"> | |
| 103 | - <div v-if="loading" class="space-y-1 p-2"> | |
| 104 | - <USkeleton v-for="i in 6" :key="i" class="h-8 w-full" /> | |
| 105 | - </div> | |
| 106 | - <div v-else-if="listError" class="p-3"> | |
| 107 | - <UAlert color="error" variant="subtle" icon="i-lucide-circle-alert" title="Cannot list this folder" :description="listError" /> | |
| 108 | - </div> | |
| 109 | - <ul v-else-if="listing" class="divide-y divide-default text-sm"> | |
| 110 | - <li v-if="listing.parent"> | |
| 111 | - <button type="button" class="flex w-full items-center gap-2 px-3 py-2 hover:bg-elevated/60" @click="browse(listing.parent!)"> | |
| 112 | - <UIcon name="i-lucide-corner-left-up" class="size-4 text-muted" /> | |
| 113 | - <span class="text-muted">..</span> | |
| 114 | - </button> | |
| 115 | - </li> | |
| 116 | - <li | |
| 117 | - v-for="e in listing.entries" | |
| 118 | - :key="e.path" | |
| 119 | - class="flex items-center hover:bg-elevated/60" | |
| 120 | - :class="highlighted === e.path ? 'bg-primary/10' : ''" | |
| 121 | - > | |
| 122 | - <button | |
| 123 | - type="button" | |
| 124 | - class="flex min-w-0 flex-1 items-center gap-2 py-2 pl-3 text-left" | |
| 125 | - :title="e.path" | |
| 126 | - @click="highlight(e.path)" | |
| 127 | - @dblclick="browse(e.path)" | |
| 128 | - > | |
| 129 | - <UIcon :name="listing.path ? 'i-lucide-folder' : /^[a-zA-Z]:[\\/]?$/.test(e.name) ? 'i-lucide-hard-drive' : 'i-lucide-house'" class="size-4 shrink-0 text-muted" /> | |
| 130 | - <span class="flex-1 truncate">{{ e.name }}</span> | |
| 131 | - <UBadge v-if="e.hasAgentConfig" label="agent config" color="primary" variant="subtle" size="sm" icon="i-lucide-sparkles" class="shrink-0" /> | |
| 132 | - </button> | |
| 133 | - <UButton | |
| 134 | - icon="i-lucide-chevron-right" | |
| 135 | - size="xs" | |
| 136 | - color="neutral" | |
| 137 | - variant="ghost" | |
| 138 | - class="mx-2 shrink-0" | |
| 139 | - :aria-label="`Open ${e.name}`" | |
| 140 | - @click="browse(e.path)" | |
| 141 | - /> | |
| 142 | - </li> | |
| 143 | - <li v-if="!listing.entries.length" class="px-3 py-8 text-center text-muted">No subfolder here.</li> | |
| 144 | - </ul> | |
| 145 | - </div> | |
| 63 | + <FolderBrowser v-if="open" v-model:selected="selectedPath" /> | |
| 146 | 64 | </div> |
| 147 | 65 | </template> |
| 148 | 66 | |
| @@ -1,38 +1,15 @@ | |||
| 1 | <script setup lang="ts"> | 1 | <script setup lang="ts"> |
| 2 | -import type { FsListing } from '@skilly/core' | ||
| 3 | - | ||
| 4 | const open = defineModel<boolean>('open', { default: false }) | 2 | const open = defineModel<boolean>('open', { default: false }) |
| 5 | 3 | ||
| 6 | -const { request } = useApi() | ||
| 7 | const { add } = useProjects() | 4 | const { add } = useProjects() |
| 8 | const router = useRouter() | 5 | const router = useRouter() |
| 9 | const toast = useToast() | 6 | const toast = useToast() |
| 10 | 7 | ||
| 11 | -const listing = ref<FsListing | null>(null) | ||
| 12 | -const loading = ref(false) | ||
| 13 | -const listError = ref<string | null>(null) | ||
| 14 | const pathInput = ref('') | 8 | const pathInput = ref('') |
| 15 | const submitting = ref(false) | 9 | const submitting = ref(false) |
| 16 | const submitError = ref<string | null>(null) | 10 | const submitError = ref<string | null>(null) |
| 17 | -const highlighted = ref<string | null>(null) | ||
| 18 | - | ||
| 19 | // The highlighted entry wins over the folder being browsed, so "Select" adds what the user clicked. | 11 | // The highlighted entry wins over the folder being browsed, so "Select" adds what the user clicked. |
| 20 | -const selectedPath = computed(() => highlighted.value ?? listing.value?.path ?? null) | 12 | +const selectedPath = ref<string | null>(null) |
| 21 | - | ||
| 22 | -const crumbs = computed(() => (listing.value?.path ? pathSegments(listing.value.path) : [])) | ||
| 23 | - | ||
| 24 | -async function browse(path?: string) { | ||
| 25 | - loading.value = true | ||
| 26 | - listError.value = null | ||
| 27 | - highlighted.value = null | ||
| 28 | - try { | ||
| 29 | - listing.value = await request<FsListing>('/api/fs/list', { query: { path }, silent: true }) | ||
| 30 | - } catch (e) { | ||
| 31 | - listError.value = (e as Error).message | ||
| 32 | - } finally { | ||
| 33 | - loading.value = false | ||
| 34 | - } | ||
| 35 | -} | ||
| 36 | 13 | ||
| 37 | async function submit(path: string) { | 14 | async function submit(path: string) { |
| 38 | const p = path.trim() | 15 | const p = path.trim() |
| @@ -52,16 +29,11 @@ async function submit(path: string) { | |||
| 52 | } | 29 | } |
| 53 | } | 30 | } |
| 54 | 31 | ||
| 55 | -function highlight(path: string) { | 32 | +watch(selectedPath, (p) => { if (p) pathInput.value = p }) |
| 56 | - highlighted.value = path | ||
| 57 | - pathInput.value = path | ||
| 58 | -} | ||
| 59 | - | ||
| 60 | watch(open, (v) => { | 33 | watch(open, (v) => { |
| 61 | if (v) { | 34 | if (v) { |
| 62 | pathInput.value = '' | 35 | pathInput.value = '' |
| 63 | submitError.value = null | 36 | submitError.value = null |
| 64 | - browse() | ||
| 65 | } | 37 | } |
| 66 | }) | 38 | }) |
| 67 | </script> | 39 | </script> |
| @@ -75,7 +47,6 @@ watch(open, (v) => { | |||
| 75 | > | 47 | > |
| 76 | <template #body> | 48 | <template #body> |
| 77 | <div class="space-y-4"> | 49 | <div class="space-y-4"> |
| 78 | - <!-- Paste a path --> | ||
| 79 | <form class="flex gap-2" @submit.prevent="submit(pathInput)"> | 50 | <form class="flex gap-2" @submit.prevent="submit(pathInput)"> |
| 80 | <UInput | 51 | <UInput |
| 81 | v-model="pathInput" | 52 | v-model="pathInput" |
| @@ -89,60 +60,7 @@ watch(open, (v) => { | |||
| 89 | </form> | 60 | </form> |
| 90 | <UAlert v-if="submitError" color="error" variant="subtle" icon="i-lucide-circle-alert" :title="submitError" /> | 61 | <UAlert v-if="submitError" color="error" variant="subtle" icon="i-lucide-circle-alert" :title="submitError" /> |
| 91 | 62 | ||
| 92 | - <!-- Breadcrumb --> | 63 | + <FolderBrowser v-if="open" v-model:selected="selectedPath" /> |
| 93 | - <div class="flex flex-wrap items-center gap-1 text-sm"> | ||
| 94 | - <UButton label="This PC" icon="i-lucide-hard-drive" size="xs" color="neutral" variant="ghost" @click="browse()" /> | ||
| 95 | - <template v-for="c in crumbs" :key="c.path"> | ||
| 96 | - <UIcon name="i-lucide-chevron-right" class="size-3.5 text-dimmed" /> | ||
| 97 | - <UButton :label="c.name" size="xs" color="neutral" variant="ghost" @click="browse(c.path)" /> | ||
| 98 | - </template> | ||
| 99 | - </div> | ||
| 100 | - | ||
| 101 | - <!-- Folder list --> | ||
| 102 | - <div class="h-72 overflow-y-auto rounded-lg border border-default"> | ||
| 103 | - <div v-if="loading" class="space-y-1 p-2"> | ||
| 104 | - <USkeleton v-for="i in 6" :key="i" class="h-8 w-full" /> | ||
| 105 | - </div> | ||
| 106 | - <div v-else-if="listError" class="p-3"> | ||
| 107 | - <UAlert color="error" variant="subtle" icon="i-lucide-circle-alert" title="Cannot list this folder" :description="listError" /> | ||
| 108 | - </div> | ||
| 109 | - <ul v-else-if="listing" class="divide-y divide-default text-sm"> | ||
| 110 | - <li v-if="listing.parent"> | ||
| 111 | - <button type="button" class="flex w-full items-center gap-2 px-3 py-2 hover:bg-elevated/60" @click="browse(listing.parent!)"> | ||
| 112 | - <UIcon name="i-lucide-corner-left-up" class="size-4 text-muted" /> | ||
| 113 | - <span class="text-muted">..</span> | ||
| 114 | - </button> | ||
| 115 | - </li> | ||
| 116 | - <li | ||
| 117 | - v-for="e in listing.entries" | ||
| 118 | - :key="e.path" | ||
| 119 | - class="flex items-center hover:bg-elevated/60" | ||
| 120 | - :class="highlighted === e.path ? 'bg-primary/10' : ''" | ||
| 121 | - > | ||
| 122 | - <button | ||
| 123 | - type="button" | ||
| 124 | - class="flex min-w-0 flex-1 items-center gap-2 py-2 pl-3 text-left" | ||
| 125 | - :title="e.path" | ||
| 126 | - @click="highlight(e.path)" | ||
| 127 | - @dblclick="browse(e.path)" | ||
| 128 | - > | ||
| 129 | - <UIcon :name="listing.path ? 'i-lucide-folder' : /^[a-zA-Z]:[\\/]?$/.test(e.name) ? 'i-lucide-hard-drive' : 'i-lucide-house'" class="size-4 shrink-0 text-muted" /> | ||
| 130 | - <span class="flex-1 truncate">{{ e.name }}</span> | ||
| 131 | - <UBadge v-if="e.hasAgentConfig" label="agent config" color="primary" variant="subtle" size="sm" icon="i-lucide-sparkles" class="shrink-0" /> | ||
| 132 | - </button> | ||
| 133 | - <UButton | ||
| 134 | - icon="i-lucide-chevron-right" | ||
| 135 | - size="xs" | ||
| 136 | - color="neutral" | ||
| 137 | - variant="ghost" | ||
| 138 | - class="mx-2 shrink-0" | ||
| 139 | - :aria-label="`Open ${e.name}`" | ||
| 140 | - @click="browse(e.path)" | ||
| 141 | - /> | ||
| 142 | - </li> | ||
| 143 | - <li v-if="!listing.entries.length" class="px-3 py-8 text-center text-muted">No subfolder here.</li> | ||
| 144 | - </ul> | ||
| 145 | - </div> | ||
| 146 | </div> | 64 | </div> |
| 147 | </template> | 65 | </template> |
| 148 | 66 | ||
added
packages/ui/app/components/AddToCatalogModal.vue +90 -0 | new file mode 100644 | ||
| @@ -0,0 +1,90 @@ | ||
| 1 | +<script setup lang="ts"> | |
| 2 | +import type { ImportResult, InventoryItem, ItemStatus } from '@skilly/core' | |
| 3 | + | |
| 4 | +const props = defineProps<{ scope: string, items: InventoryItem[], statuses: Record<string, ItemStatus> }>() | |
| 5 | +const open = defineModel<boolean>('open', { default: false }) | |
| 6 | +const emit = defineEmits<{ done: [] }>() | |
| 7 | + | |
| 8 | +const { request } = useApi() | |
| 9 | +const toast = useToast() | |
| 10 | + | |
| 11 | +const tags = ref<string[]>([]) | |
| 12 | +const overwrite = ref(false) | |
| 13 | +const busy = ref(false) | |
| 14 | +const result = ref<ImportResult | null>(null) | |
| 15 | +const error = ref<string | null>(null) | |
| 16 | + | |
| 17 | +const differing = computed(() => props.items.filter(i => props.statuses[i.id]?.state === 'differs-from-catalog')) | |
| 18 | +const toSend = computed(() => props.items.filter(i => overwrite.value || props.statuses[i.id]?.state !== 'differs-from-catalog')) | |
| 19 | + | |
| 20 | +watch(open, (v) => { | |
| 21 | + if (v) { | |
| 22 | + tags.value = [] | |
| 23 | + overwrite.value = false | |
| 24 | + result.value = null | |
| 25 | + error.value = null | |
| 26 | + } | |
| 27 | +}) | |
| 28 | + | |
| 29 | +async function submit() { | |
| 30 | + busy.value = true | |
| 31 | + error.value = null | |
| 32 | + try { | |
| 33 | + result.value = await request<ImportResult>('/api/catalog/import', { | |
| 34 | + method: 'POST', | |
| 35 | + body: { scope: props.scope, itemIds: toSend.value.map(i => i.id), overwrite: overwrite.value, ...(tags.value.length ? { tags: tags.value } : {}) }, | |
| 36 | + silent: true | |
| 37 | + }) | |
| 38 | + toast.add({ title: `${result.value.imported.length} added to the catalog`, icon: 'i-lucide-check', color: 'success' }) | |
| 39 | + emit('done') | |
| 40 | + } catch (e) { | |
| 41 | + error.value = (e as Error).message | |
| 42 | + } finally { | |
| 43 | + busy.value = false | |
| 44 | + } | |
| 45 | +} | |
| 46 | +</script> | |
| 47 | + | |
| 48 | +<template> | |
| 49 | + <UModal v-model:open="open" title="Add to catalog" description="Copy these items into the catalog so they can be reused in other projects." :ui="{ content: 'max-w-xl' }"> | |
| 50 | + <template #body> | |
| 51 | + <div v-if="result" class="space-y-3 text-sm"> | |
| 52 | + <UAlert v-if="result.imported.length" color="success" variant="subtle" icon="i-lucide-check" :title="`${result.imported.length} item(s) added`"> | |
| 53 | + <template #description> | |
| 54 | + <ul class="list-disc pl-4"><li v-for="i in result.imported" :key="`${i.type}/${i.name}`">{{ CAT_META[i.type].singular }} {{ i.name }}</li></ul> | |
| 55 | + </template> | |
| 56 | + </UAlert> | |
| 57 | + <UAlert v-if="result.skipped.length" color="warning" variant="subtle" icon="i-lucide-triangle-alert" :title="`${result.skipped.length} skipped`"> | |
| 58 | + <template #description> | |
| 59 | + <ul class="list-disc pl-4"><li v-for="s in result.skipped" :key="s.id"><span class="font-mono text-xs">{{ s.id }}</span>: {{ s.reason }}</li></ul> | |
| 60 | + </template> | |
| 61 | + </UAlert> | |
| 62 | + </div> | |
| 63 | + <div v-else class="space-y-4"> | |
| 64 | + <UAlert v-if="error" color="error" variant="subtle" icon="i-lucide-circle-alert" title="Import failed" :description="error" /> | |
| 65 | + <ul class="max-h-48 divide-y divide-default overflow-auto rounded-lg border border-default text-sm"> | |
| 66 | + <li v-for="i in items" :key="i.id" class="flex items-center gap-2 px-3 py-1.5"> | |
| 67 | + <AgentBadge :agent="i.agent" compact /> | |
| 68 | + <span class="truncate font-medium">{{ i.name }}</span> | |
| 69 | + <span class="text-xs text-muted">{{ i.type }}</span> | |
| 70 | + <UBadge v-if="statuses[i.id]?.state === 'differs-from-catalog'" label="differs from catalog" size="sm" color="warning" variant="subtle" class="ml-auto" /> | |
| 71 | + </li> | |
| 72 | + </ul> | |
| 73 | + <UAlert v-if="differing.length" color="warning" variant="subtle" icon="i-lucide-git-compare" :title="`${differing.length} item(s) already exist in the catalog with different content`"> | |
| 74 | + <template #description> | |
| 75 | + <UCheckbox v-model="overwrite" label="Overwrite the catalog version" description="Unchecked, these items are left out." class="mt-1" /> | |
| 76 | + </template> | |
| 77 | + </UAlert> | |
| 78 | + <UFormField label="Tags" description="Optional, applied to the imported items."> | |
| 79 | + <UInputTags v-model="tags" class="w-full" placeholder="Add a tag, press Enter" /> | |
| 80 | + </UFormField> | |
| 81 | + </div> | |
| 82 | + </template> | |
| 83 | + <template #footer> | |
| 84 | + <div class="flex w-full justify-end gap-2"> | |
| 85 | + <UButton :label="result ? 'Close' : 'Cancel'" color="neutral" variant="outline" @click="open = false" /> | |
| 86 | + <UButton v-if="!result" :label="`Add ${toSend.length} to catalog`" icon="i-lucide-library" :loading="busy" :disabled="!toSend.length" @click="submit" /> | |
| 87 | + </div> | |
| 88 | + </template> | |
| 89 | + </UModal> | |
| 90 | +</template> | |
| new file mode 100644 | |||
| @@ -0,0 +1,90 @@ | |||
| 1 | +<script setup lang="ts"> | ||
| 2 | +import type { ImportResult, InventoryItem, ItemStatus } from '@skilly/core' | ||
| 3 | + | ||
| 4 | +const props = defineProps<{ scope: string, items: InventoryItem[], statuses: Record<string, ItemStatus> }>() | ||
| 5 | +const open = defineModel<boolean>('open', { default: false }) | ||
| 6 | +const emit = defineEmits<{ done: [] }>() | ||
| 7 | + | ||
| 8 | +const { request } = useApi() | ||
| 9 | +const toast = useToast() | ||
| 10 | + | ||
| 11 | +const tags = ref<string[]>([]) | ||
| 12 | +const overwrite = ref(false) | ||
| 13 | +const busy = ref(false) | ||
| 14 | +const result = ref<ImportResult | null>(null) | ||
| 15 | +const error = ref<string | null>(null) | ||
| 16 | + | ||
| 17 | +const differing = computed(() => props.items.filter(i => props.statuses[i.id]?.state === 'differs-from-catalog')) | ||
| 18 | +const toSend = computed(() => props.items.filter(i => overwrite.value || props.statuses[i.id]?.state !== 'differs-from-catalog')) | ||
| 19 | + | ||
| 20 | +watch(open, (v) => { | ||
| 21 | + if (v) { | ||
| 22 | + tags.value = [] | ||
| 23 | + overwrite.value = false | ||
| 24 | + result.value = null | ||
| 25 | + error.value = null | ||
| 26 | + } | ||
| 27 | +}) | ||
| 28 | + | ||
| 29 | +async function submit() { | ||
| 30 | + busy.value = true | ||
| 31 | + error.value = null | ||
| 32 | + try { | ||
| 33 | + result.value = await request<ImportResult>('/api/catalog/import', { | ||
| 34 | + method: 'POST', | ||
| 35 | + body: { scope: props.scope, itemIds: toSend.value.map(i => i.id), overwrite: overwrite.value, ...(tags.value.length ? { tags: tags.value } : {}) }, | ||
| 36 | + silent: true | ||
| 37 | + }) | ||
| 38 | + toast.add({ title: `${result.value.imported.length} added to the catalog`, icon: 'i-lucide-check', color: 'success' }) | ||
| 39 | + emit('done') | ||
| 40 | + } catch (e) { | ||
| 41 | + error.value = (e as Error).message | ||
| 42 | + } finally { | ||
| 43 | + busy.value = false | ||
| 44 | + } | ||
| 45 | +} | ||
| 46 | +</script> | ||
| 47 | + | ||
| 48 | +<template> | ||
| 49 | + <UModal v-model:open="open" title="Add to catalog" description="Copy these items into the catalog so they can be reused in other projects." :ui="{ content: 'max-w-xl' }"> | ||
| 50 | + <template #body> | ||
| 51 | + <div v-if="result" class="space-y-3 text-sm"> | ||
| 52 | + <UAlert v-if="result.imported.length" color="success" variant="subtle" icon="i-lucide-check" :title="`${result.imported.length} item(s) added`"> | ||
| 53 | + <template #description> | ||
| 54 | + <ul class="list-disc pl-4"><li v-for="i in result.imported" :key="`${i.type}/${i.name}`">{{ CAT_META[i.type].singular }} {{ i.name }}</li></ul> | ||
| 55 | + </template> | ||
| 56 | + </UAlert> | ||
| 57 | + <UAlert v-if="result.skipped.length" color="warning" variant="subtle" icon="i-lucide-triangle-alert" :title="`${result.skipped.length} skipped`"> | ||
| 58 | + <template #description> | ||
| 59 | + <ul class="list-disc pl-4"><li v-for="s in result.skipped" :key="s.id"><span class="font-mono text-xs">{{ s.id }}</span>: {{ s.reason }}</li></ul> | ||
| 60 | + </template> | ||
| 61 | + </UAlert> | ||
| 62 | + </div> | ||
| 63 | + <div v-else class="space-y-4"> | ||
| 64 | + <UAlert v-if="error" color="error" variant="subtle" icon="i-lucide-circle-alert" title="Import failed" :description="error" /> | ||
| 65 | + <ul class="max-h-48 divide-y divide-default overflow-auto rounded-lg border border-default text-sm"> | ||
| 66 | + <li v-for="i in items" :key="i.id" class="flex items-center gap-2 px-3 py-1.5"> | ||
| 67 | + <AgentBadge :agent="i.agent" compact /> | ||
| 68 | + <span class="truncate font-medium">{{ i.name }}</span> | ||
| 69 | + <span class="text-xs text-muted">{{ i.type }}</span> | ||
| 70 | + <UBadge v-if="statuses[i.id]?.state === 'differs-from-catalog'" label="differs from catalog" size="sm" color="warning" variant="subtle" class="ml-auto" /> | ||
| 71 | + </li> | ||
| 72 | + </ul> | ||
| 73 | + <UAlert v-if="differing.length" color="warning" variant="subtle" icon="i-lucide-git-compare" :title="`${differing.length} item(s) already exist in the catalog with different content`"> | ||
| 74 | + <template #description> | ||
| 75 | + <UCheckbox v-model="overwrite" label="Overwrite the catalog version" description="Unchecked, these items are left out." class="mt-1" /> | ||
| 76 | + </template> | ||
| 77 | + </UAlert> | ||
| 78 | + <UFormField label="Tags" description="Optional, applied to the imported items."> | ||
| 79 | + <UInputTags v-model="tags" class="w-full" placeholder="Add a tag, press Enter" /> | ||
| 80 | + </UFormField> | ||
| 81 | + </div> | ||
| 82 | + </template> | ||
| 83 | + <template #footer> | ||
| 84 | + <div class="flex w-full justify-end gap-2"> | ||
| 85 | + <UButton :label="result ? 'Close' : 'Cancel'" color="neutral" variant="outline" @click="open = false" /> | ||
| 86 | + <UButton v-if="!result" :label="`Add ${toSend.length} to catalog`" icon="i-lucide-library" :loading="busy" :disabled="!toSend.length" @click="submit" /> | ||
| 87 | + </div> | ||
| 88 | + </template> | ||
| 89 | + </UModal> | ||
| 90 | +</template> | ||
modified
packages/ui/app/components/AgentBadge.vue +1 -1 | @@ -6,7 +6,7 @@ withDefaults(defineProps<{ agent: AgentId, compact?: boolean }>(), { compact: fa | ||
| 6 | 6 | |
| 7 | 7 | <template> |
| 8 | 8 | <UBadge |
| 9 | - :color="agent === 'claude' ? 'warning' : 'success'" | |
| 9 | + :color="agent === 'claude' ? 'warning' : 'secondary'" | |
| 10 | 10 | variant="subtle" |
| 11 | 11 | :size="compact ? 'sm' : 'md'" |
| 12 | 12 | :icon="agent === 'claude' ? 'i-lucide-asterisk' : 'i-lucide-terminal'" |
| @@ -6,7 +6,7 @@ withDefaults(defineProps<{ agent: AgentId, compact?: boolean }>(), { compact: fa | |||
| 6 | 6 | ||
| 7 | <template> | 7 | <template> |
| 8 | <UBadge | 8 | <UBadge |
| 9 | - :color="agent === 'claude' ? 'warning' : 'success'" | 9 | + :color="agent === 'claude' ? 'warning' : 'secondary'" |
| 10 | variant="subtle" | 10 | variant="subtle" |
| 11 | :size="compact ? 'sm' : 'md'" | 11 | :size="compact ? 'sm' : 'md'" |
| 12 | :icon="agent === 'claude' ? 'i-lucide-asterisk' : 'i-lucide-terminal'" | 12 | :icon="agent === 'claude' ? 'i-lucide-asterisk' : 'i-lucide-terminal'" |
modified
packages/ui/app/components/AppSidebar.vue +17 -15 | @@ -7,11 +7,12 @@ const route = useRoute() | ||
| 7 | 7 | const linkClass = (active: boolean) => |
| 8 | 8 | `flex items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors ${active ? 'bg-elevated text-highlighted font-medium' : 'text-muted hover:bg-elevated/60 hover:text-default'}` |
| 9 | 9 | |
| 10 | -const soon = [ | |
| 11 | - { label: 'Catalog', icon: 'i-lucide-library' }, | |
| 12 | - { label: 'Presets', icon: 'i-lucide-layers' }, | |
| 13 | - { label: 'Marketplaces', icon: 'i-lucide-store' } | |
| 10 | +const library = [ | |
| 11 | + { label: 'Catalog', icon: 'i-lucide-library', to: '/catalog' }, | |
| 12 | + { label: 'Presets', icon: 'i-lucide-layers', to: '/presets' }, | |
| 13 | + { label: 'Marketplaces', icon: 'i-lucide-store', to: '/marketplaces' } | |
| 14 | 14 | ] |
| 15 | +const isActive = (to: string) => route.path === to || route.path.startsWith(to + '/') | |
| 15 | 16 | </script> |
| 16 | 17 | |
| 17 | 18 | <template> |
| @@ -62,17 +63,18 @@ const soon = [ | ||
| 62 | 63 | @click="$emit('add')" |
| 63 | 64 | /> |
| 64 | 65 | |
| 65 | - <div class="mt-4 space-y-1"> | |
| 66 | - <div | |
| 67 | - v-for="s in soon" | |
| 68 | - :key="s.label" | |
| 69 | - class="flex cursor-not-allowed items-center gap-2 rounded-md px-2 py-1.5 text-sm text-dimmed" | |
| 70 | - aria-disabled="true" | |
| 71 | - > | |
| 72 | - <UIcon :name="s.icon" class="size-4" /> | |
| 73 | - <span class="flex-1">{{ s.label }}</span> | |
| 74 | - <UBadge label="Soon" size="sm" color="neutral" variant="subtle" /> | |
| 75 | - </div> | |
| 66 | + <div class="mt-4 px-2"> | |
| 67 | + <span class="text-xs font-semibold uppercase tracking-wide text-muted">Library</span> | |
| 76 | 68 | </div> |
| 69 | + <NuxtLink v-for="l in library" :key="l.to" :to="l.to" :class="linkClass(isActive(l.to))"> | |
| 70 | + <UIcon :name="l.icon" class="size-4" /> | |
| 71 | + {{ l.label }} | |
| 72 | + </NuxtLink> | |
| 73 | + <NuxtLink to="/settings" :class="linkClass(isActive('/settings'))"> | |
| 74 | + <UIcon name="i-lucide-settings" class="size-4" /> | |
| 75 | + Settings | |
| 76 | + </NuxtLink> | |
| 77 | + | |
| 78 | + <CatalogGitIndicator class="mt-3" /> | |
| 77 | 79 | </nav> |
| 78 | 80 | </template> |
| @@ -7,11 +7,12 @@ const route = useRoute() | |||
| 7 | const linkClass = (active: boolean) => | 7 | const linkClass = (active: boolean) => |
| 8 | `flex items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors ${active ? 'bg-elevated text-highlighted font-medium' : 'text-muted hover:bg-elevated/60 hover:text-default'}` | 8 | `flex items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors ${active ? 'bg-elevated text-highlighted font-medium' : 'text-muted hover:bg-elevated/60 hover:text-default'}` |
| 9 | 9 | ||
| 10 | -const soon = [ | 10 | +const library = [ |
| 11 | - { label: 'Catalog', icon: 'i-lucide-library' }, | 11 | + { label: 'Catalog', icon: 'i-lucide-library', to: '/catalog' }, |
| 12 | - { label: 'Presets', icon: 'i-lucide-layers' }, | 12 | + { label: 'Presets', icon: 'i-lucide-layers', to: '/presets' }, |
| 13 | - { label: 'Marketplaces', icon: 'i-lucide-store' } | 13 | + { label: 'Marketplaces', icon: 'i-lucide-store', to: '/marketplaces' } |
| 14 | ] | 14 | ] |
| 15 | +const isActive = (to: string) => route.path === to || route.path.startsWith(to + '/') | ||
| 15 | </script> | 16 | </script> |
| 16 | 17 | ||
| 17 | <template> | 18 | <template> |
| @@ -62,17 +63,18 @@ const soon = [ | |||
| 62 | @click="$emit('add')" | 63 | @click="$emit('add')" |
| 63 | /> | 64 | /> |
| 64 | 65 | ||
| 65 | - <div class="mt-4 space-y-1"> | 66 | + <div class="mt-4 px-2"> |
| 66 | - <div | 67 | + <span class="text-xs font-semibold uppercase tracking-wide text-muted">Library</span> |
| 67 | - v-for="s in soon" | ||
| 68 | - :key="s.label" | ||
| 69 | - class="flex cursor-not-allowed items-center gap-2 rounded-md px-2 py-1.5 text-sm text-dimmed" | ||
| 70 | - aria-disabled="true" | ||
| 71 | - > | ||
| 72 | - <UIcon :name="s.icon" class="size-4" /> | ||
| 73 | - <span class="flex-1">{{ s.label }}</span> | ||
| 74 | - <UBadge label="Soon" size="sm" color="neutral" variant="subtle" /> | ||
| 75 | - </div> | ||
| 76 | </div> | 68 | </div> |
| 69 | + <NuxtLink v-for="l in library" :key="l.to" :to="l.to" :class="linkClass(isActive(l.to))"> | ||
| 70 | + <UIcon :name="l.icon" class="size-4" /> | ||
| 71 | + {{ l.label }} | ||
| 72 | + </NuxtLink> | ||
| 73 | + <NuxtLink to="/settings" :class="linkClass(isActive('/settings'))"> | ||
| 74 | + <UIcon name="i-lucide-settings" class="size-4" /> | ||
| 75 | + Settings | ||
| 76 | + </NuxtLink> | ||
| 77 | + | ||
| 78 | + <CatalogGitIndicator class="mt-3" /> | ||
| 77 | </nav> | 79 | </nav> |
| 78 | </template> | 80 | </template> |
added
packages/ui/app/components/ApplyModal.vue +251 -0 | new file mode 100644 | ||
| @@ -0,0 +1,251 @@ | ||
| 1 | +<script setup lang="ts"> | |
| 2 | +import type { ApplyResponse, ConflictResolution, FileContent, PlanAction, PlanActionKind, ScopeState, SyncPlan } from '@skilly/core' | |
| 3 | + | |
| 4 | +const props = defineProps<{ scope: string, plan: SyncPlan, home: boolean }>() | |
| 5 | +const open = defineModel<boolean>('open', { default: false }) | |
| 6 | +const emit = defineEmits<{ applied: [state: ScopeState] }>() | |
| 7 | + | |
| 8 | +const { request } = useApi() | |
| 9 | +const toast = useToast() | |
| 10 | + | |
| 11 | +const KIND: Record<Exclude<PlanActionKind, 'unchanged'>, { label: string, icon: string, color: string }> = { | |
| 12 | + conflict: { label: 'Conflicts', icon: 'i-lucide-triangle-alert', color: 'text-warning' }, | |
| 13 | + add: { label: 'To add', icon: 'i-lucide-plus', color: 'text-success' }, | |
| 14 | + update: { label: 'To update', icon: 'i-lucide-refresh-cw', color: 'text-info' }, | |
| 15 | + remove: { label: 'To remove', icon: 'i-lucide-trash-2', color: 'text-error' } | |
| 16 | +} | |
| 17 | +const KIND_ORDER = ['conflict', 'add', 'update', 'remove'] as const | |
| 18 | + | |
| 19 | +const actions = computed(() => props.plan.actions.filter(a => a.kind !== 'unchanged')) | |
| 20 | +const groups = computed(() => KIND_ORDER.map(k => ({ kind: k, rows: actions.value.filter(a => a.kind === k) })).filter(g => g.rows.length)) | |
| 21 | +const conflicts = computed(() => actions.value.filter(a => a.kind === 'conflict')) | |
| 22 | + | |
| 23 | +const resolutions = ref<Record<string, ConflictResolution>>({}) | |
| 24 | +const busy = ref(false) | |
| 25 | +const response = ref<ApplyResponse | null>(null) | |
| 26 | +const error = ref<string | null>(null) | |
| 27 | +const expanded = ref<Set<string>>(new Set()) | |
| 28 | + | |
| 29 | +watch(open, (v) => { | |
| 30 | + if (!v) return | |
| 31 | + response.value = null | |
| 32 | + error.value = null | |
| 33 | + expanded.value = new Set() | |
| 34 | + compare.value = {} | |
| 35 | + // Safe default: never overwrite a manual change unless the user chooses to. | |
| 36 | + resolutions.value = Object.fromEntries(conflicts.value.map(a => [a.id, 'keep-local' as ConflictResolution])) | |
| 37 | +}) | |
| 38 | + | |
| 39 | +const CONFLICT_LABEL: Record<string, string> = { | |
| 40 | + 'modified-locally': 'Edited in the scope since the last sync', | |
| 41 | + 'exists-unmanaged': 'A file already exists here and is not managed by skilly', | |
| 42 | + 'missing-in-catalog': 'No longer in the catalog' | |
| 43 | +} | |
| 44 | + | |
| 45 | +const choices = (a: PlanAction) => [ | |
| 46 | + { label: 'Keep local', value: 'keep-local', description: 'Leave the scope version as is' }, | |
| 47 | + { label: 'Take catalog', value: 'take-catalog', description: 'Overwrite with the catalog version', disabled: a.conflict === 'missing-in-catalog' }, | |
| 48 | + { label: 'Push to catalog', value: 'push-to-catalog', description: 'Copy the scope version into the catalog' } | |
| 49 | +] | |
| 50 | + | |
| 51 | +function toggle(id: string) { | |
| 52 | + const s = new Set(expanded.value) | |
| 53 | + if (s.has(id)) s.delete(id) | |
| 54 | + else s.add(id) | |
| 55 | + expanded.value = s | |
| 56 | +} | |
| 57 | + | |
| 58 | +/* ------------------------------------------------ Compare */ | |
| 59 | + | |
| 60 | +interface Compare { loading: boolean, error?: string, local?: string, catalog?: string, note?: string } | |
| 61 | +const compare = ref<Record<string, Compare>>({}) | |
| 62 | + | |
| 63 | +/** The scope file to compare, only when it is the item's own file (not a shared config file). */ | |
| 64 | +function comparableFile(a: PlanAction): string | null { | |
| 65 | + const norm = (f: string) => f.replace(/\\/g, '/') | |
| 66 | + if (a.type === 'skill') return a.files.map(norm).find(f => f.endsWith(`/${a.name}/SKILL.md`)) ?? null | |
| 67 | + if (a.type === 'rule' || a.type === 'agent') return a.files.map(norm).find(f => f.endsWith(`/${a.name}.md`)) ?? null | |
| 68 | + return null | |
| 69 | +} | |
| 70 | + | |
| 71 | +async function loadCompare(a: PlanAction) { | |
| 72 | + const file = comparableFile(a) | |
| 73 | + if (!file) { | |
| 74 | + compare.value = { ...compare.value, [a.id]: { loading: false, note: 'This item lives in a shared configuration file (or an AGENTS.md block): there is no standalone file to compare.' } } | |
| 75 | + return | |
| 76 | + } | |
| 77 | + compare.value = { ...compare.value, [a.id]: { loading: true } } | |
| 78 | + const out: Compare = { loading: false } | |
| 79 | + try { | |
| 80 | + out.local = (await request<FileContent>('/api/files', { query: { path: joinPath(props.plan.root, file) }, silent: true })).content | |
| 81 | + } catch (e) { | |
| 82 | + out.error = `Scope file: ${(e as Error).message}` | |
| 83 | + } | |
| 84 | + if (a.conflict === 'missing-in-catalog') out.note = 'The item no longer exists in the catalog.' | |
| 85 | + else { | |
| 86 | + try { | |
| 87 | + out.catalog = (await request<{ content: string }>(`/api/catalog/items/${a.type}/${encodeURIComponent(a.name)}`, { silent: true })).content | |
| 88 | + } catch (e) { | |
| 89 | + out.error = [out.error, `Catalog: ${(e as Error).message}`].filter(Boolean).join(' / ') | |
| 90 | + } | |
| 91 | + } | |
| 92 | + compare.value = { ...compare.value, [a.id]: out } | |
| 93 | +} | |
| 94 | + | |
| 95 | +function toggleCompare(a: PlanAction) { | |
| 96 | + toggle(a.id) | |
| 97 | + if (expanded.value.has(a.id) && !compare.value[a.id]) loadCompare(a) | |
| 98 | +} | |
| 99 | + | |
| 100 | +/** Lines of `text`, flagged when the other side has no identical line (cheap "diff-ish" highlighting). */ | |
| 101 | +function marked(text: string | undefined, other: string | undefined) { | |
| 102 | + const set = new Set((other ?? '').split(/\r?\n/)) | |
| 103 | + return (text ?? '').split(/\r?\n/).map(line => ({ line, diff: other !== undefined && !set.has(line) })) | |
| 104 | +} | |
| 105 | + | |
| 106 | +/* ------------------------------------------------ Apply */ | |
| 107 | + | |
| 108 | +let appliedActions: PlanAction[] = [] | |
| 109 | +async function apply() { | |
| 110 | + appliedActions = [...props.plan.actions] | |
| 111 | + busy.value = true | |
| 112 | + error.value = null | |
| 113 | + try { | |
| 114 | + response.value = await request<ApplyResponse>(`/api/scopes/${encodeURIComponent(props.scope)}/apply`, { | |
| 115 | + method: 'POST', | |
| 116 | + body: { resolutions: resolutions.value }, | |
| 117 | + silent: true | |
| 118 | + }) | |
| 119 | + emit('applied', response.value.state) | |
| 120 | + if (!response.value.result.errors.length) toast.add({ title: 'Changes applied', icon: 'i-lucide-check', color: 'success' }) | |
| 121 | + } catch (e) { | |
| 122 | + error.value = (e as Error).message | |
| 123 | + } finally { | |
| 124 | + busy.value = false | |
| 125 | + } | |
| 126 | +} | |
| 127 | + | |
| 128 | +const label = (id: string) => { | |
| 129 | + const a = appliedActions.find(x => x.id === id) | |
| 130 | + return a ? `${a.type}/${a.name} (${AGENT_LABELS[a.agent]})` : id | |
| 131 | +} | |
| 132 | +</script> | |
| 133 | + | |
| 134 | +<template> | |
| 135 | + <UModal v-model:open="open" title="Review changes" :description="response ? 'Result of the sync.' : 'Nothing is written until you click Apply.'" :ui="{ content: 'max-w-4xl' }"> | |
| 136 | + <template #body> | |
| 137 | + <!-- Result --> | |
| 138 | + <div v-if="response" class="space-y-3 text-sm"> | |
| 139 | + <UAlert | |
| 140 | + :color="response.result.errors.length ? 'warning' : 'success'" | |
| 141 | + variant="subtle" | |
| 142 | + :icon="response.result.errors.length ? 'i-lucide-triangle-alert' : 'i-lucide-check'" | |
| 143 | + :title="`${response.result.applied.length} applied, ${response.result.skipped.length} skipped, ${response.result.errors.length} error(s)`" | |
| 144 | + /> | |
| 145 | + <UAlert v-if="response.result.errors.length" color="error" variant="subtle" icon="i-lucide-circle-alert" title="Errors"> | |
| 146 | + <template #description> | |
| 147 | + <ul class="list-disc space-y-0.5 pl-4"><li v-for="e in response.result.errors" :key="e.actionId"><span class="font-medium">{{ label(e.actionId) }}</span>: {{ e.message }}</li></ul> | |
| 148 | + </template> | |
| 149 | + </UAlert> | |
| 150 | + <div v-if="response.result.skipped.length"> | |
| 151 | + <p class="mb-1 text-xs font-medium text-muted">Skipped</p> | |
| 152 | + <ul class="list-disc pl-5 text-xs text-muted"><li v-for="s in response.result.skipped" :key="s">{{ label(s) }}</li></ul> | |
| 153 | + </div> | |
| 154 | + <div v-if="response.result.applied.length"> | |
| 155 | + <p class="mb-1 text-xs font-medium text-muted">Applied</p> | |
| 156 | + <ul class="list-disc pl-5 text-xs text-muted"><li v-for="s in response.result.applied" :key="s">{{ label(s) }}</li></ul> | |
| 157 | + </div> | |
| 158 | + </div> | |
| 159 | + | |
| 160 | + <!-- Review --> | |
| 161 | + <div v-else class="space-y-4"> | |
| 162 | + <UAlert v-if="error" color="error" variant="subtle" icon="i-lucide-circle-alert" title="Apply failed" :description="error" /> | |
| 163 | + <UAlert v-if="home" color="warning" variant="subtle" icon="i-lucide-house" title="This writes into your Home agent folders" description="Home is shared by all your projects. Review the files below." /> | |
| 164 | + | |
| 165 | + <UAlert v-if="plan.warnings.length" color="warning" variant="subtle" icon="i-lucide-triangle-alert" :title="plan.warnings.length > 1 ? 'Warnings' : 'Warning'"> | |
| 166 | + <template #description> | |
| 167 | + <ul class="list-disc space-y-0.5 pl-4"><li v-for="w in plan.warnings" :key="w">{{ w }}</li></ul> | |
| 168 | + </template> | |
| 169 | + </UAlert> | |
| 170 | + <UAlert v-if="plan.missing.length" color="warning" variant="subtle" icon="i-lucide-circle-help" :title="`${plan.missing.length} item(s) not found in the catalog`"> | |
| 171 | + <template #description> | |
| 172 | + <p class="mb-1">They are referenced by the configuration and will be skipped.</p> | |
| 173 | + <div class="flex flex-wrap gap-1"><UBadge v-for="m in plan.missing" :key="`${m.type}/${m.name}`" :label="`${m.type}/${m.name}`" size="sm" color="warning" variant="outline" class="font-mono" /></div> | |
| 174 | + </template> | |
| 175 | + </UAlert> | |
| 176 | + | |
| 177 | + <section v-for="g in groups" :key="g.kind" class="space-y-1.5"> | |
| 178 | + <h3 class="flex items-center gap-1.5 text-sm font-semibold"> | |
| 179 | + <UIcon :name="KIND[g.kind].icon" class="size-4" :class="KIND[g.kind].color" />{{ KIND[g.kind].label }} ({{ g.rows.length }}) | |
| 180 | + </h3> | |
| 181 | + <ul class="divide-y divide-default overflow-hidden rounded-lg border border-default text-sm"> | |
| 182 | + <li v-for="a in g.rows" :key="a.id" class="px-3 py-2"> | |
| 183 | + <div class="flex items-start gap-2"> | |
| 184 | + <AgentBadge :agent="a.agent" compact class="mt-0.5" /> | |
| 185 | + <div class="min-w-0 flex-1"> | |
| 186 | + <div class="flex items-center gap-2"> | |
| 187 | + <UIcon :name="CAT_META[a.type].icon" class="size-3.5 shrink-0 text-muted" /> | |
| 188 | + <span class="truncate font-medium">{{ a.name }}</span> | |
| 189 | + <UBadge v-if="a.catalogUpdated" label="catalog updated" size="sm" color="info" variant="subtle" /> | |
| 190 | + <UBadge v-if="a.conflict" :label="a.conflict" size="sm" color="warning" variant="subtle" /> | |
| 191 | + </div> | |
| 192 | + <p class="text-xs text-muted">{{ a.conflict ? CONFLICT_LABEL[a.conflict] : a.reason }}<span v-if="a.conflict && a.reason"> - {{ a.reason }}</span></p> | |
| 193 | + </div> | |
| 194 | + <UButton | |
| 195 | + v-if="a.files.length || a.configKeys.length" | |
| 196 | + :label="`${a.files.length + a.configKeys.length} file${a.files.length + a.configKeys.length > 1 ? 's' : ''}`" | |
| 197 | + :trailing-icon="expanded.has(a.id) ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'" | |
| 198 | + size="xs" color="neutral" variant="link" class="shrink-0" | |
| 199 | + @click="a.kind === 'conflict' ? toggleCompare(a) : toggle(a.id)" | |
| 200 | + /> | |
| 201 | + </div> | |
| 202 | + | |
| 203 | + <ul v-if="expanded.has(a.id) && (a.files.length || a.configKeys.length)" class="mt-1.5 space-y-0.5 pl-9 font-mono text-[11px] text-muted"> | |
| 204 | + <li v-for="f in a.files" :key="f" class="break-all"><UIcon name="i-lucide-file" class="mr-1 size-3 align-text-bottom" />{{ f }}</li> | |
| 205 | + <li v-for="c in a.configKeys" :key="c.file + c.key" class="break-all"><UIcon name="i-lucide-braces" class="mr-1 size-3 align-text-bottom" />{{ c.file }} : {{ c.key }}</li> | |
| 206 | + </ul> | |
| 207 | + | |
| 208 | + <!-- Conflict resolution --> | |
| 209 | + <div v-if="a.kind === 'conflict'" class="mt-2 space-y-2 pl-9"> | |
| 210 | + <URadioGroup v-model="resolutions[a.id]" :items="choices(a)" orientation="horizontal" size="sm" :aria-label="`Resolution for ${a.name}`" /> | |
| 211 | + <div v-if="expanded.has(a.id)"> | |
| 212 | + <USkeleton v-if="compare[a.id]?.loading" class="h-24 w-full" /> | |
| 213 | + <template v-else-if="compare[a.id]"> | |
| 214 | + <UAlert v-if="compare[a.id]!.note" color="neutral" variant="subtle" icon="i-lucide-info" :title="compare[a.id]!.note" class="mb-2" /> | |
| 215 | + <UAlert v-if="compare[a.id]!.error" color="error" variant="subtle" icon="i-lucide-circle-alert" :title="compare[a.id]!.error" class="mb-2" /> | |
| 216 | + <div v-if="compare[a.id]!.local !== undefined || compare[a.id]!.catalog !== undefined" class="grid gap-2 md:grid-cols-2"> | |
| 217 | + <div v-for="side in ([['Scope (local)', 'local', 'catalog'], ['Catalog', 'catalog', 'local']] as const)" :key="side[0]" class="min-w-0"> | |
| 218 | + <p class="mb-1 text-xs font-medium text-muted">{{ side[0] }}</p> | |
| 219 | + <div class="max-h-64 overflow-auto rounded-lg border border-default bg-elevated/40 py-1 font-mono text-[11px] leading-relaxed"> | |
| 220 | + <div v-if="compare[a.id]![side[1]] === undefined" class="px-2 text-muted">Not available</div> | |
| 221 | + <div | |
| 222 | + v-for="(l, i) in marked(compare[a.id]![side[1]], compare[a.id]![side[2]])" | |
| 223 | + :key="i" | |
| 224 | + class="whitespace-pre-wrap break-all px-2" | |
| 225 | + :class="l.diff ? (side[1] === 'local' ? 'bg-warning/15' : 'bg-info/15') : ''" | |
| 226 | + >{{ l.line || ' ' }}</div> | |
| 227 | + </div> | |
| 228 | + </div> | |
| 229 | + </div> | |
| 230 | + </template> | |
| 231 | + </div> | |
| 232 | + </div> | |
| 233 | + </li> | |
| 234 | + </ul> | |
| 235 | + </section> | |
| 236 | + | |
| 237 | + <p v-if="!groups.length" class="rounded-lg border border-dashed border-default p-6 text-center text-sm text-muted">Nothing to apply.</p> | |
| 238 | + </div> | |
| 239 | + </template> | |
| 240 | + | |
| 241 | + <template #footer> | |
| 242 | + <div class="flex w-full items-center justify-between gap-2"> | |
| 243 | + <span class="text-xs text-muted">{{ response ? '' : `${actions.length} change${actions.length > 1 ? 's' : ''}${conflicts.length ? `, ${conflicts.length} conflict${conflicts.length > 1 ? 's' : ''}` : ''}` }}</span> | |
| 244 | + <div class="flex gap-2"> | |
| 245 | + <UButton :label="response ? 'Close' : 'Cancel'" color="neutral" variant="outline" @click="open = false" /> | |
| 246 | + <UButton v-if="!response" label="Apply" icon="i-lucide-check" :loading="busy" :disabled="!actions.length" @click="apply" /> | |
| 247 | + </div> | |
| 248 | + </div> | |
| 249 | + </template> | |
| 250 | + </UModal> | |
| 251 | +</template> | |
| new file mode 100644 | |||
| @@ -0,0 +1,251 @@ | |||
| 1 | +<script setup lang="ts"> | ||
| 2 | +import type { ApplyResponse, ConflictResolution, FileContent, PlanAction, PlanActionKind, ScopeState, SyncPlan } from '@skilly/core' | ||
| 3 | + | ||
| 4 | +const props = defineProps<{ scope: string, plan: SyncPlan, home: boolean }>() | ||
| 5 | +const open = defineModel<boolean>('open', { default: false }) | ||
| 6 | +const emit = defineEmits<{ applied: [state: ScopeState] }>() | ||
| 7 | + | ||
| 8 | +const { request } = useApi() | ||
| 9 | +const toast = useToast() | ||
| 10 | + | ||
| 11 | +const KIND: Record<Exclude<PlanActionKind, 'unchanged'>, { label: string, icon: string, color: string }> = { | ||
| 12 | + conflict: { label: 'Conflicts', icon: 'i-lucide-triangle-alert', color: 'text-warning' }, | ||
| 13 | + add: { label: 'To add', icon: 'i-lucide-plus', color: 'text-success' }, | ||
| 14 | + update: { label: 'To update', icon: 'i-lucide-refresh-cw', color: 'text-info' }, | ||
| 15 | + remove: { label: 'To remove', icon: 'i-lucide-trash-2', color: 'text-error' } | ||
| 16 | +} | ||
| 17 | +const KIND_ORDER = ['conflict', 'add', 'update', 'remove'] as const | ||
| 18 | + | ||
| 19 | +const actions = computed(() => props.plan.actions.filter(a => a.kind !== 'unchanged')) | ||
| 20 | +const groups = computed(() => KIND_ORDER.map(k => ({ kind: k, rows: actions.value.filter(a => a.kind === k) })).filter(g => g.rows.length)) | ||
| 21 | +const conflicts = computed(() => actions.value.filter(a => a.kind === 'conflict')) | ||
| 22 | + | ||
| 23 | +const resolutions = ref<Record<string, ConflictResolution>>({}) | ||
| 24 | +const busy = ref(false) | ||
| 25 | +const response = ref<ApplyResponse | null>(null) | ||
| 26 | +const error = ref<string | null>(null) | ||
| 27 | +const expanded = ref<Set<string>>(new Set()) | ||
| 28 | + | ||
| 29 | +watch(open, (v) => { | ||
| 30 | + if (!v) return | ||
| 31 | + response.value = null | ||
| 32 | + error.value = null | ||
| 33 | + expanded.value = new Set() | ||
| 34 | + compare.value = {} | ||
| 35 | + // Safe default: never overwrite a manual change unless the user chooses to. | ||
| 36 | + resolutions.value = Object.fromEntries(conflicts.value.map(a => [a.id, 'keep-local' as ConflictResolution])) | ||
| 37 | +}) | ||
| 38 | + | ||
| 39 | +const CONFLICT_LABEL: Record<string, string> = { | ||
| 40 | + 'modified-locally': 'Edited in the scope since the last sync', | ||
| 41 | + 'exists-unmanaged': 'A file already exists here and is not managed by skilly', | ||
| 42 | + 'missing-in-catalog': 'No longer in the catalog' | ||
| 43 | +} | ||
| 44 | + | ||
| 45 | +const choices = (a: PlanAction) => [ | ||
| 46 | + { label: 'Keep local', value: 'keep-local', description: 'Leave the scope version as is' }, | ||
| 47 | + { label: 'Take catalog', value: 'take-catalog', description: 'Overwrite with the catalog version', disabled: a.conflict === 'missing-in-catalog' }, | ||
| 48 | + { label: 'Push to catalog', value: 'push-to-catalog', description: 'Copy the scope version into the catalog' } | ||
| 49 | +] | ||
| 50 | + | ||
| 51 | +function toggle(id: string) { | ||
| 52 | + const s = new Set(expanded.value) | ||
| 53 | + if (s.has(id)) s.delete(id) | ||
| 54 | + else s.add(id) | ||
| 55 | + expanded.value = s | ||
| 56 | +} | ||
| 57 | + | ||
| 58 | +/* ------------------------------------------------ Compare */ | ||
| 59 | + | ||
| 60 | +interface Compare { loading: boolean, error?: string, local?: string, catalog?: string, note?: string } | ||
| 61 | +const compare = ref<Record<string, Compare>>({}) | ||
| 62 | + | ||
| 63 | +/** The scope file to compare, only when it is the item's own file (not a shared config file). */ | ||
| 64 | +function comparableFile(a: PlanAction): string | null { | ||
| 65 | + const norm = (f: string) => f.replace(/\\/g, '/') | ||
| 66 | + if (a.type === 'skill') return a.files.map(norm).find(f => f.endsWith(`/${a.name}/SKILL.md`)) ?? null | ||
| 67 | + if (a.type === 'rule' || a.type === 'agent') return a.files.map(norm).find(f => f.endsWith(`/${a.name}.md`)) ?? null | ||
| 68 | + return null | ||
| 69 | +} | ||
| 70 | + | ||
| 71 | +async function loadCompare(a: PlanAction) { | ||
| 72 | + const file = comparableFile(a) | ||
| 73 | + if (!file) { | ||
| 74 | + compare.value = { ...compare.value, [a.id]: { loading: false, note: 'This item lives in a shared configuration file (or an AGENTS.md block): there is no standalone file to compare.' } } | ||
| 75 | + return | ||
| 76 | + } | ||
| 77 | + compare.value = { ...compare.value, [a.id]: { loading: true } } | ||
| 78 | + const out: Compare = { loading: false } | ||
| 79 | + try { | ||
| 80 | + out.local = (await request<FileContent>('/api/files', { query: { path: joinPath(props.plan.root, file) }, silent: true })).content | ||
| 81 | + } catch (e) { | ||
| 82 | + out.error = `Scope file: ${(e as Error).message}` | ||
| 83 | + } | ||
| 84 | + if (a.conflict === 'missing-in-catalog') out.note = 'The item no longer exists in the catalog.' | ||
| 85 | + else { | ||
| 86 | + try { | ||
| 87 | + out.catalog = (await request<{ content: string }>(`/api/catalog/items/${a.type}/${encodeURIComponent(a.name)}`, { silent: true })).content | ||
| 88 | + } catch (e) { | ||
| 89 | + out.error = [out.error, `Catalog: ${(e as Error).message}`].filter(Boolean).join(' / ') | ||
| 90 | + } | ||
| 91 | + } | ||
| 92 | + compare.value = { ...compare.value, [a.id]: out } | ||
| 93 | +} | ||
| 94 | + | ||
| 95 | +function toggleCompare(a: PlanAction) { | ||
| 96 | + toggle(a.id) | ||
| 97 | + if (expanded.value.has(a.id) && !compare.value[a.id]) loadCompare(a) | ||
| 98 | +} | ||
| 99 | + | ||
| 100 | +/** Lines of `text`, flagged when the other side has no identical line (cheap "diff-ish" highlighting). */ | ||
| 101 | +function marked(text: string | undefined, other: string | undefined) { | ||
| 102 | + const set = new Set((other ?? '').split(/\r?\n/)) | ||
| 103 | + return (text ?? '').split(/\r?\n/).map(line => ({ line, diff: other !== undefined && !set.has(line) })) | ||
| 104 | +} | ||
| 105 | + | ||
| 106 | +/* ------------------------------------------------ Apply */ | ||
| 107 | + | ||
| 108 | +let appliedActions: PlanAction[] = [] | ||
| 109 | +async function apply() { | ||
| 110 | + appliedActions = [...props.plan.actions] | ||
| 111 | + busy.value = true | ||
| 112 | + error.value = null | ||
| 113 | + try { | ||
| 114 | + response.value = await request<ApplyResponse>(`/api/scopes/${encodeURIComponent(props.scope)}/apply`, { | ||
| 115 | + method: 'POST', | ||
| 116 | + body: { resolutions: resolutions.value }, | ||
| 117 | + silent: true | ||
| 118 | + }) | ||
| 119 | + emit('applied', response.value.state) | ||
| 120 | + if (!response.value.result.errors.length) toast.add({ title: 'Changes applied', icon: 'i-lucide-check', color: 'success' }) | ||
| 121 | + } catch (e) { | ||
| 122 | + error.value = (e as Error).message | ||
| 123 | + } finally { | ||
| 124 | + busy.value = false | ||
| 125 | + } | ||
| 126 | +} | ||
| 127 | + | ||
| 128 | +const label = (id: string) => { | ||
| 129 | + const a = appliedActions.find(x => x.id === id) | ||
| 130 | + return a ? `${a.type}/${a.name} (${AGENT_LABELS[a.agent]})` : id | ||
| 131 | +} | ||
| 132 | +</script> | ||
| 133 | + | ||
| 134 | +<template> | ||
| 135 | + <UModal v-model:open="open" title="Review changes" :description="response ? 'Result of the sync.' : 'Nothing is written until you click Apply.'" :ui="{ content: 'max-w-4xl' }"> | ||
| 136 | + <template #body> | ||
| 137 | + <!-- Result --> | ||
| 138 | + <div v-if="response" class="space-y-3 text-sm"> | ||
| 139 | + <UAlert | ||
| 140 | + :color="response.result.errors.length ? 'warning' : 'success'" | ||
| 141 | + variant="subtle" | ||
| 142 | + :icon="response.result.errors.length ? 'i-lucide-triangle-alert' : 'i-lucide-check'" | ||
| 143 | + :title="`${response.result.applied.length} applied, ${response.result.skipped.length} skipped, ${response.result.errors.length} error(s)`" | ||
| 144 | + /> | ||
| 145 | + <UAlert v-if="response.result.errors.length" color="error" variant="subtle" icon="i-lucide-circle-alert" title="Errors"> | ||
| 146 | + <template #description> | ||
| 147 | + <ul class="list-disc space-y-0.5 pl-4"><li v-for="e in response.result.errors" :key="e.actionId"><span class="font-medium">{{ label(e.actionId) }}</span>: {{ e.message }}</li></ul> | ||
| 148 | + </template> | ||
| 149 | + </UAlert> | ||
| 150 | + <div v-if="response.result.skipped.length"> | ||
| 151 | + <p class="mb-1 text-xs font-medium text-muted">Skipped</p> | ||
| 152 | + <ul class="list-disc pl-5 text-xs text-muted"><li v-for="s in response.result.skipped" :key="s">{{ label(s) }}</li></ul> | ||
| 153 | + </div> | ||
| 154 | + <div v-if="response.result.applied.length"> | ||
| 155 | + <p class="mb-1 text-xs font-medium text-muted">Applied</p> | ||
| 156 | + <ul class="list-disc pl-5 text-xs text-muted"><li v-for="s in response.result.applied" :key="s">{{ label(s) }}</li></ul> | ||
| 157 | + </div> | ||
| 158 | + </div> | ||
| 159 | + | ||
| 160 | + <!-- Review --> | ||
| 161 | + <div v-else class="space-y-4"> | ||
| 162 | + <UAlert v-if="error" color="error" variant="subtle" icon="i-lucide-circle-alert" title="Apply failed" :description="error" /> | ||
| 163 | + <UAlert v-if="home" color="warning" variant="subtle" icon="i-lucide-house" title="This writes into your Home agent folders" description="Home is shared by all your projects. Review the files below." /> | ||
| 164 | + | ||
| 165 | + <UAlert v-if="plan.warnings.length" color="warning" variant="subtle" icon="i-lucide-triangle-alert" :title="plan.warnings.length > 1 ? 'Warnings' : 'Warning'"> | ||
| 166 | + <template #description> | ||
| 167 | + <ul class="list-disc space-y-0.5 pl-4"><li v-for="w in plan.warnings" :key="w">{{ w }}</li></ul> | ||
| 168 | + </template> | ||
| 169 | + </UAlert> | ||
| 170 | + <UAlert v-if="plan.missing.length" color="warning" variant="subtle" icon="i-lucide-circle-help" :title="`${plan.missing.length} item(s) not found in the catalog`"> | ||
| 171 | + <template #description> | ||
| 172 | + <p class="mb-1">They are referenced by the configuration and will be skipped.</p> | ||
| 173 | + <div class="flex flex-wrap gap-1"><UBadge v-for="m in plan.missing" :key="`${m.type}/${m.name}`" :label="`${m.type}/${m.name}`" size="sm" color="warning" variant="outline" class="font-mono" /></div> | ||
| 174 | + </template> | ||
| 175 | + </UAlert> | ||
| 176 | + | ||
| 177 | + <section v-for="g in groups" :key="g.kind" class="space-y-1.5"> | ||
| 178 | + <h3 class="flex items-center gap-1.5 text-sm font-semibold"> | ||
| 179 | + <UIcon :name="KIND[g.kind].icon" class="size-4" :class="KIND[g.kind].color" />{{ KIND[g.kind].label }} ({{ g.rows.length }}) | ||
| 180 | + </h3> | ||
| 181 | + <ul class="divide-y divide-default overflow-hidden rounded-lg border border-default text-sm"> | ||
| 182 | + <li v-for="a in g.rows" :key="a.id" class="px-3 py-2"> | ||
| 183 | + <div class="flex items-start gap-2"> | ||
| 184 | + <AgentBadge :agent="a.agent" compact class="mt-0.5" /> | ||
| 185 | + <div class="min-w-0 flex-1"> | ||
| 186 | + <div class="flex items-center gap-2"> | ||
| 187 | + <UIcon :name="CAT_META[a.type].icon" class="size-3.5 shrink-0 text-muted" /> | ||
| 188 | + <span class="truncate font-medium">{{ a.name }}</span> | ||
| 189 | + <UBadge v-if="a.catalogUpdated" label="catalog updated" size="sm" color="info" variant="subtle" /> | ||
| 190 | + <UBadge v-if="a.conflict" :label="a.conflict" size="sm" color="warning" variant="subtle" /> | ||
| 191 | + </div> | ||
| 192 | + <p class="text-xs text-muted">{{ a.conflict ? CONFLICT_LABEL[a.conflict] : a.reason }}<span v-if="a.conflict && a.reason"> - {{ a.reason }}</span></p> | ||
| 193 | + </div> | ||
| 194 | + <UButton | ||
| 195 | + v-if="a.files.length || a.configKeys.length" | ||
| 196 | + :label="`${a.files.length + a.configKeys.length} file${a.files.length + a.configKeys.length > 1 ? 's' : ''}`" | ||
| 197 | + :trailing-icon="expanded.has(a.id) ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'" | ||
| 198 | + size="xs" color="neutral" variant="link" class="shrink-0" | ||
| 199 | + @click="a.kind === 'conflict' ? toggleCompare(a) : toggle(a.id)" | ||
| 200 | + /> | ||
| 201 | + </div> | ||
| 202 | + | ||
| 203 | + <ul v-if="expanded.has(a.id) && (a.files.length || a.configKeys.length)" class="mt-1.5 space-y-0.5 pl-9 font-mono text-[11px] text-muted"> | ||
| 204 | + <li v-for="f in a.files" :key="f" class="break-all"><UIcon name="i-lucide-file" class="mr-1 size-3 align-text-bottom" />{{ f }}</li> | ||
| 205 | + <li v-for="c in a.configKeys" :key="c.file + c.key" class="break-all"><UIcon name="i-lucide-braces" class="mr-1 size-3 align-text-bottom" />{{ c.file }} : {{ c.key }}</li> | ||
| 206 | + </ul> | ||
| 207 | + | ||
| 208 | + <!-- Conflict resolution --> | ||
| 209 | + <div v-if="a.kind === 'conflict'" class="mt-2 space-y-2 pl-9"> | ||
| 210 | + <URadioGroup v-model="resolutions[a.id]" :items="choices(a)" orientation="horizontal" size="sm" :aria-label="`Resolution for ${a.name}`" /> | ||
| 211 | + <div v-if="expanded.has(a.id)"> | ||
| 212 | + <USkeleton v-if="compare[a.id]?.loading" class="h-24 w-full" /> | ||
| 213 | + <template v-else-if="compare[a.id]"> | ||
| 214 | + <UAlert v-if="compare[a.id]!.note" color="neutral" variant="subtle" icon="i-lucide-info" :title="compare[a.id]!.note" class="mb-2" /> | ||
| 215 | + <UAlert v-if="compare[a.id]!.error" color="error" variant="subtle" icon="i-lucide-circle-alert" :title="compare[a.id]!.error" class="mb-2" /> | ||
| 216 | + <div v-if="compare[a.id]!.local !== undefined || compare[a.id]!.catalog !== undefined" class="grid gap-2 md:grid-cols-2"> | ||
| 217 | + <div v-for="side in ([['Scope (local)', 'local', 'catalog'], ['Catalog', 'catalog', 'local']] as const)" :key="side[0]" class="min-w-0"> | ||
| 218 | + <p class="mb-1 text-xs font-medium text-muted">{{ side[0] }}</p> | ||
| 219 | + <div class="max-h-64 overflow-auto rounded-lg border border-default bg-elevated/40 py-1 font-mono text-[11px] leading-relaxed"> | ||
| 220 | + <div v-if="compare[a.id]![side[1]] === undefined" class="px-2 text-muted">Not available</div> | ||
| 221 | + <div | ||
| 222 | + v-for="(l, i) in marked(compare[a.id]![side[1]], compare[a.id]![side[2]])" | ||
| 223 | + :key="i" | ||
| 224 | + class="whitespace-pre-wrap break-all px-2" | ||
| 225 | + :class="l.diff ? (side[1] === 'local' ? 'bg-warning/15' : 'bg-info/15') : ''" | ||
| 226 | + >{{ l.line || ' ' }}</div> | ||
| 227 | + </div> | ||
| 228 | + </div> | ||
| 229 | + </div> | ||
| 230 | + </template> | ||
| 231 | + </div> | ||
| 232 | + </div> | ||
| 233 | + </li> | ||
| 234 | + </ul> | ||
| 235 | + </section> | ||
| 236 | + | ||
| 237 | + <p v-if="!groups.length" class="rounded-lg border border-dashed border-default p-6 text-center text-sm text-muted">Nothing to apply.</p> | ||
| 238 | + </div> | ||
| 239 | + </template> | ||
| 240 | + | ||
| 241 | + <template #footer> | ||
| 242 | + <div class="flex w-full items-center justify-between gap-2"> | ||
| 243 | + <span class="text-xs text-muted">{{ response ? '' : `${actions.length} change${actions.length > 1 ? 's' : ''}${conflicts.length ? `, ${conflicts.length} conflict${conflicts.length > 1 ? 's' : ''}` : ''}` }}</span> | ||
| 244 | + <div class="flex gap-2"> | ||
| 245 | + <UButton :label="response ? 'Close' : 'Cancel'" color="neutral" variant="outline" @click="open = false" /> | ||
| 246 | + <UButton v-if="!response" label="Apply" icon="i-lucide-check" :loading="busy" :disabled="!actions.length" @click="apply" /> | ||
| 247 | + </div> | ||
| 248 | + </div> | ||
| 249 | + </template> | ||
| 250 | + </UModal> | ||
| 251 | +</template> | ||
added
packages/ui/app/components/CatalogGitDialog.vue +96 -0 | new file mode 100644 | ||
| @@ -0,0 +1,96 @@ | ||
| 1 | +<script setup lang="ts"> | |
| 2 | +const { git, refresh } = useCatalog() | |
| 3 | +const { open, busy, error, run } = useCatalogGit() | |
| 4 | + | |
| 5 | +const remote = ref('') | |
| 6 | +const message = ref('') | |
| 7 | + | |
| 8 | +watch(open, (v) => { | |
| 9 | + if (!v) return | |
| 10 | + error.value = null | |
| 11 | + refresh() | |
| 12 | +}) | |
| 13 | +watch(() => git.value?.remote, (r) => { remote.value = r ?? '' }, { immediate: true }) | |
| 14 | + | |
| 15 | +const dirty = computed(() => git.value?.dirty ?? []) | |
| 16 | +const conflicts = computed(() => git.value?.conflicts ?? []) | |
| 17 | +const ready = computed(() => !!git.value?.isRepo) | |
| 18 | + | |
| 19 | +async function commit() { | |
| 20 | + if (await run('commit', { message: message.value.trim() }, 'Committed')) message.value = '' | |
| 21 | +} | |
| 22 | +</script> | |
| 23 | + | |
| 24 | +<template> | |
| 25 | + <UModal v-model:open="open" title="Catalog git" description="Commit, pull and push the catalog repository." :ui="{ content: 'max-w-xl' }"> | |
| 26 | + <template #body> | |
| 27 | + <div v-if="!git" class="text-sm text-muted">The catalog is not available yet.</div> | |
| 28 | + <div v-else-if="!git.isRepo" class="text-sm text-muted">The catalog folder is not a git repository.</div> | |
| 29 | + <div v-else class="space-y-5"> | |
| 30 | + <UAlert v-if="error" color="error" variant="subtle" icon="i-lucide-circle-alert" title="Git error" :description="error" class="whitespace-pre-wrap" /> | |
| 31 | + | |
| 32 | + <!-- Status --> | |
| 33 | + <div class="grid grid-cols-2 gap-x-4 gap-y-1 text-sm"> | |
| 34 | + <div><span class="text-muted">Branch </span><span class="font-mono">{{ git.branch ?? '-' }}</span></div> | |
| 35 | + <div class="truncate"><span class="text-muted">Remote </span><span class="font-mono" :title="git.remote">{{ git.remote ?? 'none' }}</span></div> | |
| 36 | + <div class="flex gap-3"> | |
| 37 | + <span :class="git.ahead ? 'text-info' : 'text-muted'">{{ git.ahead }} ahead</span> | |
| 38 | + <span :class="git.behind ? 'text-info' : 'text-muted'">{{ git.behind }} behind</span> | |
| 39 | + </div> | |
| 40 | + <div class="truncate" :title="git.lastCommit?.message"> | |
| 41 | + <span class="text-muted">Last commit </span> | |
| 42 | + <span v-if="git.lastCommit"><span class="font-mono">{{ git.lastCommit.hash.slice(0, 7) }}</span> {{ git.lastCommit.message }}</span> | |
| 43 | + <span v-else class="text-muted">none</span> | |
| 44 | + </div> | |
| 45 | + </div> | |
| 46 | + | |
| 47 | + <!-- Remote --> | |
| 48 | + <form class="space-y-1" @submit.prevent="run('remote', { url: remote.trim() }, 'Remote updated')"> | |
| 49 | + <label class="text-xs font-medium text-muted">Remote URL (any git host)</label> | |
| 50 | + <div class="flex gap-2"> | |
| 51 | + <UInput v-model="remote" placeholder="git@github.com:you/agent-kit.git" class="flex-1" :ui="{ base: 'font-mono' }" /> | |
| 52 | + <UButton type="submit" label="Set remote" color="neutral" variant="outline" :loading="busy === 'remote'" :disabled="!remote.trim() || remote.trim() === git.remote" /> | |
| 53 | + </div> | |
| 54 | + </form> | |
| 55 | + | |
| 56 | + <!-- Conflicts --> | |
| 57 | + <div v-if="conflicts.length" class="space-y-2"> | |
| 58 | + <h3 class="flex items-center gap-1.5 text-sm font-semibold text-error"><UIcon name="i-lucide-triangle-alert" class="size-4" />Conflicts ({{ conflicts.length }})</h3> | |
| 59 | + <ul class="divide-y divide-default rounded-lg border border-default text-sm"> | |
| 60 | + <li v-for="c in conflicts" :key="c" class="flex items-center gap-2 px-3 py-2"> | |
| 61 | + <span class="min-w-0 flex-1 truncate font-mono text-xs" :title="c">{{ c }}</span> | |
| 62 | + <UButton label="Keep mine" size="xs" color="neutral" variant="outline" :disabled="!!busy" @click="run('resolve', { path: c, side: 'ours' }, 'Conflict resolved')" /> | |
| 63 | + <UButton label="Use remote" size="xs" color="neutral" variant="outline" :disabled="!!busy" @click="run('resolve', { path: c, side: 'theirs' }, 'Conflict resolved')" /> | |
| 64 | + </li> | |
| 65 | + </ul> | |
| 66 | + </div> | |
| 67 | + | |
| 68 | + <!-- Commit --> | |
| 69 | + <div class="space-y-2"> | |
| 70 | + <h3 class="text-sm font-semibold">Uncommitted changes ({{ dirty.length }})</h3> | |
| 71 | + <ul v-if="dirty.length" class="max-h-32 overflow-auto rounded-lg border border-default text-xs"> | |
| 72 | + <li v-for="d in dirty" :key="d" class="truncate px-3 py-1 font-mono">{{ d }}</li> | |
| 73 | + </ul> | |
| 74 | + <p v-else class="text-xs text-muted">Working tree clean.</p> | |
| 75 | + <form class="flex gap-2" @submit.prevent="commit"> | |
| 76 | + <UInput v-model="message" placeholder="Commit message" class="flex-1" :disabled="!dirty.length" /> | |
| 77 | + <UButton type="submit" label="Commit" icon="i-lucide-git-commit-horizontal" :loading="busy === 'commit'" :disabled="!dirty.length || !message.trim()" /> | |
| 78 | + </form> | |
| 79 | + </div> | |
| 80 | + </div> | |
| 81 | + </template> | |
| 82 | + | |
| 83 | + <template #footer> | |
| 84 | + <div class="flex w-full flex-wrap items-center justify-between gap-2"> | |
| 85 | + <div class="flex gap-2"> | |
| 86 | + <UButton label="Pull" icon="i-lucide-arrow-down" color="neutral" variant="outline" :loading="busy === 'pull'" :disabled="!ready || !git?.remote || !!busy" @click="run('pull', undefined, 'Pulled')" /> | |
| 87 | + <UButton label="Push" icon="i-lucide-arrow-up" color="neutral" variant="outline" :loading="busy === 'push'" :disabled="!ready || !git?.remote || !!busy" @click="run('push', undefined, 'Pushed')" /> | |
| 88 | + </div> | |
| 89 | + <div class="flex gap-2"> | |
| 90 | + <UButton label="Close" color="neutral" variant="ghost" @click="open = false" /> | |
| 91 | + <UButton label="Sync" icon="i-lucide-refresh-cw" :loading="busy === 'sync'" :disabled="!ready || !git?.remote || !!busy" @click="run('sync', undefined, 'Catalog synced')" /> | |
| 92 | + </div> | |
| 93 | + </div> | |
| 94 | + </template> | |
| 95 | + </UModal> | |
| 96 | +</template> | |
| new file mode 100644 | |||
| @@ -0,0 +1,96 @@ | |||
| 1 | +<script setup lang="ts"> | ||
| 2 | +const { git, refresh } = useCatalog() | ||
| 3 | +const { open, busy, error, run } = useCatalogGit() | ||
| 4 | + | ||
| 5 | +const remote = ref('') | ||
| 6 | +const message = ref('') | ||
| 7 | + | ||
| 8 | +watch(open, (v) => { | ||
| 9 | + if (!v) return | ||
| 10 | + error.value = null | ||
| 11 | + refresh() | ||
| 12 | +}) | ||
| 13 | +watch(() => git.value?.remote, (r) => { remote.value = r ?? '' }, { immediate: true }) | ||
| 14 | + | ||
| 15 | +const dirty = computed(() => git.value?.dirty ?? []) | ||
| 16 | +const conflicts = computed(() => git.value?.conflicts ?? []) | ||
| 17 | +const ready = computed(() => !!git.value?.isRepo) | ||
| 18 | + | ||
| 19 | +async function commit() { | ||
| 20 | + if (await run('commit', { message: message.value.trim() }, 'Committed')) message.value = '' | ||
| 21 | +} | ||
| 22 | +</script> | ||
| 23 | + | ||
| 24 | +<template> | ||
| 25 | + <UModal v-model:open="open" title="Catalog git" description="Commit, pull and push the catalog repository." :ui="{ content: 'max-w-xl' }"> | ||
| 26 | + <template #body> | ||
| 27 | + <div v-if="!git" class="text-sm text-muted">The catalog is not available yet.</div> | ||
| 28 | + <div v-else-if="!git.isRepo" class="text-sm text-muted">The catalog folder is not a git repository.</div> | ||
| 29 | + <div v-else class="space-y-5"> | ||
| 30 | + <UAlert v-if="error" color="error" variant="subtle" icon="i-lucide-circle-alert" title="Git error" :description="error" class="whitespace-pre-wrap" /> | ||
| 31 | + | ||
| 32 | + <!-- Status --> | ||
| 33 | + <div class="grid grid-cols-2 gap-x-4 gap-y-1 text-sm"> | ||
| 34 | + <div><span class="text-muted">Branch </span><span class="font-mono">{{ git.branch ?? '-' }}</span></div> | ||
| 35 | + <div class="truncate"><span class="text-muted">Remote </span><span class="font-mono" :title="git.remote">{{ git.remote ?? 'none' }}</span></div> | ||
| 36 | + <div class="flex gap-3"> | ||
| 37 | + <span :class="git.ahead ? 'text-info' : 'text-muted'">{{ git.ahead }} ahead</span> | ||
| 38 | + <span :class="git.behind ? 'text-info' : 'text-muted'">{{ git.behind }} behind</span> | ||
| 39 | + </div> | ||
| 40 | + <div class="truncate" :title="git.lastCommit?.message"> | ||
| 41 | + <span class="text-muted">Last commit </span> | ||
| 42 | + <span v-if="git.lastCommit"><span class="font-mono">{{ git.lastCommit.hash.slice(0, 7) }}</span> {{ git.lastCommit.message }}</span> | ||
| 43 | + <span v-else class="text-muted">none</span> | ||
| 44 | + </div> | ||
| 45 | + </div> | ||
| 46 | + | ||
| 47 | + <!-- Remote --> | ||
| 48 | + <form class="space-y-1" @submit.prevent="run('remote', { url: remote.trim() }, 'Remote updated')"> | ||
| 49 | + <label class="text-xs font-medium text-muted">Remote URL (any git host)</label> | ||
| 50 | + <div class="flex gap-2"> | ||
| 51 | + <UInput v-model="remote" placeholder="git@github.com:you/agent-kit.git" class="flex-1" :ui="{ base: 'font-mono' }" /> | ||
| 52 | + <UButton type="submit" label="Set remote" color="neutral" variant="outline" :loading="busy === 'remote'" :disabled="!remote.trim() || remote.trim() === git.remote" /> | ||
| 53 | + </div> | ||
| 54 | + </form> | ||
| 55 | + | ||
| 56 | + <!-- Conflicts --> | ||
| 57 | + <div v-if="conflicts.length" class="space-y-2"> | ||
| 58 | + <h3 class="flex items-center gap-1.5 text-sm font-semibold text-error"><UIcon name="i-lucide-triangle-alert" class="size-4" />Conflicts ({{ conflicts.length }})</h3> | ||
| 59 | + <ul class="divide-y divide-default rounded-lg border border-default text-sm"> | ||
| 60 | + <li v-for="c in conflicts" :key="c" class="flex items-center gap-2 px-3 py-2"> | ||
| 61 | + <span class="min-w-0 flex-1 truncate font-mono text-xs" :title="c">{{ c }}</span> | ||
| 62 | + <UButton label="Keep mine" size="xs" color="neutral" variant="outline" :disabled="!!busy" @click="run('resolve', { path: c, side: 'ours' }, 'Conflict resolved')" /> | ||
| 63 | + <UButton label="Use remote" size="xs" color="neutral" variant="outline" :disabled="!!busy" @click="run('resolve', { path: c, side: 'theirs' }, 'Conflict resolved')" /> | ||
| 64 | + </li> | ||
| 65 | + </ul> | ||
| 66 | + </div> | ||
| 67 | + | ||
| 68 | + <!-- Commit --> | ||
| 69 | + <div class="space-y-2"> | ||
| 70 | + <h3 class="text-sm font-semibold">Uncommitted changes ({{ dirty.length }})</h3> | ||
| 71 | + <ul v-if="dirty.length" class="max-h-32 overflow-auto rounded-lg border border-default text-xs"> | ||
| 72 | + <li v-for="d in dirty" :key="d" class="truncate px-3 py-1 font-mono">{{ d }}</li> | ||
| 73 | + </ul> | ||
| 74 | + <p v-else class="text-xs text-muted">Working tree clean.</p> | ||
| 75 | + <form class="flex gap-2" @submit.prevent="commit"> | ||
| 76 | + <UInput v-model="message" placeholder="Commit message" class="flex-1" :disabled="!dirty.length" /> | ||
| 77 | + <UButton type="submit" label="Commit" icon="i-lucide-git-commit-horizontal" :loading="busy === 'commit'" :disabled="!dirty.length || !message.trim()" /> | ||
| 78 | + </form> | ||
| 79 | + </div> | ||
| 80 | + </div> | ||
| 81 | + </template> | ||
| 82 | + | ||
| 83 | + <template #footer> | ||
| 84 | + <div class="flex w-full flex-wrap items-center justify-between gap-2"> | ||
| 85 | + <div class="flex gap-2"> | ||
| 86 | + <UButton label="Pull" icon="i-lucide-arrow-down" color="neutral" variant="outline" :loading="busy === 'pull'" :disabled="!ready || !git?.remote || !!busy" @click="run('pull', undefined, 'Pulled')" /> | ||
| 87 | + <UButton label="Push" icon="i-lucide-arrow-up" color="neutral" variant="outline" :loading="busy === 'push'" :disabled="!ready || !git?.remote || !!busy" @click="run('push', undefined, 'Pushed')" /> | ||
| 88 | + </div> | ||
| 89 | + <div class="flex gap-2"> | ||
| 90 | + <UButton label="Close" color="neutral" variant="ghost" @click="open = false" /> | ||
| 91 | + <UButton label="Sync" icon="i-lucide-refresh-cw" :loading="busy === 'sync'" :disabled="!ready || !git?.remote || !!busy" @click="run('sync', undefined, 'Catalog synced')" /> | ||
| 92 | + </div> | ||
| 93 | + </div> | ||
| 94 | + </template> | ||
| 95 | + </UModal> | ||
| 96 | +</template> | ||
added
packages/ui/app/components/CatalogGitIndicator.vue +43 -0 | new file mode 100644 | ||
| @@ -0,0 +1,43 @@ | ||
| 1 | +<script setup lang="ts"> | |
| 2 | +const { git, exists } = useCatalog() | |
| 3 | +const { open, busy, run } = useCatalogGit() | |
| 4 | + | |
| 5 | +const parts = computed(() => { | |
| 6 | + const g = git.value | |
| 7 | + if (!g?.isRepo) return [] | |
| 8 | + const p: { icon: string, label: string, color: string, title: string }[] = [] | |
| 9 | + if (g.conflicts.length) p.push({ icon: 'i-lucide-triangle-alert', label: String(g.conflicts.length), color: 'text-error', title: `${g.conflicts.length} conflict(s)` }) | |
| 10 | + if (g.dirty.length) p.push({ icon: 'i-lucide-circle-dot', label: String(g.dirty.length), color: 'text-warning', title: `${g.dirty.length} uncommitted change(s)` }) | |
| 11 | + if (g.ahead) p.push({ icon: 'i-lucide-arrow-up', label: String(g.ahead), color: 'text-info', title: `${g.ahead} commit(s) to push` }) | |
| 12 | + if (g.behind) p.push({ icon: 'i-lucide-arrow-down', label: String(g.behind), color: 'text-info', title: `${g.behind} commit(s) to pull` }) | |
| 13 | + return p | |
| 14 | +}) | |
| 15 | +</script> | |
| 16 | + | |
| 17 | +<template> | |
| 18 | + <div v-if="exists && git" class="flex items-center gap-1 rounded-md border border-default px-1.5 py-1"> | |
| 19 | + <button type="button" class="flex min-w-0 flex-1 items-center gap-1.5 rounded px-1 py-0.5 text-left text-xs hover:bg-elevated/60" title="Catalog git status" @click="open = true"> | |
| 20 | + <UIcon name="i-lucide-git-branch" class="size-3.5 shrink-0 text-muted" /> | |
| 21 | + <span v-if="!git.isRepo" class="truncate text-muted">Not a git repo</span> | |
| 22 | + <template v-else> | |
| 23 | + <span class="truncate text-muted">{{ git.branch ?? 'catalog' }}</span> | |
| 24 | + <span v-if="!parts.length" class="flex items-center gap-0.5 text-success" title="Clean and up to date"><UIcon name="i-lucide-check" class="size-3.5" /></span> | |
| 25 | + <span v-for="p in parts" :key="p.icon" class="flex items-center gap-0.5 tabular-nums" :class="p.color" :title="p.title"> | |
| 26 | + <UIcon :name="p.icon" class="size-3" />{{ p.label }} | |
| 27 | + </span> | |
| 28 | + </template> | |
| 29 | + </button> | |
| 30 | + <UTooltip v-if="git.isRepo" :text="git.remote ? 'Commit, pull and push' : 'Set a remote in the git dialog to sync'"> | |
| 31 | + <UButton | |
| 32 | + icon="i-lucide-refresh-cw" | |
| 33 | + size="xs" | |
| 34 | + color="neutral" | |
| 35 | + variant="ghost" | |
| 36 | + aria-label="Sync catalog" | |
| 37 | + :loading="busy === 'sync'" | |
| 38 | + :disabled="!git.remote || !!busy" | |
| 39 | + @click="run('sync', undefined, 'Catalog synced')" | |
| 40 | + /> | |
| 41 | + </UTooltip> | |
| 42 | + </div> | |
| 43 | +</template> | |
| new file mode 100644 | |||
| @@ -0,0 +1,43 @@ | |||
| 1 | +<script setup lang="ts"> | ||
| 2 | +const { git, exists } = useCatalog() | ||
| 3 | +const { open, busy, run } = useCatalogGit() | ||
| 4 | + | ||
| 5 | +const parts = computed(() => { | ||
| 6 | + const g = git.value | ||
| 7 | + if (!g?.isRepo) return [] | ||
| 8 | + const p: { icon: string, label: string, color: string, title: string }[] = [] | ||
| 9 | + if (g.conflicts.length) p.push({ icon: 'i-lucide-triangle-alert', label: String(g.conflicts.length), color: 'text-error', title: `${g.conflicts.length} conflict(s)` }) | ||
| 10 | + if (g.dirty.length) p.push({ icon: 'i-lucide-circle-dot', label: String(g.dirty.length), color: 'text-warning', title: `${g.dirty.length} uncommitted change(s)` }) | ||
| 11 | + if (g.ahead) p.push({ icon: 'i-lucide-arrow-up', label: String(g.ahead), color: 'text-info', title: `${g.ahead} commit(s) to push` }) | ||
| 12 | + if (g.behind) p.push({ icon: 'i-lucide-arrow-down', label: String(g.behind), color: 'text-info', title: `${g.behind} commit(s) to pull` }) | ||
| 13 | + return p | ||
| 14 | +}) | ||
| 15 | +</script> | ||
| 16 | + | ||
| 17 | +<template> | ||
| 18 | + <div v-if="exists && git" class="flex items-center gap-1 rounded-md border border-default px-1.5 py-1"> | ||
| 19 | + <button type="button" class="flex min-w-0 flex-1 items-center gap-1.5 rounded px-1 py-0.5 text-left text-xs hover:bg-elevated/60" title="Catalog git status" @click="open = true"> | ||
| 20 | + <UIcon name="i-lucide-git-branch" class="size-3.5 shrink-0 text-muted" /> | ||
| 21 | + <span v-if="!git.isRepo" class="truncate text-muted">Not a git repo</span> | ||
| 22 | + <template v-else> | ||
| 23 | + <span class="truncate text-muted">{{ git.branch ?? 'catalog' }}</span> | ||
| 24 | + <span v-if="!parts.length" class="flex items-center gap-0.5 text-success" title="Clean and up to date"><UIcon name="i-lucide-check" class="size-3.5" /></span> | ||
| 25 | + <span v-for="p in parts" :key="p.icon" class="flex items-center gap-0.5 tabular-nums" :class="p.color" :title="p.title"> | ||
| 26 | + <UIcon :name="p.icon" class="size-3" />{{ p.label }} | ||
| 27 | + </span> | ||
| 28 | + </template> | ||
| 29 | + </button> | ||
| 30 | + <UTooltip v-if="git.isRepo" :text="git.remote ? 'Commit, pull and push' : 'Set a remote in the git dialog to sync'"> | ||
| 31 | + <UButton | ||
| 32 | + icon="i-lucide-refresh-cw" | ||
| 33 | + size="xs" | ||
| 34 | + color="neutral" | ||
| 35 | + variant="ghost" | ||
| 36 | + aria-label="Sync catalog" | ||
| 37 | + :loading="busy === 'sync'" | ||
| 38 | + :disabled="!git.remote || !!busy" | ||
| 39 | + @click="run('sync', undefined, 'Catalog synced')" | ||
| 40 | + /> | ||
| 41 | + </UTooltip> | ||
| 42 | + </div> | ||
| 43 | +</template> | ||
added
packages/ui/app/components/CatalogItemEditor.vue +405 -0 | new file mode 100644 | ||
| @@ -0,0 +1,405 @@ | ||
| 1 | +<script setup lang="ts"> | |
| 2 | +import type { CatalogItemDetail, CatalogItemType, HookTemplate, McpTemplate, SaveItemInput } from '@skilly/core' | |
| 3 | + | |
| 4 | +/** Catalog item editor: Markdown (skill / rule / agent) or form + YAML (mcp / hook). `name` is undefined for a new item. */ | |
| 5 | +const props = defineProps<{ type: CatalogItemType, name?: string }>() | |
| 6 | + | |
| 7 | +const { request } = useApi() | |
| 8 | +const catalog = useCatalog() | |
| 9 | +const scopeNames = useScopeNames() | |
| 10 | +const toast = useToast() | |
| 11 | + | |
| 12 | +const isNew = computed(() => !props.name) | |
| 13 | +const meta = computed(() => CAT_META[props.type]) | |
| 14 | +const isMd = computed(() => meta.value.markdown) | |
| 15 | + | |
| 16 | +const loading = ref(!!props.name) | |
| 17 | +const loadError = ref<string | null>(null) | |
| 18 | +const detail = ref<CatalogItemDetail | null>(null) | |
| 19 | + | |
| 20 | +const form = reactive({ name: props.name ?? '', content: '', tags: [] as string[] }) | |
| 21 | +const mcp = ref<McpTemplate>({ name: '', transport: 'stdio', command: '', args: [] }) | |
| 22 | +const hook = ref<HookTemplate>({ name: '', event: 'PreToolUse', handler: { type: 'command', command: '' } }) | |
| 23 | +const extraFiles = ref<Record<string, string>>({}) | |
| 24 | +const deleteFiles = ref<string[]>([]) | |
| 25 | +const original = ref('') | |
| 26 | +const view = ref<'edit' | 'split' | 'preview'>('split') | |
| 27 | +const saving = ref(false) | |
| 28 | +const saveError = ref<string | null>(null) | |
| 29 | + | |
| 30 | +const yaml = computed(() => { | |
| 31 | + if (props.type === 'mcp') return mcpToYaml({ ...mcp.value, name: form.name }) | |
| 32 | + if (props.type === 'hook') return hookToYaml({ ...hook.value, name: form.name }) | |
| 33 | + return '' | |
| 34 | +}) | |
| 35 | +const mainContent = computed(() => (isMd.value ? form.content : yaml.value)) | |
| 36 | + | |
| 37 | +const snapshot = () => JSON.stringify([form.name, mainContent.value, form.tags, extraFiles.value, deleteFiles.value]) | |
| 38 | +const dirty = computed(() => snapshot() !== original.value) | |
| 39 | + | |
| 40 | +function seed(d: CatalogItemDetail | null) { | |
| 41 | + detail.value = d | |
| 42 | + extraFiles.value = {} | |
| 43 | + deleteFiles.value = [] | |
| 44 | + if (d) { | |
| 45 | + form.name = d.item.name | |
| 46 | + form.content = d.content | |
| 47 | + form.tags = [...d.item.tags] | |
| 48 | + if (props.type === 'mcp' && d.template) mcp.value = structuredClone(toRaw(d.template)) as McpTemplate | |
| 49 | + if (props.type === 'hook' && d.template) hook.value = structuredClone(toRaw(d.template)) as HookTemplate | |
| 50 | + } else if (isMd.value) { | |
| 51 | + form.content = newItemContent(props.type, form.name || 'new-item') | |
| 52 | + } | |
| 53 | + nextTick(() => { original.value = snapshot() }) | |
| 54 | +} | |
| 55 | + | |
| 56 | +async function load() { | |
| 57 | + if (isNew.value) return seed(null) | |
| 58 | + loading.value = true | |
| 59 | + loadError.value = null | |
| 60 | + try { | |
| 61 | + seed(await request<CatalogItemDetail>(`/api/catalog/items/${props.type}/${encodeURIComponent(props.name!)}`, { silent: true })) | |
| 62 | + } catch (e) { | |
| 63 | + loadError.value = (e as Error).message | |
| 64 | + } finally { | |
| 65 | + loading.value = false | |
| 66 | + } | |
| 67 | +} | |
| 68 | + | |
| 69 | +onMounted(() => { | |
| 70 | + if (!catalog.data.value) catalog.refresh() | |
| 71 | + load() | |
| 72 | +}) | |
| 73 | + | |
| 74 | +/* ------------------------------------------------ Markdown fields */ | |
| 75 | + | |
| 76 | +const description = computed({ | |
| 77 | + get: () => getFm(form.content, 'description'), | |
| 78 | + set: (v: string) => { form.content = setFm(form.content, 'description', v) } | |
| 79 | +}) | |
| 80 | +function onName(v: string) { | |
| 81 | + form.name = v | |
| 82 | + if (isMd.value) form.content = setFm(form.content, 'name', v) | |
| 83 | +} | |
| 84 | +// New item: the name typed first also seeds the template when the content is still the untouched one. | |
| 85 | +const fmName = computed(() => getFm(form.content, 'name')) | |
| 86 | + | |
| 87 | +const parsed = computed(() => splitFrontmatter(form.content)) | |
| 88 | +const previewHtml = computed(() => renderMarkdown(parsed.value.body)) | |
| 89 | + | |
| 90 | +/* ------------------------------------------------ Validation */ | |
| 91 | + | |
| 92 | +const problems = computed(() => { | |
| 93 | + const p: string[] = [] | |
| 94 | + if (!form.name) p.push('A name is required.') | |
| 95 | + else if (!NAME_RE.test(form.name)) p.push('Name must be kebab-case: lowercase letters, digits, "." "_" "-", starting with a letter or digit.') | |
| 96 | + else if ((isNew.value || form.name !== props.name) && catalog.findItem(props.type, form.name)) p.push(`A ${meta.value.singular.toLowerCase()} named "${form.name}" already exists.`) | |
| 97 | + if (isMd.value) { | |
| 98 | + if (!parsed.value.frontmatter) p.push('The file has no frontmatter block (--- ... ---).') | |
| 99 | + if (!description.value.trim()) p.push('The frontmatter needs a description.') | |
| 100 | + } else if (props.type === 'mcp') { | |
| 101 | + if (mcp.value.transport === 'stdio' && !mcp.value.command?.trim()) p.push('A command is required for stdio servers.') | |
| 102 | + if (mcp.value.transport === 'http' && !mcp.value.url?.trim()) p.push('A URL is required for http servers.') | |
| 103 | + } else if (!hook.value.event || !hook.value.handler.command.trim()) { | |
| 104 | + p.push('A hook needs an event and a command.') | |
| 105 | + } | |
| 106 | + return p | |
| 107 | +}) | |
| 108 | +const nameMismatch = computed(() => isMd.value && !!parsed.value.frontmatter && fmName.value !== form.name && !!form.name) | |
| 109 | +const mismatchTitle = computed(() => `Frontmatter name is ${fmName.value ? '"' + fmName.value + '"' : 'missing'}`) | |
| 110 | +const renaming = computed(() => !isNew.value && form.name !== props.name) | |
| 111 | + | |
| 112 | +/* ------------------------------------------------ Save */ | |
| 113 | + | |
| 114 | +async function save() { | |
| 115 | + if (problems.value.length) return | |
| 116 | + saving.value = true | |
| 117 | + saveError.value = null | |
| 118 | + const content = isMd.value && nameMismatch.value ? setFm(form.content, 'name', form.name) : mainContent.value | |
| 119 | + const body: SaveItemInput = { | |
| 120 | + type: props.type, | |
| 121 | + name: form.name, | |
| 122 | + content, | |
| 123 | + tags: form.tags, | |
| 124 | + ...(props.type === 'skill' ? { files: { ...extraFiles.value }, deleteFiles: [...deleteFiles.value] } : {}), | |
| 125 | + ...(renaming.value ? { previousName: props.name } : {}) | |
| 126 | + } | |
| 127 | + try { | |
| 128 | + await request(`/api/catalog/items/${props.type}/${encodeURIComponent(form.name)}`, { method: 'PUT', body, silent: true }) | |
| 129 | + toast.add({ title: isNew.value ? 'Created' : 'Saved', description: form.name, icon: 'i-lucide-check', color: 'success' }) | |
| 130 | + await catalog.refresh() | |
| 131 | + if (isNew.value || renaming.value) { | |
| 132 | + original.value = snapshot() | |
| 133 | + await navigateTo(`/catalog/${props.type}/${encodeURIComponent(form.name)}`, { replace: true }) | |
| 134 | + } else { | |
| 135 | + form.content = content | |
| 136 | + await load() | |
| 137 | + } | |
| 138 | + } catch (e) { | |
| 139 | + saveError.value = (e as Error).message | |
| 140 | + } finally { | |
| 141 | + saving.value = false | |
| 142 | + } | |
| 143 | +} | |
| 144 | + | |
| 145 | +onBeforeRouteLeave(() => !dirty.value || window.confirm('Discard unsaved changes?')) | |
| 146 | + | |
| 147 | +/* ------------------------------------------------ Delete */ | |
| 148 | + | |
| 149 | +const delOpen = ref(false) | |
| 150 | +const deleting = ref(false) | |
| 151 | +const delConflict = ref<string | null>(null) | |
| 152 | +const usedBy = computed(() => (props.name ? catalog.usedBy(props.type, props.name) : [])) | |
| 153 | + | |
| 154 | +async function doDelete(force = false) { | |
| 155 | + deleting.value = true | |
| 156 | + try { | |
| 157 | + await request(`/api/catalog/items/${props.type}/${encodeURIComponent(props.name!)}`, { method: 'DELETE', query: force ? { force: '1' } : undefined, silent: true }) | |
| 158 | + original.value = snapshot() | |
| 159 | + toast.add({ title: 'Deleted', description: props.name, icon: 'i-lucide-check', color: 'success' }) | |
| 160 | + delOpen.value = false | |
| 161 | + await catalog.refresh() | |
| 162 | + await navigateTo(`/catalog?type=${props.type}`) | |
| 163 | + } catch (e) { | |
| 164 | + const err = e as { status?: number, message: string } | |
| 165 | + if (err.status === 409) delConflict.value = err.message | |
| 166 | + else toast.add({ title: 'Delete failed', description: err.message, color: 'error', icon: 'i-lucide-circle-alert' }) | |
| 167 | + } finally { | |
| 168 | + deleting.value = false | |
| 169 | + } | |
| 170 | +} | |
| 171 | +function askDelete() { | |
| 172 | + delConflict.value = null | |
| 173 | + delOpen.value = true | |
| 174 | +} | |
| 175 | + | |
| 176 | +/* ------------------------------------------------ Skill files */ | |
| 177 | + | |
| 178 | +const existingFiles = computed(() => (detail.value?.files ?? []).filter(f => f.path !== 'SKILL.md')) | |
| 179 | +const viewFile = ref<string | null>(null) | |
| 180 | +const newFile = reactive({ path: '', content: '' }) | |
| 181 | +const newFileError = computed(() => { | |
| 182 | + const p = newFile.path.trim() | |
| 183 | + if (!p) return null | |
| 184 | + if (/^([/\\]|[a-zA-Z]:)/.test(p) || p.split(/[\\/]/).includes('..')) return 'Use a relative path inside the skill folder.' | |
| 185 | + if (p === 'SKILL.md') return 'SKILL.md is edited above.' | |
| 186 | + if (extraFiles.value[p] !== undefined || existingFiles.value.some(f => f.path === p)) return 'This file already exists.' | |
| 187 | + return null | |
| 188 | +}) | |
| 189 | +function addFile() { | |
| 190 | + if (!newFile.path.trim() || newFileError.value) return | |
| 191 | + extraFiles.value = { ...extraFiles.value, [newFile.path.trim().replace(/\\/g, '/')]: newFile.content } | |
| 192 | + newFile.path = '' | |
| 193 | + newFile.content = '' | |
| 194 | +} | |
| 195 | +function dropNew(p: string) { | |
| 196 | + const { [p]: _gone, ...rest } = extraFiles.value | |
| 197 | + extraFiles.value = rest | |
| 198 | +} | |
| 199 | +function toggleDelete(p: string) { | |
| 200 | + deleteFiles.value = deleteFiles.value.includes(p) ? deleteFiles.value.filter(x => x !== p) : [...deleteFiles.value, p] | |
| 201 | +} | |
| 202 | + | |
| 203 | +const VIEWS = [ | |
| 204 | + { label: 'Edit', value: 'edit' }, | |
| 205 | + { label: 'Split', value: 'split' }, | |
| 206 | + { label: 'Preview', value: 'preview' } | |
| 207 | +] | |
| 208 | +const TA = 'block w-full resize-y rounded-lg border border-default bg-default p-3 font-mono text-xs leading-relaxed outline-none focus:border-primary focus:ring-1 focus:ring-primary' | |
| 209 | +</script> | |
| 210 | + | |
| 211 | +<template> | |
| 212 | + <UDashboardPanel :id="`catalog-item-${type}`"> | |
| 213 | + <template #header> | |
| 214 | + <UDashboardNavbar icon="i-lucide-library"> | |
| 215 | + <template #title> | |
| 216 | + <NuxtLink :to="`/catalog?type=${type}`" class="text-muted hover:text-default">Catalog</NuxtLink> | |
| 217 | + <UIcon name="i-lucide-chevron-right" class="size-4 text-dimmed" /> | |
| 218 | + <span class="text-muted">{{ meta.label }}</span> | |
| 219 | + <UIcon name="i-lucide-chevron-right" class="size-4 text-dimmed" /> | |
| 220 | + <span class="truncate">{{ isNew ? 'New' : name }}</span> | |
| 221 | + </template> | |
| 222 | + <template #right> | |
| 223 | + <UBadge v-if="dirty" label="Unsaved" color="warning" variant="subtle" /> | |
| 224 | + <UButton v-if="!isNew" label="Delete" icon="i-lucide-trash-2" color="error" variant="soft" @click="askDelete" /> | |
| 225 | + <UButton label="Save" icon="i-lucide-save" :loading="saving" :disabled="!dirty || !!problems.length" @click="save" /> | |
| 226 | + </template> | |
| 227 | + </UDashboardNavbar> | |
| 228 | + </template> | |
| 229 | + | |
| 230 | + <template #body> | |
| 231 | + <div v-if="loading" class="space-y-3"> | |
| 232 | + <USkeleton class="h-10 w-1/2" /> | |
| 233 | + <USkeleton class="h-72 w-full" /> | |
| 234 | + </div> | |
| 235 | + <UAlert v-else-if="loadError" color="error" variant="subtle" icon="i-lucide-circle-alert" title="Could not load this item" :description="loadError" :actions="[{ label: 'Retry', color: 'neutral', variant: 'outline', onClick: load }]" /> | |
| 236 | + | |
| 237 | + <div v-else class="grid items-start gap-6 xl:grid-cols-[minmax(0,1fr)_20rem]"> | |
| 238 | + <!-- Main column --> | |
| 239 | + <div class="min-w-0 space-y-4"> | |
| 240 | + <UAlert v-if="saveError" color="error" variant="subtle" icon="i-lucide-circle-alert" title="Save failed" :description="saveError" class="whitespace-pre-wrap" /> | |
| 241 | + | |
| 242 | + <div class="grid gap-4 sm:grid-cols-2"> | |
| 243 | + <UFormField :label="renaming ? 'Name (renaming)' : 'Name'" required> | |
| 244 | + <UInput :model-value="form.name" class="w-full" placeholder="kebab-case-name" :ui="{ base: 'font-mono' }" @update:model-value="onName" /> | |
| 245 | + </UFormField> | |
| 246 | + <UFormField v-if="isMd" label="Description" required :description="type === 'skill' ? 'Tells the agent when to use the skill.' : undefined"> | |
| 247 | + <UInput v-model="description" class="w-full" /> | |
| 248 | + </UFormField> | |
| 249 | + </div> | |
| 250 | + | |
| 251 | + <UAlert v-if="renaming" color="info" variant="subtle" icon="i-lucide-info" title="Renaming" description="The item is renamed in the catalog. Scopes that reference the old name will report it as missing until they are updated." /> | |
| 252 | + | |
| 253 | + <UAlert v-if="problems.length && (dirty || !isNew)" color="warning" variant="subtle" icon="i-lucide-triangle-alert" title="Fix before saving"> | |
| 254 | + <template #description> | |
| 255 | + <ul class="list-disc pl-4"> | |
| 256 | + <li v-for="p in problems" :key="p">{{ p }}</li> | |
| 257 | + </ul> | |
| 258 | + </template> | |
| 259 | + </UAlert> | |
| 260 | + <UAlert | |
| 261 | + v-else-if="nameMismatch" | |
| 262 | + color="warning" | |
| 263 | + variant="subtle" | |
| 264 | + icon="i-lucide-triangle-alert" | |
| 265 | + :title="mismatchTitle" | |
| 266 | + description="It will be set to the item name when you save." | |
| 267 | + /> | |
| 268 | + | |
| 269 | + <!-- Markdown editor --> | |
| 270 | + <template v-if="isMd"> | |
| 271 | + <div class="flex items-center justify-between"> | |
| 272 | + <h2 class="text-sm font-semibold">{{ type === 'skill' ? 'SKILL.md' : `${name ?? form.name}.md` }}</h2> | |
| 273 | + <UTabs v-model="view" :items="VIEWS" :content="false" size="xs" variant="pill" color="neutral" class="w-auto" /> | |
| 274 | + </div> | |
| 275 | + <div class="grid gap-3" :class="view === 'split' ? 'lg:grid-cols-2' : ''"> | |
| 276 | + <textarea v-if="view !== 'preview'" v-model="form.content" :class="TA" class="h-[32rem]" spellcheck="false" aria-label="Markdown content" /> | |
| 277 | + <div v-if="view !== 'edit'" class="h-[32rem] overflow-auto rounded-lg border border-default p-4"> | |
| 278 | + <div v-if="parsed.frontmatter" class="mb-3 overflow-hidden rounded-lg border border-default"> | |
| 279 | + <table class="w-full text-xs"> | |
| 280 | + <tbody class="divide-y divide-default"> | |
| 281 | + <tr v-for="(v, k) in parsed.frontmatter" :key="k"> | |
| 282 | + <th class="w-1/4 bg-elevated/40 px-2 py-1.5 text-left align-top font-medium text-muted">{{ k }}</th> | |
| 283 | + <td class="px-2 py-1.5 break-words">{{ v }}</td> | |
| 284 | + </tr> | |
| 285 | + </tbody> | |
| 286 | + </table> | |
| 287 | + </div> | |
| 288 | + <!-- eslint-disable-next-line vue/no-v-html --> | |
| 289 | + <div class="md-body" v-html="previewHtml" /> | |
| 290 | + </div> | |
| 291 | + </div> | |
| 292 | + </template> | |
| 293 | + | |
| 294 | + <!-- MCP / hook form --> | |
| 295 | + <template v-else> | |
| 296 | + <div class="grid items-start gap-6 lg:grid-cols-2"> | |
| 297 | + <McpForm v-if="type === 'mcp'" v-model="mcp" :key="detail?.item.hash ?? 'new'" /> | |
| 298 | + <HookForm v-else v-model="hook" /> | |
| 299 | + <div class="space-y-2"> | |
| 300 | + <h2 class="text-sm font-semibold">{{ type === 'mcp' ? `mcp/${form.name || 'name'}.yaml` : `hooks/${form.name || 'name'}.yaml` }} <span class="font-normal text-muted">(read-only)</span></h2> | |
| 301 | + <pre class="overflow-auto rounded-lg border border-default bg-elevated p-3 font-mono text-xs">{{ yaml }}</pre> | |
| 302 | + <p v-if="type === 'hook'" class="text-xs text-muted">Hooks are stored in the catalog; enabling them per project is not available yet.</p> | |
| 303 | + </div> | |
| 304 | + </div> | |
| 305 | + </template> | |
| 306 | + </div> | |
| 307 | + | |
| 308 | + <!-- Side column --> | |
| 309 | + <aside class="space-y-5"> | |
| 310 | + <section class="space-y-1.5"> | |
| 311 | + <h3 class="text-sm font-semibold">Tags</h3> | |
| 312 | + <UInputTags v-model="form.tags" placeholder="Add a tag, press Enter" class="w-full" /> | |
| 313 | + </section> | |
| 314 | + | |
| 315 | + <section v-if="!isNew" class="space-y-1.5"> | |
| 316 | + <h3 class="text-sm font-semibold">Used by</h3> | |
| 317 | + <ul v-if="usedBy.length" class="space-y-1 text-sm"> | |
| 318 | + <li v-for="s in usedBy" :key="s"> | |
| 319 | + <NuxtLink :to="scopeLink(s)" class="flex items-center gap-1.5 text-primary hover:underline"> | |
| 320 | + <UIcon :name="s === 'home' ? 'i-lucide-house' : 'i-lucide-folder'" class="size-4" /> | |
| 321 | + {{ scopeLabel(s, scopeNames) }} | |
| 322 | + </NuxtLink> | |
| 323 | + </li> | |
| 324 | + </ul> | |
| 325 | + <p v-else class="text-xs text-muted">Not used by any scope.</p> | |
| 326 | + </section> | |
| 327 | + | |
| 328 | + <section v-if="detail" class="space-y-1 text-xs text-muted"> | |
| 329 | + <h3 class="text-sm font-semibold text-default">Details</h3> | |
| 330 | + <p>Origin: <span class="text-default">{{ detail.item.origin?.kind ?? 'created' }}</span><span v-if="detail.item.origin?.source"> ({{ detail.item.origin.source }})</span></p> | |
| 331 | + <p>Updated: <span class="text-default">{{ formatDate(detail.item.updatedAt) }}</span></p> | |
| 332 | + <p class="break-all font-mono" :title="detail.item.path">{{ detail.item.path }}</p> | |
| 333 | + </section> | |
| 334 | + | |
| 335 | + <!-- Skill files --> | |
| 336 | + <section v-if="type === 'skill'" class="space-y-2"> | |
| 337 | + <h3 class="text-sm font-semibold">Extra files</h3> | |
| 338 | + <ul v-if="existingFiles.length || Object.keys(extraFiles).length" class="divide-y divide-default rounded-lg border border-default text-xs"> | |
| 339 | + <li v-for="f in existingFiles" :key="f.path" class="flex items-center gap-1 px-2 py-1" :class="deleteFiles.includes(f.path) ? 'opacity-50' : ''"> | |
| 340 | + <button type="button" class="flex min-w-0 flex-1 items-center gap-1.5 text-left font-mono hover:text-highlighted" :class="deleteFiles.includes(f.path) ? 'line-through' : ''" @click="viewFile = f.path"> | |
| 341 | + <UIcon name="i-lucide-file" class="size-3.5 shrink-0 text-muted" /><span class="truncate">{{ f.path }}</span> | |
| 342 | + </button> | |
| 343 | + <span class="shrink-0 text-dimmed">{{ formatBytes(f.sizeBytes) }}</span> | |
| 344 | + <UButton :icon="deleteFiles.includes(f.path) ? 'i-lucide-undo-2' : 'i-lucide-trash-2'" size="xs" color="neutral" variant="ghost" :aria-label="deleteFiles.includes(f.path) ? 'Keep file' : 'Delete file'" @click="toggleDelete(f.path)" /> | |
| 345 | + </li> | |
| 346 | + <li v-for="(_c, p) in extraFiles" :key="p" class="flex items-center gap-1 px-2 py-1"> | |
| 347 | + <UIcon name="i-lucide-file-plus" class="size-3.5 shrink-0 text-success" /> | |
| 348 | + <span class="min-w-0 flex-1 truncate font-mono">{{ p }}</span> | |
| 349 | + <UBadge label="new" size="sm" color="success" variant="subtle" /> | |
| 350 | + <UButton icon="i-lucide-x" size="xs" color="neutral" variant="ghost" aria-label="Discard new file" @click="dropNew(p as string)" /> | |
| 351 | + </li> | |
| 352 | + </ul> | |
| 353 | + <p v-else class="text-xs text-muted">SKILL.md only.</p> | |
| 354 | + | |
| 355 | + <div class="space-y-1.5 rounded-lg border border-dashed border-default p-2"> | |
| 356 | + <UInput v-model="newFile.path" size="sm" placeholder="references/notes.md" class="w-full" :ui="{ base: 'font-mono' }" /> | |
| 357 | + <p v-if="newFileError" class="text-xs text-error">{{ newFileError }}</p> | |
| 358 | + <textarea v-model="newFile.content" :class="TA" class="h-24" placeholder="Text content" spellcheck="false" aria-label="New file content" /> | |
| 359 | + <UButton label="Add file" icon="i-lucide-plus" size="xs" color="neutral" variant="outline" :disabled="!newFile.path.trim() || !!newFileError" @click="addFile" /> | |
| 360 | + </div> | |
| 361 | + </section> | |
| 362 | + </aside> | |
| 363 | + </div> | |
| 364 | + | |
| 365 | + <!-- Skill file viewer --> | |
| 366 | + <USlideover :open="!!viewFile" :title="viewFile ?? ''" :ui="{ content: 'sm:max-w-2xl' }" @update:open="(v: boolean) => { if (!v) viewFile = null }"> | |
| 367 | + <template #body> | |
| 368 | + <MarkdownView v-if="viewFile && detail" :path="joinPath(detail.item.path, viewFile)" /> | |
| 369 | + </template> | |
| 370 | + </USlideover> | |
| 371 | + | |
| 372 | + <!-- Delete --> | |
| 373 | + <UModal v-model:open="delOpen" :title="`Delete ${name}`" description="This removes the item from the catalog."> | |
| 374 | + <template #body> | |
| 375 | + <div class="space-y-3 text-sm"> | |
| 376 | + <p> | |
| 377 | + <strong>{{ name }}</strong> will be deleted from the catalog folder. Scopes where it was already written keep their files, | |
| 378 | + but the item will be reported as missing in the catalog. | |
| 379 | + </p> | |
| 380 | + <UAlert | |
| 381 | + v-if="delConflict || usedBy.length" | |
| 382 | + color="warning" | |
| 383 | + variant="subtle" | |
| 384 | + icon="i-lucide-triangle-alert" | |
| 385 | + :title="delConflict ? 'This item is used' : 'Used by'" | |
| 386 | + > | |
| 387 | + <template #description> | |
| 388 | + <p v-if="delConflict" class="mb-1">{{ delConflict }}</p> | |
| 389 | + <ul class="list-disc pl-4"> | |
| 390 | + <li v-for="s in usedBy" :key="s">{{ scopeLabel(s, scopeNames) }}</li> | |
| 391 | + </ul> | |
| 392 | + </template> | |
| 393 | + </UAlert> | |
| 394 | + </div> | |
| 395 | + </template> | |
| 396 | + <template #footer> | |
| 397 | + <div class="flex w-full justify-end gap-2"> | |
| 398 | + <UButton label="Cancel" color="neutral" variant="outline" @click="delOpen = false" /> | |
| 399 | + <UButton :label="delConflict ? 'Delete anyway' : 'Delete'" color="error" :loading="deleting" @click="doDelete(!!delConflict)" /> | |
| 400 | + </div> | |
| 401 | + </template> | |
| 402 | + </UModal> | |
| 403 | + </template> | |
| 404 | + </UDashboardPanel> | |
| 405 | +</template> | |
| new file mode 100644 | |||
| @@ -0,0 +1,405 @@ | |||
| 1 | +<script setup lang="ts"> | ||
| 2 | +import type { CatalogItemDetail, CatalogItemType, HookTemplate, McpTemplate, SaveItemInput } from '@skilly/core' | ||
| 3 | + | ||
| 4 | +/** Catalog item editor: Markdown (skill / rule / agent) or form + YAML (mcp / hook). `name` is undefined for a new item. */ | ||
| 5 | +const props = defineProps<{ type: CatalogItemType, name?: string }>() | ||
| 6 | + | ||
| 7 | +const { request } = useApi() | ||
| 8 | +const catalog = useCatalog() | ||
| 9 | +const scopeNames = useScopeNames() | ||
| 10 | +const toast = useToast() | ||
| 11 | + | ||
| 12 | +const isNew = computed(() => !props.name) | ||
| 13 | +const meta = computed(() => CAT_META[props.type]) | ||
| 14 | +const isMd = computed(() => meta.value.markdown) | ||
| 15 | + | ||
| 16 | +const loading = ref(!!props.name) | ||
| 17 | +const loadError = ref<string | null>(null) | ||
| 18 | +const detail = ref<CatalogItemDetail | null>(null) | ||
| 19 | + | ||
| 20 | +const form = reactive({ name: props.name ?? '', content: '', tags: [] as string[] }) | ||
| 21 | +const mcp = ref<McpTemplate>({ name: '', transport: 'stdio', command: '', args: [] }) | ||
| 22 | +const hook = ref<HookTemplate>({ name: '', event: 'PreToolUse', handler: { type: 'command', command: '' } }) | ||
| 23 | +const extraFiles = ref<Record<string, string>>({}) | ||
| 24 | +const deleteFiles = ref<string[]>([]) | ||
| 25 | +const original = ref('') | ||
| 26 | +const view = ref<'edit' | 'split' | 'preview'>('split') | ||
| 27 | +const saving = ref(false) | ||
| 28 | +const saveError = ref<string | null>(null) | ||
| 29 | + | ||
| 30 | +const yaml = computed(() => { | ||
| 31 | + if (props.type === 'mcp') return mcpToYaml({ ...mcp.value, name: form.name }) | ||
| 32 | + if (props.type === 'hook') return hookToYaml({ ...hook.value, name: form.name }) | ||
| 33 | + return '' | ||
| 34 | +}) | ||
| 35 | +const mainContent = computed(() => (isMd.value ? form.content : yaml.value)) | ||
| 36 | + | ||
| 37 | +const snapshot = () => JSON.stringify([form.name, mainContent.value, form.tags, extraFiles.value, deleteFiles.value]) | ||
| 38 | +const dirty = computed(() => snapshot() !== original.value) | ||
| 39 | + | ||
| 40 | +function seed(d: CatalogItemDetail | null) { | ||
| 41 | + detail.value = d | ||
| 42 | + extraFiles.value = {} | ||
| 43 | + deleteFiles.value = [] | ||
| 44 | + if (d) { | ||
| 45 | + form.name = d.item.name | ||
| 46 | + form.content = d.content | ||
| 47 | + form.tags = [...d.item.tags] | ||
| 48 | + if (props.type === 'mcp' && d.template) mcp.value = structuredClone(toRaw(d.template)) as McpTemplate | ||
| 49 | + if (props.type === 'hook' && d.template) hook.value = structuredClone(toRaw(d.template)) as HookTemplate | ||
| 50 | + } else if (isMd.value) { | ||
| 51 | + form.content = newItemContent(props.type, form.name || 'new-item') | ||
| 52 | + } | ||
| 53 | + nextTick(() => { original.value = snapshot() }) | ||
| 54 | +} | ||
| 55 | + | ||
| 56 | +async function load() { | ||
| 57 | + if (isNew.value) return seed(null) | ||
| 58 | + loading.value = true | ||
| 59 | + loadError.value = null | ||
| 60 | + try { | ||
| 61 | + seed(await request<CatalogItemDetail>(`/api/catalog/items/${props.type}/${encodeURIComponent(props.name!)}`, { silent: true })) | ||
| 62 | + } catch (e) { | ||
| 63 | + loadError.value = (e as Error).message | ||
| 64 | + } finally { | ||
| 65 | + loading.value = false | ||
| 66 | + } | ||
| 67 | +} | ||
| 68 | + | ||
| 69 | +onMounted(() => { | ||
| 70 | + if (!catalog.data.value) catalog.refresh() | ||
| 71 | + load() | ||
| 72 | +}) | ||
| 73 | + | ||
| 74 | +/* ------------------------------------------------ Markdown fields */ | ||
| 75 | + | ||
| 76 | +const description = computed({ | ||
| 77 | + get: () => getFm(form.content, 'description'), | ||
| 78 | + set: (v: string) => { form.content = setFm(form.content, 'description', v) } | ||
| 79 | +}) | ||
| 80 | +function onName(v: string) { | ||
| 81 | + form.name = v | ||
| 82 | + if (isMd.value) form.content = setFm(form.content, 'name', v) | ||
| 83 | +} | ||
| 84 | +// New item: the name typed first also seeds the template when the content is still the untouched one. | ||
| 85 | +const fmName = computed(() => getFm(form.content, 'name')) | ||
| 86 | + | ||
| 87 | +const parsed = computed(() => splitFrontmatter(form.content)) | ||
| 88 | +const previewHtml = computed(() => renderMarkdown(parsed.value.body)) | ||
| 89 | + | ||
| 90 | +/* ------------------------------------------------ Validation */ | ||
| 91 | + | ||
| 92 | +const problems = computed(() => { | ||
| 93 | + const p: string[] = [] | ||
| 94 | + if (!form.name) p.push('A name is required.') | ||
| 95 | + else if (!NAME_RE.test(form.name)) p.push('Name must be kebab-case: lowercase letters, digits, "." "_" "-", starting with a letter or digit.') | ||
| 96 | + else if ((isNew.value || form.name !== props.name) && catalog.findItem(props.type, form.name)) p.push(`A ${meta.value.singular.toLowerCase()} named "${form.name}" already exists.`) | ||
| 97 | + if (isMd.value) { | ||
| 98 | + if (!parsed.value.frontmatter) p.push('The file has no frontmatter block (--- ... ---).') | ||
| 99 | + if (!description.value.trim()) p.push('The frontmatter needs a description.') | ||
| 100 | + } else if (props.type === 'mcp') { | ||
| 101 | + if (mcp.value.transport === 'stdio' && !mcp.value.command?.trim()) p.push('A command is required for stdio servers.') | ||
| 102 | + if (mcp.value.transport === 'http' && !mcp.value.url?.trim()) p.push('A URL is required for http servers.') | ||
| 103 | + } else if (!hook.value.event || !hook.value.handler.command.trim()) { | ||
| 104 | + p.push('A hook needs an event and a command.') | ||
| 105 | + } | ||
| 106 | + return p | ||
| 107 | +}) | ||
| 108 | +const nameMismatch = computed(() => isMd.value && !!parsed.value.frontmatter && fmName.value !== form.name && !!form.name) | ||
| 109 | +const mismatchTitle = computed(() => `Frontmatter name is ${fmName.value ? '"' + fmName.value + '"' : 'missing'}`) | ||
| 110 | +const renaming = computed(() => !isNew.value && form.name !== props.name) | ||
| 111 | + | ||
| 112 | +/* ------------------------------------------------ Save */ | ||
| 113 | + | ||
| 114 | +async function save() { | ||
| 115 | + if (problems.value.length) return | ||
| 116 | + saving.value = true | ||
| 117 | + saveError.value = null | ||
| 118 | + const content = isMd.value && nameMismatch.value ? setFm(form.content, 'name', form.name) : mainContent.value | ||
| 119 | + const body: SaveItemInput = { | ||
| 120 | + type: props.type, | ||
| 121 | + name: form.name, | ||
| 122 | + content, | ||
| 123 | + tags: form.tags, | ||
| 124 | + ...(props.type === 'skill' ? { files: { ...extraFiles.value }, deleteFiles: [...deleteFiles.value] } : {}), | ||
| 125 | + ...(renaming.value ? { previousName: props.name } : {}) | ||
| 126 | + } | ||
| 127 | + try { | ||
| 128 | + await request(`/api/catalog/items/${props.type}/${encodeURIComponent(form.name)}`, { method: 'PUT', body, silent: true }) | ||
| 129 | + toast.add({ title: isNew.value ? 'Created' : 'Saved', description: form.name, icon: 'i-lucide-check', color: 'success' }) | ||
| 130 | + await catalog.refresh() | ||
| 131 | + if (isNew.value || renaming.value) { | ||
| 132 | + original.value = snapshot() | ||
| 133 | + await navigateTo(`/catalog/${props.type}/${encodeURIComponent(form.name)}`, { replace: true }) | ||
| 134 | + } else { | ||
| 135 | + form.content = content | ||
| 136 | + await load() | ||
| 137 | + } | ||
| 138 | + } catch (e) { | ||
| 139 | + saveError.value = (e as Error).message | ||
| 140 | + } finally { | ||
| 141 | + saving.value = false | ||
| 142 | + } | ||
| 143 | +} | ||
| 144 | + | ||
| 145 | +onBeforeRouteLeave(() => !dirty.value || window.confirm('Discard unsaved changes?')) | ||
| 146 | + | ||
| 147 | +/* ------------------------------------------------ Delete */ | ||
| 148 | + | ||
| 149 | +const delOpen = ref(false) | ||
| 150 | +const deleting = ref(false) | ||
| 151 | +const delConflict = ref<string | null>(null) | ||
| 152 | +const usedBy = computed(() => (props.name ? catalog.usedBy(props.type, props.name) : [])) | ||
| 153 | + | ||
| 154 | +async function doDelete(force = false) { | ||
| 155 | + deleting.value = true | ||
| 156 | + try { | ||
| 157 | + await request(`/api/catalog/items/${props.type}/${encodeURIComponent(props.name!)}`, { method: 'DELETE', query: force ? { force: '1' } : undefined, silent: true }) | ||
| 158 | + original.value = snapshot() | ||
| 159 | + toast.add({ title: 'Deleted', description: props.name, icon: 'i-lucide-check', color: 'success' }) | ||
| 160 | + delOpen.value = false | ||
| 161 | + await catalog.refresh() | ||
| 162 | + await navigateTo(`/catalog?type=${props.type}`) | ||
| 163 | + } catch (e) { | ||
| 164 | + const err = e as { status?: number, message: string } | ||
| 165 | + if (err.status === 409) delConflict.value = err.message | ||
| 166 | + else toast.add({ title: 'Delete failed', description: err.message, color: 'error', icon: 'i-lucide-circle-alert' }) | ||
| 167 | + } finally { | ||
| 168 | + deleting.value = false | ||
| 169 | + } | ||
| 170 | +} | ||
| 171 | +function askDelete() { | ||
| 172 | + delConflict.value = null | ||
| 173 | + delOpen.value = true | ||
| 174 | +} | ||
| 175 | + | ||
| 176 | +/* ------------------------------------------------ Skill files */ | ||
| 177 | + | ||
| 178 | +const existingFiles = computed(() => (detail.value?.files ?? []).filter(f => f.path !== 'SKILL.md')) | ||
| 179 | +const viewFile = ref<string | null>(null) | ||
| 180 | +const newFile = reactive({ path: '', content: '' }) | ||
| 181 | +const newFileError = computed(() => { | ||
| 182 | + const p = newFile.path.trim() | ||
| 183 | + if (!p) return null | ||
| 184 | + if (/^([/\\]|[a-zA-Z]:)/.test(p) || p.split(/[\\/]/).includes('..')) return 'Use a relative path inside the skill folder.' | ||
| 185 | + if (p === 'SKILL.md') return 'SKILL.md is edited above.' | ||
| 186 | + if (extraFiles.value[p] !== undefined || existingFiles.value.some(f => f.path === p)) return 'This file already exists.' | ||
| 187 | + return null | ||
| 188 | +}) | ||
| 189 | +function addFile() { | ||
| 190 | + if (!newFile.path.trim() || newFileError.value) return | ||
| 191 | + extraFiles.value = { ...extraFiles.value, [newFile.path.trim().replace(/\\/g, '/')]: newFile.content } | ||
| 192 | + newFile.path = '' | ||
| 193 | + newFile.content = '' | ||
| 194 | +} | ||
| 195 | +function dropNew(p: string) { | ||
| 196 | + const { [p]: _gone, ...rest } = extraFiles.value | ||
| 197 | + extraFiles.value = rest | ||
| 198 | +} | ||
| 199 | +function toggleDelete(p: string) { | ||
| 200 | + deleteFiles.value = deleteFiles.value.includes(p) ? deleteFiles.value.filter(x => x !== p) : [...deleteFiles.value, p] | ||
| 201 | +} | ||
| 202 | + | ||
| 203 | +const VIEWS = [ | ||
| 204 | + { label: 'Edit', value: 'edit' }, | ||
| 205 | + { label: 'Split', value: 'split' }, | ||
| 206 | + { label: 'Preview', value: 'preview' } | ||
| 207 | +] | ||
| 208 | +const TA = 'block w-full resize-y rounded-lg border border-default bg-default p-3 font-mono text-xs leading-relaxed outline-none focus:border-primary focus:ring-1 focus:ring-primary' | ||
| 209 | +</script> | ||
| 210 | + | ||
| 211 | +<template> | ||
| 212 | + <UDashboardPanel :id="`catalog-item-${type}`"> | ||
| 213 | + <template #header> | ||
| 214 | + <UDashboardNavbar icon="i-lucide-library"> | ||
| 215 | + <template #title> | ||
| 216 | + <NuxtLink :to="`/catalog?type=${type}`" class="text-muted hover:text-default">Catalog</NuxtLink> | ||
| 217 | + <UIcon name="i-lucide-chevron-right" class="size-4 text-dimmed" /> | ||
| 218 | + <span class="text-muted">{{ meta.label }}</span> | ||
| 219 | + <UIcon name="i-lucide-chevron-right" class="size-4 text-dimmed" /> | ||
| 220 | + <span class="truncate">{{ isNew ? 'New' : name }}</span> | ||
| 221 | + </template> | ||
| 222 | + <template #right> | ||
| 223 | + <UBadge v-if="dirty" label="Unsaved" color="warning" variant="subtle" /> | ||
| 224 | + <UButton v-if="!isNew" label="Delete" icon="i-lucide-trash-2" color="error" variant="soft" @click="askDelete" /> | ||
| 225 | + <UButton label="Save" icon="i-lucide-save" :loading="saving" :disabled="!dirty || !!problems.length" @click="save" /> | ||
| 226 | + </template> | ||
| 227 | + </UDashboardNavbar> | ||
| 228 | + </template> | ||
| 229 | + | ||
| 230 | + <template #body> | ||
| 231 | + <div v-if="loading" class="space-y-3"> | ||
| 232 | + <USkeleton class="h-10 w-1/2" /> | ||
| 233 | + <USkeleton class="h-72 w-full" /> | ||
| 234 | + </div> | ||
| 235 | + <UAlert v-else-if="loadError" color="error" variant="subtle" icon="i-lucide-circle-alert" title="Could not load this item" :description="loadError" :actions="[{ label: 'Retry', color: 'neutral', variant: 'outline', onClick: load }]" /> | ||
| 236 | + | ||
| 237 | + <div v-else class="grid items-start gap-6 xl:grid-cols-[minmax(0,1fr)_20rem]"> | ||
| 238 | + <!-- Main column --> | ||
| 239 | + <div class="min-w-0 space-y-4"> | ||
| 240 | + <UAlert v-if="saveError" color="error" variant="subtle" icon="i-lucide-circle-alert" title="Save failed" :description="saveError" class="whitespace-pre-wrap" /> | ||
| 241 | + | ||
| 242 | + <div class="grid gap-4 sm:grid-cols-2"> | ||
| 243 | + <UFormField :label="renaming ? 'Name (renaming)' : 'Name'" required> | ||
| 244 | + <UInput :model-value="form.name" class="w-full" placeholder="kebab-case-name" :ui="{ base: 'font-mono' }" @update:model-value="onName" /> | ||
| 245 | + </UFormField> | ||
| 246 | + <UFormField v-if="isMd" label="Description" required :description="type === 'skill' ? 'Tells the agent when to use the skill.' : undefined"> | ||
| 247 | + <UInput v-model="description" class="w-full" /> | ||
| 248 | + </UFormField> | ||
| 249 | + </div> | ||
| 250 | + | ||
| 251 | + <UAlert v-if="renaming" color="info" variant="subtle" icon="i-lucide-info" title="Renaming" description="The item is renamed in the catalog. Scopes that reference the old name will report it as missing until they are updated." /> | ||
| 252 | + | ||
| 253 | + <UAlert v-if="problems.length && (dirty || !isNew)" color="warning" variant="subtle" icon="i-lucide-triangle-alert" title="Fix before saving"> | ||
| 254 | + <template #description> | ||
| 255 | + <ul class="list-disc pl-4"> | ||
| 256 | + <li v-for="p in problems" :key="p">{{ p }}</li> | ||
| 257 | + </ul> | ||
| 258 | + </template> | ||
| 259 | + </UAlert> | ||
| 260 | + <UAlert | ||
| 261 | + v-else-if="nameMismatch" | ||
| 262 | + color="warning" | ||
| 263 | + variant="subtle" | ||
| 264 | + icon="i-lucide-triangle-alert" | ||
| 265 | + :title="mismatchTitle" | ||
| 266 | + description="It will be set to the item name when you save." | ||
| 267 | + /> | ||
| 268 | + | ||
| 269 | + <!-- Markdown editor --> | ||
| 270 | + <template v-if="isMd"> | ||
| 271 | + <div class="flex items-center justify-between"> | ||
| 272 | + <h2 class="text-sm font-semibold">{{ type === 'skill' ? 'SKILL.md' : `${name ?? form.name}.md` }}</h2> | ||
| 273 | + <UTabs v-model="view" :items="VIEWS" :content="false" size="xs" variant="pill" color="neutral" class="w-auto" /> | ||
| 274 | + </div> | ||
| 275 | + <div class="grid gap-3" :class="view === 'split' ? 'lg:grid-cols-2' : ''"> | ||
| 276 | + <textarea v-if="view !== 'preview'" v-model="form.content" :class="TA" class="h-[32rem]" spellcheck="false" aria-label="Markdown content" /> | ||
| 277 | + <div v-if="view !== 'edit'" class="h-[32rem] overflow-auto rounded-lg border border-default p-4"> | ||
| 278 | + <div v-if="parsed.frontmatter" class="mb-3 overflow-hidden rounded-lg border border-default"> | ||
| 279 | + <table class="w-full text-xs"> | ||
| 280 | + <tbody class="divide-y divide-default"> | ||
| 281 | + <tr v-for="(v, k) in parsed.frontmatter" :key="k"> | ||
| 282 | + <th class="w-1/4 bg-elevated/40 px-2 py-1.5 text-left align-top font-medium text-muted">{{ k }}</th> | ||
| 283 | + <td class="px-2 py-1.5 break-words">{{ v }}</td> | ||
| 284 | + </tr> | ||
| 285 | + </tbody> | ||
| 286 | + </table> | ||
| 287 | + </div> | ||
| 288 | + <!-- eslint-disable-next-line vue/no-v-html --> | ||
| 289 | + <div class="md-body" v-html="previewHtml" /> | ||
| 290 | + </div> | ||
| 291 | + </div> | ||
| 292 | + </template> | ||
| 293 | + | ||
| 294 | + <!-- MCP / hook form --> | ||
| 295 | + <template v-else> | ||
| 296 | + <div class="grid items-start gap-6 lg:grid-cols-2"> | ||
| 297 | + <McpForm v-if="type === 'mcp'" v-model="mcp" :key="detail?.item.hash ?? 'new'" /> | ||
| 298 | + <HookForm v-else v-model="hook" /> | ||
| 299 | + <div class="space-y-2"> | ||
| 300 | + <h2 class="text-sm font-semibold">{{ type === 'mcp' ? `mcp/${form.name || 'name'}.yaml` : `hooks/${form.name || 'name'}.yaml` }} <span class="font-normal text-muted">(read-only)</span></h2> | ||
| 301 | + <pre class="overflow-auto rounded-lg border border-default bg-elevated p-3 font-mono text-xs">{{ yaml }}</pre> | ||
| 302 | + <p v-if="type === 'hook'" class="text-xs text-muted">Hooks are stored in the catalog; enabling them per project is not available yet.</p> | ||
| 303 | + </div> | ||
| 304 | + </div> | ||
| 305 | + </template> | ||
| 306 | + </div> | ||
| 307 | + | ||
| 308 | + <!-- Side column --> | ||
| 309 | + <aside class="space-y-5"> | ||
| 310 | + <section class="space-y-1.5"> | ||
| 311 | + <h3 class="text-sm font-semibold">Tags</h3> | ||
| 312 | + <UInputTags v-model="form.tags" placeholder="Add a tag, press Enter" class="w-full" /> | ||
| 313 | + </section> | ||
| 314 | + | ||
| 315 | + <section v-if="!isNew" class="space-y-1.5"> | ||
| 316 | + <h3 class="text-sm font-semibold">Used by</h3> | ||
| 317 | + <ul v-if="usedBy.length" class="space-y-1 text-sm"> | ||
| 318 | + <li v-for="s in usedBy" :key="s"> | ||
| 319 | + <NuxtLink :to="scopeLink(s)" class="flex items-center gap-1.5 text-primary hover:underline"> | ||
| 320 | + <UIcon :name="s === 'home' ? 'i-lucide-house' : 'i-lucide-folder'" class="size-4" /> | ||
| 321 | + {{ scopeLabel(s, scopeNames) }} | ||
| 322 | + </NuxtLink> | ||
| 323 | + </li> | ||
| 324 | + </ul> | ||
| 325 | + <p v-else class="text-xs text-muted">Not used by any scope.</p> | ||
| 326 | + </section> | ||
| 327 | + | ||
| 328 | + <section v-if="detail" class="space-y-1 text-xs text-muted"> | ||
| 329 | + <h3 class="text-sm font-semibold text-default">Details</h3> | ||
| 330 | + <p>Origin: <span class="text-default">{{ detail.item.origin?.kind ?? 'created' }}</span><span v-if="detail.item.origin?.source"> ({{ detail.item.origin.source }})</span></p> | ||
| 331 | + <p>Updated: <span class="text-default">{{ formatDate(detail.item.updatedAt) }}</span></p> | ||
| 332 | + <p class="break-all font-mono" :title="detail.item.path">{{ detail.item.path }}</p> | ||
| 333 | + </section> | ||
| 334 | + | ||
| 335 | + <!-- Skill files --> | ||
| 336 | + <section v-if="type === 'skill'" class="space-y-2"> | ||
| 337 | + <h3 class="text-sm font-semibold">Extra files</h3> | ||
| 338 | + <ul v-if="existingFiles.length || Object.keys(extraFiles).length" class="divide-y divide-default rounded-lg border border-default text-xs"> | ||
| 339 | + <li v-for="f in existingFiles" :key="f.path" class="flex items-center gap-1 px-2 py-1" :class="deleteFiles.includes(f.path) ? 'opacity-50' : ''"> | ||
| 340 | + <button type="button" class="flex min-w-0 flex-1 items-center gap-1.5 text-left font-mono hover:text-highlighted" :class="deleteFiles.includes(f.path) ? 'line-through' : ''" @click="viewFile = f.path"> | ||
| 341 | + <UIcon name="i-lucide-file" class="size-3.5 shrink-0 text-muted" /><span class="truncate">{{ f.path }}</span> | ||
| 342 | + </button> | ||
| 343 | + <span class="shrink-0 text-dimmed">{{ formatBytes(f.sizeBytes) }}</span> | ||
| 344 | + <UButton :icon="deleteFiles.includes(f.path) ? 'i-lucide-undo-2' : 'i-lucide-trash-2'" size="xs" color="neutral" variant="ghost" :aria-label="deleteFiles.includes(f.path) ? 'Keep file' : 'Delete file'" @click="toggleDelete(f.path)" /> | ||
| 345 | + </li> | ||
| 346 | + <li v-for="(_c, p) in extraFiles" :key="p" class="flex items-center gap-1 px-2 py-1"> | ||
| 347 | + <UIcon name="i-lucide-file-plus" class="size-3.5 shrink-0 text-success" /> | ||
| 348 | + <span class="min-w-0 flex-1 truncate font-mono">{{ p }}</span> | ||
| 349 | + <UBadge label="new" size="sm" color="success" variant="subtle" /> | ||
| 350 | + <UButton icon="i-lucide-x" size="xs" color="neutral" variant="ghost" aria-label="Discard new file" @click="dropNew(p as string)" /> | ||
| 351 | + </li> | ||
| 352 | + </ul> | ||
| 353 | + <p v-else class="text-xs text-muted">SKILL.md only.</p> | ||
| 354 | + | ||
| 355 | + <div class="space-y-1.5 rounded-lg border border-dashed border-default p-2"> | ||
| 356 | + <UInput v-model="newFile.path" size="sm" placeholder="references/notes.md" class="w-full" :ui="{ base: 'font-mono' }" /> | ||
| 357 | + <p v-if="newFileError" class="text-xs text-error">{{ newFileError }}</p> | ||
| 358 | + <textarea v-model="newFile.content" :class="TA" class="h-24" placeholder="Text content" spellcheck="false" aria-label="New file content" /> | ||
| 359 | + <UButton label="Add file" icon="i-lucide-plus" size="xs" color="neutral" variant="outline" :disabled="!newFile.path.trim() || !!newFileError" @click="addFile" /> | ||
| 360 | + </div> | ||
| 361 | + </section> | ||
| 362 | + </aside> | ||
| 363 | + </div> | ||
| 364 | + | ||
| 365 | + <!-- Skill file viewer --> | ||
| 366 | + <USlideover :open="!!viewFile" :title="viewFile ?? ''" :ui="{ content: 'sm:max-w-2xl' }" @update:open="(v: boolean) => { if (!v) viewFile = null }"> | ||
| 367 | + <template #body> | ||
| 368 | + <MarkdownView v-if="viewFile && detail" :path="joinPath(detail.item.path, viewFile)" /> | ||
| 369 | + </template> | ||
| 370 | + </USlideover> | ||
| 371 | + | ||
| 372 | + <!-- Delete --> | ||
| 373 | + <UModal v-model:open="delOpen" :title="`Delete ${name}`" description="This removes the item from the catalog."> | ||
| 374 | + <template #body> | ||
| 375 | + <div class="space-y-3 text-sm"> | ||
| 376 | + <p> | ||
| 377 | + <strong>{{ name }}</strong> will be deleted from the catalog folder. Scopes where it was already written keep their files, | ||
| 378 | + but the item will be reported as missing in the catalog. | ||
| 379 | + </p> | ||
| 380 | + <UAlert | ||
| 381 | + v-if="delConflict || usedBy.length" | ||
| 382 | + color="warning" | ||
| 383 | + variant="subtle" | ||
| 384 | + icon="i-lucide-triangle-alert" | ||
| 385 | + :title="delConflict ? 'This item is used' : 'Used by'" | ||
| 386 | + > | ||
| 387 | + <template #description> | ||
| 388 | + <p v-if="delConflict" class="mb-1">{{ delConflict }}</p> | ||
| 389 | + <ul class="list-disc pl-4"> | ||
| 390 | + <li v-for="s in usedBy" :key="s">{{ scopeLabel(s, scopeNames) }}</li> | ||
| 391 | + </ul> | ||
| 392 | + </template> | ||
| 393 | + </UAlert> | ||
| 394 | + </div> | ||
| 395 | + </template> | ||
| 396 | + <template #footer> | ||
| 397 | + <div class="flex w-full justify-end gap-2"> | ||
| 398 | + <UButton label="Cancel" color="neutral" variant="outline" @click="delOpen = false" /> | ||
| 399 | + <UButton :label="delConflict ? 'Delete anyway' : 'Delete'" color="error" :loading="deleting" @click="doDelete(!!delConflict)" /> | ||
| 400 | + </div> | ||
| 401 | + </template> | ||
| 402 | + </UModal> | ||
| 403 | + </template> | ||
| 404 | + </UDashboardPanel> | ||
| 405 | +</template> | ||
added
packages/ui/app/components/CatalogSetupCard.vue +42 -0 | new file mode 100644 | ||
| @@ -0,0 +1,42 @@ | ||
| 1 | +<script setup lang="ts"> | |
| 2 | +const { request } = useApi() | |
| 3 | +const { init, refresh } = useCatalog() | |
| 4 | +const toast = useToast() | |
| 5 | +const path = ref('') | |
| 6 | +const creating = ref(false) | |
| 7 | + | |
| 8 | +onMounted(async () => { | |
| 9 | + try { | |
| 10 | + path.value = (await request<{ catalogPath: string }>('/api/settings', { silent: true })).catalogPath | |
| 11 | + } catch { /* shown by the page */ } | |
| 12 | +}) | |
| 13 | + | |
| 14 | +async function create() { | |
| 15 | + creating.value = true | |
| 16 | + try { | |
| 17 | + await init() | |
| 18 | + toast.add({ title: 'Catalog created', description: path.value, icon: 'i-lucide-check', color: 'success' }) | |
| 19 | + await refresh() | |
| 20 | + } catch { | |
| 21 | + // toast shown by useApi | |
| 22 | + } finally { | |
| 23 | + creating.value = false | |
| 24 | + } | |
| 25 | +} | |
| 26 | +</script> | |
| 27 | + | |
| 28 | +<template> | |
| 29 | + <div class="mx-auto flex max-w-xl flex-col items-center gap-3 rounded-xl border border-dashed border-default px-6 py-12 text-center"> | |
| 30 | + <UIcon name="i-lucide-library" class="size-10 text-dimmed" /> | |
| 31 | + <h2 class="font-semibold">No catalog yet</h2> | |
| 32 | + <p class="text-sm text-muted"> | |
| 33 | + The catalog is a git repository holding your skills, rules, MCP servers, hooks, agents and presets. | |
| 34 | + Create one at the location below, or point skilly to an existing repository in Settings. | |
| 35 | + </p> | |
| 36 | + <p class="max-w-full truncate rounded bg-elevated px-2 py-1 font-mono text-xs" :title="path">{{ path || '...' }}</p> | |
| 37 | + <div class="flex gap-2"> | |
| 38 | + <UButton label="Create catalog" icon="i-lucide-folder-plus" :loading="creating" :disabled="!path" @click="create" /> | |
| 39 | + <UButton label="Change location" to="/settings" icon="i-lucide-settings" color="neutral" variant="outline" /> | |
| 40 | + </div> | |
| 41 | + </div> | |
| 42 | +</template> | |
| new file mode 100644 | |||
| @@ -0,0 +1,42 @@ | |||
| 1 | +<script setup lang="ts"> | ||
| 2 | +const { request } = useApi() | ||
| 3 | +const { init, refresh } = useCatalog() | ||
| 4 | +const toast = useToast() | ||
| 5 | +const path = ref('') | ||
| 6 | +const creating = ref(false) | ||
| 7 | + | ||
| 8 | +onMounted(async () => { | ||
| 9 | + try { | ||
| 10 | + path.value = (await request<{ catalogPath: string }>('/api/settings', { silent: true })).catalogPath | ||
| 11 | + } catch { /* shown by the page */ } | ||
| 12 | +}) | ||
| 13 | + | ||
| 14 | +async function create() { | ||
| 15 | + creating.value = true | ||
| 16 | + try { | ||
| 17 | + await init() | ||
| 18 | + toast.add({ title: 'Catalog created', description: path.value, icon: 'i-lucide-check', color: 'success' }) | ||
| 19 | + await refresh() | ||
| 20 | + } catch { | ||
| 21 | + // toast shown by useApi | ||
| 22 | + } finally { | ||
| 23 | + creating.value = false | ||
| 24 | + } | ||
| 25 | +} | ||
| 26 | +</script> | ||
| 27 | + | ||
| 28 | +<template> | ||
| 29 | + <div class="mx-auto flex max-w-xl flex-col items-center gap-3 rounded-xl border border-dashed border-default px-6 py-12 text-center"> | ||
| 30 | + <UIcon name="i-lucide-library" class="size-10 text-dimmed" /> | ||
| 31 | + <h2 class="font-semibold">No catalog yet</h2> | ||
| 32 | + <p class="text-sm text-muted"> | ||
| 33 | + The catalog is a git repository holding your skills, rules, MCP servers, hooks, agents and presets. | ||
| 34 | + Create one at the location below, or point skilly to an existing repository in Settings. | ||
| 35 | + </p> | ||
| 36 | + <p class="max-w-full truncate rounded bg-elevated px-2 py-1 font-mono text-xs" :title="path">{{ path || '...' }}</p> | ||
| 37 | + <div class="flex gap-2"> | ||
| 38 | + <UButton label="Create catalog" icon="i-lucide-folder-plus" :loading="creating" :disabled="!path" @click="create" /> | ||
| 39 | + <UButton label="Change location" to="/settings" icon="i-lucide-settings" color="neutral" variant="outline" /> | ||
| 40 | + </div> | ||
| 41 | + </div> | ||
| 42 | +</template> | ||
modified
packages/ui/app/components/CountChips.vue +4 -4 | @@ -1,13 +1,13 @@ | ||
| 1 | 1 | <script setup lang="ts"> |
| 2 | 2 | import type { ItemType } from '@skilly/core' |
| 3 | 3 | |
| 4 | -defineProps<{ counts: Record<ItemType, number> }>() | |
| 4 | +defineProps<{ counts: Record<ItemType, number>, compact?: boolean }>() | |
| 5 | 5 | </script> |
| 6 | 6 | |
| 7 | 7 | <template> |
| 8 | - <div class="flex flex-wrap gap-x-4 gap-y-1.5"> | |
| 9 | - <div v-for="t in TYPE_ORDER" :key="t" class="flex items-center gap-1.5 text-sm" :class="counts[t] ? '' : 'text-dimmed'"> | |
| 10 | - <UIcon :name="TYPE_META[t].icon" class="size-4" /> | |
| 8 | + <div class="flex flex-wrap" :class="compact ? 'gap-x-3 gap-y-1' : 'gap-x-4 gap-y-1.5'"> | |
| 9 | + <div v-for="t in TYPE_ORDER" :key="t" class="flex items-center gap-1" :class="[counts[t] ? '' : 'text-dimmed', compact ? 'text-xs' : 'text-sm']" :title="TYPE_META[t].label"> | |
| 10 | + <UIcon :name="TYPE_META[t].icon" class="size-3.5" /> | |
| 11 | 11 | <span class="font-medium tabular-nums">{{ counts[t] ?? 0 }}</span> |
| 12 | 12 | <span class="text-muted">{{ TYPE_META[t].label }}</span> |
| 13 | 13 | </div> |
| @@ -1,13 +1,13 @@ | |||
| 1 | <script setup lang="ts"> | 1 | <script setup lang="ts"> |
| 2 | import type { ItemType } from '@skilly/core' | 2 | import type { ItemType } from '@skilly/core' |
| 3 | 3 | ||
| 4 | -defineProps<{ counts: Record<ItemType, number> }>() | 4 | +defineProps<{ counts: Record<ItemType, number>, compact?: boolean }>() |
| 5 | </script> | 5 | </script> |
| 6 | 6 | ||
| 7 | <template> | 7 | <template> |
| 8 | - <div class="flex flex-wrap gap-x-4 gap-y-1.5"> | 8 | + <div class="flex flex-wrap" :class="compact ? 'gap-x-3 gap-y-1' : 'gap-x-4 gap-y-1.5'"> |
| 9 | - <div v-for="t in TYPE_ORDER" :key="t" class="flex items-center gap-1.5 text-sm" :class="counts[t] ? '' : 'text-dimmed'"> | 9 | + <div v-for="t in TYPE_ORDER" :key="t" class="flex items-center gap-1" :class="[counts[t] ? '' : 'text-dimmed', compact ? 'text-xs' : 'text-sm']" :title="TYPE_META[t].label"> |
| 10 | - <UIcon :name="TYPE_META[t].icon" class="size-4" /> | 10 | + <UIcon :name="TYPE_META[t].icon" class="size-3.5" /> |
| 11 | <span class="font-medium tabular-nums">{{ counts[t] ?? 0 }}</span> | 11 | <span class="font-medium tabular-nums">{{ counts[t] ?? 0 }}</span> |
| 12 | <span class="text-muted">{{ TYPE_META[t].label }}</span> | 12 | <span class="text-muted">{{ TYPE_META[t].label }}</span> |
| 13 | </div> | 13 | </div> |
added
packages/ui/app/components/EnvRows.vue +66 -0 | new file mode 100644 | ||
| @@ -0,0 +1,66 @@ | ||
| 1 | +<script setup lang="ts"> | |
| 2 | +import type { McpEnvValue } from '@skilly/core' | |
| 3 | + | |
| 4 | +/** Editable key -> (env var | literal) rows. Used for MCP `env` and `headers`. */ | |
| 5 | +const props = defineProps<{ modelValue: Record<string, McpEnvValue> | undefined, keyLabel: string, keyPlaceholder: string }>() | |
| 6 | +const emit = defineEmits<{ 'update:modelValue': [v: Record<string, McpEnvValue>] }>() | |
| 7 | + | |
| 8 | +interface Row { key: string, mode: 'env' | 'literal', val: string } | |
| 9 | + | |
| 10 | +// Rows are the source of truth while editing (keys may be empty or duplicated until the user is done). | |
| 11 | +const rows = ref<Row[]>(Object.entries(props.modelValue ?? {}).map(([key, v]) => ({ | |
| 12 | + key, | |
| 13 | + mode: v.fromEnv !== undefined ? 'env' : 'literal', | |
| 14 | + val: v.fromEnv ?? v.value ?? '' | |
| 15 | +}))) | |
| 16 | + | |
| 17 | +function push() { | |
| 18 | + const out: Record<string, McpEnvValue> = {} | |
| 19 | + for (const r of rows.value) { | |
| 20 | + if (!r.key.trim()) continue | |
| 21 | + out[r.key.trim()] = r.mode === 'env' ? { fromEnv: r.val } : { value: r.val } | |
| 22 | + } | |
| 23 | + emit('update:modelValue', out) | |
| 24 | +} | |
| 25 | + | |
| 26 | +const hasLiteral = computed(() => rows.value.some(r => r.mode === 'literal' && r.val)) | |
| 27 | + | |
| 28 | +function add() { | |
| 29 | + rows.value.push({ key: '', mode: 'env', val: '' }) | |
| 30 | +} | |
| 31 | +function removeRow(i: number) { | |
| 32 | + rows.value.splice(i, 1) | |
| 33 | + push() | |
| 34 | +} | |
| 35 | + | |
| 36 | +const MODES = [ | |
| 37 | + { label: 'From env var', value: 'env' }, | |
| 38 | + { label: 'Literal value', value: 'literal' } | |
| 39 | +] | |
| 40 | +</script> | |
| 41 | + | |
| 42 | +<template> | |
| 43 | + <div class="space-y-2"> | |
| 44 | + <div v-for="(r, i) in rows" :key="i" class="flex items-center gap-2"> | |
| 45 | + <UInput v-model="r.key" :placeholder="keyPlaceholder" :aria-label="keyLabel" class="w-48" :ui="{ base: 'font-mono' }" @update:model-value="push" /> | |
| 46 | + <USelect v-model="r.mode" :items="MODES" class="w-40" @update:model-value="push" /> | |
| 47 | + <UInput | |
| 48 | + v-model="r.val" | |
| 49 | + :placeholder="r.mode === 'env' ? 'ENV_VAR_NAME' : 'value (stored in clear)'" | |
| 50 | + class="flex-1" | |
| 51 | + :ui="{ base: 'font-mono' }" | |
| 52 | + @update:model-value="push" | |
| 53 | + /> | |
| 54 | + <UButton icon="i-lucide-x" color="neutral" variant="ghost" size="sm" aria-label="Remove row" @click="removeRow(i)" /> | |
| 55 | + </div> | |
| 56 | + <UButton label="Add row" icon="i-lucide-plus" size="xs" color="neutral" variant="outline" @click="add" /> | |
| 57 | + <UAlert | |
| 58 | + v-if="hasLiteral" | |
| 59 | + color="warning" | |
| 60 | + variant="subtle" | |
| 61 | + icon="i-lucide-triangle-alert" | |
| 62 | + title="Literal values are stored in clear in the catalog" | |
| 63 | + description="The catalog is a git repository that may be shared. Use a literal value only for non-secret settings; for tokens and keys, choose 'From env var'." | |
| 64 | + /> | |
| 65 | + </div> | |
| 66 | +</template> | |
| new file mode 100644 | |||
| @@ -0,0 +1,66 @@ | |||
| 1 | +<script setup lang="ts"> | ||
| 2 | +import type { McpEnvValue } from '@skilly/core' | ||
| 3 | + | ||
| 4 | +/** Editable key -> (env var | literal) rows. Used for MCP `env` and `headers`. */ | ||
| 5 | +const props = defineProps<{ modelValue: Record<string, McpEnvValue> | undefined, keyLabel: string, keyPlaceholder: string }>() | ||
| 6 | +const emit = defineEmits<{ 'update:modelValue': [v: Record<string, McpEnvValue>] }>() | ||
| 7 | + | ||
| 8 | +interface Row { key: string, mode: 'env' | 'literal', val: string } | ||
| 9 | + | ||
| 10 | +// Rows are the source of truth while editing (keys may be empty or duplicated until the user is done). | ||
| 11 | +const rows = ref<Row[]>(Object.entries(props.modelValue ?? {}).map(([key, v]) => ({ | ||
| 12 | + key, | ||
| 13 | + mode: v.fromEnv !== undefined ? 'env' : 'literal', | ||
| 14 | + val: v.fromEnv ?? v.value ?? '' | ||
| 15 | +}))) | ||
| 16 | + | ||
| 17 | +function push() { | ||
| 18 | + const out: Record<string, McpEnvValue> = {} | ||
| 19 | + for (const r of rows.value) { | ||
| 20 | + if (!r.key.trim()) continue | ||
| 21 | + out[r.key.trim()] = r.mode === 'env' ? { fromEnv: r.val } : { value: r.val } | ||
| 22 | + } | ||
| 23 | + emit('update:modelValue', out) | ||
| 24 | +} | ||
| 25 | + | ||
| 26 | +const hasLiteral = computed(() => rows.value.some(r => r.mode === 'literal' && r.val)) | ||
| 27 | + | ||
| 28 | +function add() { | ||
| 29 | + rows.value.push({ key: '', mode: 'env', val: '' }) | ||
| 30 | +} | ||
| 31 | +function removeRow(i: number) { | ||
| 32 | + rows.value.splice(i, 1) | ||
| 33 | + push() | ||
| 34 | +} | ||
| 35 | + | ||
| 36 | +const MODES = [ | ||
| 37 | + { label: 'From env var', value: 'env' }, | ||
| 38 | + { label: 'Literal value', value: 'literal' } | ||
| 39 | +] | ||
| 40 | +</script> | ||
| 41 | + | ||
| 42 | +<template> | ||
| 43 | + <div class="space-y-2"> | ||
| 44 | + <div v-for="(r, i) in rows" :key="i" class="flex items-center gap-2"> | ||
| 45 | + <UInput v-model="r.key" :placeholder="keyPlaceholder" :aria-label="keyLabel" class="w-48" :ui="{ base: 'font-mono' }" @update:model-value="push" /> | ||
| 46 | + <USelect v-model="r.mode" :items="MODES" class="w-40" @update:model-value="push" /> | ||
| 47 | + <UInput | ||
| 48 | + v-model="r.val" | ||
| 49 | + :placeholder="r.mode === 'env' ? 'ENV_VAR_NAME' : 'value (stored in clear)'" | ||
| 50 | + class="flex-1" | ||
| 51 | + :ui="{ base: 'font-mono' }" | ||
| 52 | + @update:model-value="push" | ||
| 53 | + /> | ||
| 54 | + <UButton icon="i-lucide-x" color="neutral" variant="ghost" size="sm" aria-label="Remove row" @click="removeRow(i)" /> | ||
| 55 | + </div> | ||
| 56 | + <UButton label="Add row" icon="i-lucide-plus" size="xs" color="neutral" variant="outline" @click="add" /> | ||
| 57 | + <UAlert | ||
| 58 | + v-if="hasLiteral" | ||
| 59 | + color="warning" | ||
| 60 | + variant="subtle" | ||
| 61 | + icon="i-lucide-triangle-alert" | ||
| 62 | + title="Literal values are stored in clear in the catalog" | ||
| 63 | + description="The catalog is a git repository that may be shared. Use a literal value only for non-secret settings; for tokens and keys, choose 'From env var'." | ||
| 64 | + /> | ||
| 65 | + </div> | ||
| 66 | +</template> | ||
added
packages/ui/app/components/FolderBrowser.vue +88 -0 | new file mode 100644 | ||
| @@ -0,0 +1,88 @@ | ||
| 1 | +<script setup lang="ts"> | |
| 2 | +import type { FsListing } from '@skilly/core' | |
| 3 | + | |
| 4 | +/** Folder browser backed by /api/fs/list. `selected` is the highlighted folder, falling back to the folder being browsed. */ | |
| 5 | +const props = defineProps<{ start?: string, height?: string }>() | |
| 6 | +const selected = defineModel<string | null>('selected', { default: null }) | |
| 7 | + | |
| 8 | + | |
| 9 | +const { request } = useApi() | |
| 10 | +const listing = ref<FsListing | null>(null) | |
| 11 | +const loading = ref(false) | |
| 12 | +const listError = ref<string | null>(null) | |
| 13 | +const highlighted = ref<string | null>(null) | |
| 14 | + | |
| 15 | +const crumbs = computed(() => (listing.value?.path ? pathSegments(listing.value.path) : [])) | |
| 16 | + | |
| 17 | +async function browse(path?: string) { | |
| 18 | + loading.value = true | |
| 19 | + listError.value = null | |
| 20 | + highlighted.value = null | |
| 21 | + try { | |
| 22 | + listing.value = await request<FsListing>('/api/fs/list', { query: { path }, silent: true }) | |
| 23 | + selected.value = listing.value.path | |
| 24 | + } catch (e) { | |
| 25 | + listError.value = (e as Error).message | |
| 26 | + } finally { | |
| 27 | + loading.value = false | |
| 28 | + } | |
| 29 | +} | |
| 30 | + | |
| 31 | +function highlight(path: string) { | |
| 32 | + highlighted.value = path | |
| 33 | + selected.value = path | |
| 34 | +} | |
| 35 | + | |
| 36 | +function reset() { browse(props.start || undefined) } | |
| 37 | +defineExpose({ reset }) | |
| 38 | +onMounted(reset) | |
| 39 | +</script> | |
| 40 | + | |
| 41 | +<template> | |
| 42 | + <div class="space-y-3"> | |
| 43 | + <div class="flex flex-wrap items-center gap-1 text-sm"> | |
| 44 | + <UButton label="This PC" icon="i-lucide-hard-drive" size="xs" color="neutral" variant="ghost" @click="browse()" /> | |
| 45 | + <template v-for="c in crumbs" :key="c.path"> | |
| 46 | + <UIcon name="i-lucide-chevron-right" class="size-3.5 text-dimmed" /> | |
| 47 | + <UButton :label="c.name" size="xs" color="neutral" variant="ghost" @click="browse(c.path)" /> | |
| 48 | + </template> | |
| 49 | + </div> | |
| 50 | + | |
| 51 | + <div class="overflow-y-auto rounded-lg border border-default" :class="height ?? 'h-72'"> | |
| 52 | + <div v-if="loading" class="space-y-1 p-2"> | |
| 53 | + <USkeleton v-for="i in 6" :key="i" class="h-8 w-full" /> | |
| 54 | + </div> | |
| 55 | + <div v-else-if="listError" class="p-3"> | |
| 56 | + <UAlert color="error" variant="subtle" icon="i-lucide-circle-alert" title="Cannot list this folder" :description="listError" /> | |
| 57 | + </div> | |
| 58 | + <ul v-else-if="listing" class="divide-y divide-default text-sm"> | |
| 59 | + <li v-if="listing.parent"> | |
| 60 | + <button type="button" class="flex w-full items-center gap-2 px-3 py-2 hover:bg-elevated/60" @click="browse(listing.parent!)"> | |
| 61 | + <UIcon name="i-lucide-corner-left-up" class="size-4 text-muted" /> | |
| 62 | + <span class="text-muted">..</span> | |
| 63 | + </button> | |
| 64 | + </li> | |
| 65 | + <li | |
| 66 | + v-for="e in listing.entries" | |
| 67 | + :key="e.path" | |
| 68 | + class="flex items-center hover:bg-elevated/60" | |
| 69 | + :class="highlighted === e.path ? 'bg-primary/10' : ''" | |
| 70 | + > | |
| 71 | + <button | |
| 72 | + type="button" | |
| 73 | + class="flex min-w-0 flex-1 items-center gap-2 py-2 pl-3 text-left" | |
| 74 | + :title="e.path" | |
| 75 | + @click="highlight(e.path)" | |
| 76 | + @dblclick="browse(e.path)" | |
| 77 | + > | |
| 78 | + <UIcon :name="listing.path ? 'i-lucide-folder' : /^[a-zA-Z]:[\\/]?$/.test(e.name) ? 'i-lucide-hard-drive' : 'i-lucide-house'" class="size-4 shrink-0 text-muted" /> | |
| 79 | + <span class="flex-1 truncate">{{ e.name }}</span> | |
| 80 | + <UBadge v-if="e.hasAgentConfig" label="agent config" color="primary" variant="subtle" size="sm" icon="i-lucide-sparkles" class="shrink-0" /> | |
| 81 | + </button> | |
| 82 | + <UButton icon="i-lucide-chevron-right" size="xs" color="neutral" variant="ghost" class="mx-2 shrink-0" :aria-label="`Open ${e.name}`" @click="browse(e.path)" /> | |
| 83 | + </li> | |
| 84 | + <li v-if="!listing.entries.length" class="px-3 py-8 text-center text-muted">No subfolder here.</li> | |
| 85 | + </ul> | |
| 86 | + </div> | |
| 87 | + </div> | |
| 88 | +</template> | |
| new file mode 100644 | |||
| @@ -0,0 +1,88 @@ | |||
| 1 | +<script setup lang="ts"> | ||
| 2 | +import type { FsListing } from '@skilly/core' | ||
| 3 | + | ||
| 4 | +/** Folder browser backed by /api/fs/list. `selected` is the highlighted folder, falling back to the folder being browsed. */ | ||
| 5 | +const props = defineProps<{ start?: string, height?: string }>() | ||
| 6 | +const selected = defineModel<string | null>('selected', { default: null }) | ||
| 7 | + | ||
| 8 | + | ||
| 9 | +const { request } = useApi() | ||
| 10 | +const listing = ref<FsListing | null>(null) | ||
| 11 | +const loading = ref(false) | ||
| 12 | +const listError = ref<string | null>(null) | ||
| 13 | +const highlighted = ref<string | null>(null) | ||
| 14 | + | ||
| 15 | +const crumbs = computed(() => (listing.value?.path ? pathSegments(listing.value.path) : [])) | ||
| 16 | + | ||
| 17 | +async function browse(path?: string) { | ||
| 18 | + loading.value = true | ||
| 19 | + listError.value = null | ||
| 20 | + highlighted.value = null | ||
| 21 | + try { | ||
| 22 | + listing.value = await request<FsListing>('/api/fs/list', { query: { path }, silent: true }) | ||
| 23 | + selected.value = listing.value.path | ||
| 24 | + } catch (e) { | ||
| 25 | + listError.value = (e as Error).message | ||
| 26 | + } finally { | ||
| 27 | + loading.value = false | ||
| 28 | + } | ||
| 29 | +} | ||
| 30 | + | ||
| 31 | +function highlight(path: string) { | ||
| 32 | + highlighted.value = path | ||
| 33 | + selected.value = path | ||
| 34 | +} | ||
| 35 | + | ||
| 36 | +function reset() { browse(props.start || undefined) } | ||
| 37 | +defineExpose({ reset }) | ||
| 38 | +onMounted(reset) | ||
| 39 | +</script> | ||
| 40 | + | ||
| 41 | +<template> | ||
| 42 | + <div class="space-y-3"> | ||
| 43 | + <div class="flex flex-wrap items-center gap-1 text-sm"> | ||
| 44 | + <UButton label="This PC" icon="i-lucide-hard-drive" size="xs" color="neutral" variant="ghost" @click="browse()" /> | ||
| 45 | + <template v-for="c in crumbs" :key="c.path"> | ||
| 46 | + <UIcon name="i-lucide-chevron-right" class="size-3.5 text-dimmed" /> | ||
| 47 | + <UButton :label="c.name" size="xs" color="neutral" variant="ghost" @click="browse(c.path)" /> | ||
| 48 | + </template> | ||
| 49 | + </div> | ||
| 50 | + | ||
| 51 | + <div class="overflow-y-auto rounded-lg border border-default" :class="height ?? 'h-72'"> | ||
| 52 | + <div v-if="loading" class="space-y-1 p-2"> | ||
| 53 | + <USkeleton v-for="i in 6" :key="i" class="h-8 w-full" /> | ||
| 54 | + </div> | ||
| 55 | + <div v-else-if="listError" class="p-3"> | ||
| 56 | + <UAlert color="error" variant="subtle" icon="i-lucide-circle-alert" title="Cannot list this folder" :description="listError" /> | ||
| 57 | + </div> | ||
| 58 | + <ul v-else-if="listing" class="divide-y divide-default text-sm"> | ||
| 59 | + <li v-if="listing.parent"> | ||
| 60 | + <button type="button" class="flex w-full items-center gap-2 px-3 py-2 hover:bg-elevated/60" @click="browse(listing.parent!)"> | ||
| 61 | + <UIcon name="i-lucide-corner-left-up" class="size-4 text-muted" /> | ||
| 62 | + <span class="text-muted">..</span> | ||
| 63 | + </button> | ||
| 64 | + </li> | ||
| 65 | + <li | ||
| 66 | + v-for="e in listing.entries" | ||
| 67 | + :key="e.path" | ||
| 68 | + class="flex items-center hover:bg-elevated/60" | ||
| 69 | + :class="highlighted === e.path ? 'bg-primary/10' : ''" | ||
| 70 | + > | ||
| 71 | + <button | ||
| 72 | + type="button" | ||
| 73 | + class="flex min-w-0 flex-1 items-center gap-2 py-2 pl-3 text-left" | ||
| 74 | + :title="e.path" | ||
| 75 | + @click="highlight(e.path)" | ||
| 76 | + @dblclick="browse(e.path)" | ||
| 77 | + > | ||
| 78 | + <UIcon :name="listing.path ? 'i-lucide-folder' : /^[a-zA-Z]:[\\/]?$/.test(e.name) ? 'i-lucide-hard-drive' : 'i-lucide-house'" class="size-4 shrink-0 text-muted" /> | ||
| 79 | + <span class="flex-1 truncate">{{ e.name }}</span> | ||
| 80 | + <UBadge v-if="e.hasAgentConfig" label="agent config" color="primary" variant="subtle" size="sm" icon="i-lucide-sparkles" class="shrink-0" /> | ||
| 81 | + </button> | ||
| 82 | + <UButton icon="i-lucide-chevron-right" size="xs" color="neutral" variant="ghost" class="mx-2 shrink-0" :aria-label="`Open ${e.name}`" @click="browse(e.path)" /> | ||
| 83 | + </li> | ||
| 84 | + <li v-if="!listing.entries.length" class="px-3 py-8 text-center text-muted">No subfolder here.</li> | ||
| 85 | + </ul> | ||
| 86 | + </div> | ||
| 87 | + </div> | ||
| 88 | +</template> | ||
added
packages/ui/app/components/FolderPickerModal.vue +29 -0 | new file mode 100644 | ||
| @@ -0,0 +1,29 @@ | ||
| 1 | +<script setup lang="ts"> | |
| 2 | +const open = defineModel<boolean>('open', { default: false }) | |
| 3 | +defineProps<{ title?: string, start?: string }>() | |
| 4 | +const emit = defineEmits<{ pick: [path: string] }>() | |
| 5 | +const selected = ref<string | null>(null) | |
| 6 | + | |
| 7 | +function confirm() { | |
| 8 | + if (!selected.value) return | |
| 9 | + emit('pick', selected.value) | |
| 10 | + open.value = false | |
| 11 | +} | |
| 12 | +</script> | |
| 13 | + | |
| 14 | +<template> | |
| 15 | + <UModal v-model:open="open" :title="title ?? 'Choose a folder'" description="Browse to a folder, then select it." :ui="{ content: 'max-w-2xl' }"> | |
| 16 | + <template #body> | |
| 17 | + <FolderBrowser v-if="open" v-model:selected="selected" :start="start" /> | |
| 18 | + </template> | |
| 19 | + <template #footer> | |
| 20 | + <div class="flex w-full items-center justify-between gap-2"> | |
| 21 | + <span class="truncate font-mono text-xs text-muted" :title="selected ?? ''">{{ selected ?? 'Select a drive or folder' }}</span> | |
| 22 | + <div class="flex shrink-0 gap-2"> | |
| 23 | + <UButton label="Cancel" color="neutral" variant="outline" @click="open = false" /> | |
| 24 | + <UButton label="Select this folder" icon="i-lucide-check" :disabled="!selected" @click="confirm" /> | |
| 25 | + </div> | |
| 26 | + </div> | |
| 27 | + </template> | |
| 28 | + </UModal> | |
| 29 | +</template> | |
| new file mode 100644 | |||
| @@ -0,0 +1,29 @@ | |||
| 1 | +<script setup lang="ts"> | ||
| 2 | +const open = defineModel<boolean>('open', { default: false }) | ||
| 3 | +defineProps<{ title?: string, start?: string }>() | ||
| 4 | +const emit = defineEmits<{ pick: [path: string] }>() | ||
| 5 | +const selected = ref<string | null>(null) | ||
| 6 | + | ||
| 7 | +function confirm() { | ||
| 8 | + if (!selected.value) return | ||
| 9 | + emit('pick', selected.value) | ||
| 10 | + open.value = false | ||
| 11 | +} | ||
| 12 | +</script> | ||
| 13 | + | ||
| 14 | +<template> | ||
| 15 | + <UModal v-model:open="open" :title="title ?? 'Choose a folder'" description="Browse to a folder, then select it." :ui="{ content: 'max-w-2xl' }"> | ||
| 16 | + <template #body> | ||
| 17 | + <FolderBrowser v-if="open" v-model:selected="selected" :start="start" /> | ||
| 18 | + </template> | ||
| 19 | + <template #footer> | ||
| 20 | + <div class="flex w-full items-center justify-between gap-2"> | ||
| 21 | + <span class="truncate font-mono text-xs text-muted" :title="selected ?? ''">{{ selected ?? 'Select a drive or folder' }}</span> | ||
| 22 | + <div class="flex shrink-0 gap-2"> | ||
| 23 | + <UButton label="Cancel" color="neutral" variant="outline" @click="open = false" /> | ||
| 24 | + <UButton label="Select this folder" icon="i-lucide-check" :disabled="!selected" @click="confirm" /> | ||
| 25 | + </div> | ||
| 26 | + </div> | ||
| 27 | + </template> | ||
| 28 | + </UModal> | ||
| 29 | +</template> | ||
added
packages/ui/app/components/HookForm.vue +56 -0 | new file mode 100644 | ||
| @@ -0,0 +1,56 @@ | ||
| 1 | +<script setup lang="ts"> | |
| 2 | +import type { AgentId, HookTemplate } from '@skilly/core' | |
| 3 | + | |
| 4 | +/** Form for a hook template. The parent owns the object (v-model) and renders the YAML. */ | |
| 5 | +const t = defineModel<HookTemplate>({ required: true }) | |
| 6 | + | |
| 7 | +const events = computed(() => (HOOK_EVENTS.includes(t.value.event) || !t.value.event ? HOOK_EVENTS : [t.value.event, ...HOOK_EVENTS])) | |
| 8 | + | |
| 9 | +function patch(p: Partial<HookTemplate>) { | |
| 10 | + t.value = { ...t.value, ...p } | |
| 11 | +} | |
| 12 | +function patchHandler(p: Partial<HookTemplate['handler']>) { | |
| 13 | + t.value = { ...t.value, handler: { ...t.value.handler, ...p } } | |
| 14 | +} | |
| 15 | + | |
| 16 | +const agents = computed(() => t.value.agents ?? []) | |
| 17 | +function toggleAgent(a: AgentId, on: boolean | 'indeterminate') { | |
| 18 | + const set = new Set(agents.value) | |
| 19 | + if (on === true) set.add(a) | |
| 20 | + else set.delete(a) | |
| 21 | + patch({ agents: set.size ? [...set] : undefined }) | |
| 22 | +} | |
| 23 | +</script> | |
| 24 | + | |
| 25 | +<template> | |
| 26 | + <div class="space-y-4"> | |
| 27 | + <UFormField label="Description"> | |
| 28 | + <UInput :model-value="t.description ?? ''" class="w-full" placeholder="What this hook does" @update:model-value="(v: string) => patch({ description: v })" /> | |
| 29 | + </UFormField> | |
| 30 | + | |
| 31 | + <div class="grid gap-4 sm:grid-cols-2"> | |
| 32 | + <UFormField label="Event" required> | |
| 33 | + <USelect :model-value="t.event" :items="events" placeholder="Choose an event" class="w-full" @update:model-value="(v: string) => patch({ event: v })" /> | |
| 34 | + </UFormField> | |
| 35 | + <UFormField label="Matcher" description="Tool name or regex; empty matches everything."> | |
| 36 | + <UInput :model-value="t.matcher ?? ''" class="w-full" placeholder="Edit|Write" :ui="{ base: 'font-mono' }" @update:model-value="(v: string) => patch({ matcher: v || undefined })" /> | |
| 37 | + </UFormField> | |
| 38 | + </div> | |
| 39 | + | |
| 40 | + <UFormField label="Command" required description="Shell command run by the agent. skilly never executes it."> | |
| 41 | + <UInput :model-value="t.handler.command" class="w-full" placeholder="prettier --write $FILE" :ui="{ base: 'font-mono' }" @update:model-value="(v: string) => patchHandler({ command: v })" /> | |
| 42 | + </UFormField> | |
| 43 | + | |
| 44 | + <div class="grid gap-4 sm:grid-cols-2"> | |
| 45 | + <UFormField label="Timeout (seconds)"> | |
| 46 | + <UInputNumber :model-value="t.handler.timeout" :min="1" class="w-40" @update:model-value="(v: number | null | undefined) => patchHandler({ timeout: v || undefined })" /> | |
| 47 | + </UFormField> | |
| 48 | + <UFormField label="Agents" description="None selected means all target agents."> | |
| 49 | + <div class="flex gap-4 pt-1"> | |
| 50 | + <UCheckbox label="Claude Code" :model-value="agents.includes('claude')" @update:model-value="(v) => toggleAgent('claude', v)" /> | |
| 51 | + <UCheckbox label="Codex" :model-value="agents.includes('codex')" @update:model-value="(v) => toggleAgent('codex', v)" /> | |
| 52 | + </div> | |
| 53 | + </UFormField> | |
| 54 | + </div> | |
| 55 | + </div> | |
| 56 | +</template> | |
| new file mode 100644 | |||
| @@ -0,0 +1,56 @@ | |||
| 1 | +<script setup lang="ts"> | ||
| 2 | +import type { AgentId, HookTemplate } from '@skilly/core' | ||
| 3 | + | ||
| 4 | +/** Form for a hook template. The parent owns the object (v-model) and renders the YAML. */ | ||
| 5 | +const t = defineModel<HookTemplate>({ required: true }) | ||
| 6 | + | ||
| 7 | +const events = computed(() => (HOOK_EVENTS.includes(t.value.event) || !t.value.event ? HOOK_EVENTS : [t.value.event, ...HOOK_EVENTS])) | ||
| 8 | + | ||
| 9 | +function patch(p: Partial<HookTemplate>) { | ||
| 10 | + t.value = { ...t.value, ...p } | ||
| 11 | +} | ||
| 12 | +function patchHandler(p: Partial<HookTemplate['handler']>) { | ||
| 13 | + t.value = { ...t.value, handler: { ...t.value.handler, ...p } } | ||
| 14 | +} | ||
| 15 | + | ||
| 16 | +const agents = computed(() => t.value.agents ?? []) | ||
| 17 | +function toggleAgent(a: AgentId, on: boolean | 'indeterminate') { | ||
| 18 | + const set = new Set(agents.value) | ||
| 19 | + if (on === true) set.add(a) | ||
| 20 | + else set.delete(a) | ||
| 21 | + patch({ agents: set.size ? [...set] : undefined }) | ||
| 22 | +} | ||
| 23 | +</script> | ||
| 24 | + | ||
| 25 | +<template> | ||
| 26 | + <div class="space-y-4"> | ||
| 27 | + <UFormField label="Description"> | ||
| 28 | + <UInput :model-value="t.description ?? ''" class="w-full" placeholder="What this hook does" @update:model-value="(v: string) => patch({ description: v })" /> | ||
| 29 | + </UFormField> | ||
| 30 | + | ||
| 31 | + <div class="grid gap-4 sm:grid-cols-2"> | ||
| 32 | + <UFormField label="Event" required> | ||
| 33 | + <USelect :model-value="t.event" :items="events" placeholder="Choose an event" class="w-full" @update:model-value="(v: string) => patch({ event: v })" /> | ||
| 34 | + </UFormField> | ||
| 35 | + <UFormField label="Matcher" description="Tool name or regex; empty matches everything."> | ||
| 36 | + <UInput :model-value="t.matcher ?? ''" class="w-full" placeholder="Edit|Write" :ui="{ base: 'font-mono' }" @update:model-value="(v: string) => patch({ matcher: v || undefined })" /> | ||
| 37 | + </UFormField> | ||
| 38 | + </div> | ||
| 39 | + | ||
| 40 | + <UFormField label="Command" required description="Shell command run by the agent. skilly never executes it."> | ||
| 41 | + <UInput :model-value="t.handler.command" class="w-full" placeholder="prettier --write $FILE" :ui="{ base: 'font-mono' }" @update:model-value="(v: string) => patchHandler({ command: v })" /> | ||
| 42 | + </UFormField> | ||
| 43 | + | ||
| 44 | + <div class="grid gap-4 sm:grid-cols-2"> | ||
| 45 | + <UFormField label="Timeout (seconds)"> | ||
| 46 | + <UInputNumber :model-value="t.handler.timeout" :min="1" class="w-40" @update:model-value="(v: number | null | undefined) => patchHandler({ timeout: v || undefined })" /> | ||
| 47 | + </UFormField> | ||
| 48 | + <UFormField label="Agents" description="None selected means all target agents."> | ||
| 49 | + <div class="flex gap-4 pt-1"> | ||
| 50 | + <UCheckbox label="Claude Code" :model-value="agents.includes('claude')" @update:model-value="(v) => toggleAgent('claude', v)" /> | ||
| 51 | + <UCheckbox label="Codex" :model-value="agents.includes('codex')" @update:model-value="(v) => toggleAgent('codex', v)" /> | ||
| 52 | + </div> | ||
| 53 | + </UFormField> | ||
| 54 | + </div> | ||
| 55 | + </div> | ||
| 56 | +</template> | ||
added
packages/ui/app/components/ItemPicker.vue +80 -0 | new file mode 100644 | ||
| @@ -0,0 +1,80 @@ | ||
| 1 | +<script setup lang="ts"> | |
| 2 | +import type { CatalogItem, CatalogItemType } from '@skilly/core' | |
| 3 | + | |
| 4 | +/** | |
| 5 | + * Checkbox picker over the catalog, per type with search. | |
| 6 | + * `locked` maps `type/name` to a label: those rows are shown checked and disabled (already provided elsewhere). | |
| 7 | + */ | |
| 8 | +const props = withDefaults(defineProps<{ | |
| 9 | + items: CatalogItem[] | |
| 10 | + types?: CatalogItemType[] | |
| 11 | + locked?: Record<string, string> | |
| 12 | + height?: string | |
| 13 | +}>(), { types: () => CAT_TYPES, locked: () => ({}), height: 'h-72' }) | |
| 14 | + | |
| 15 | +const selected = defineModel<Partial<Record<CatalogItemType, string[]>>>({ required: true }) | |
| 16 | + | |
| 17 | +const tab = ref<CatalogItemType>(props.types[0] ?? 'skill') | |
| 18 | +const search = ref('') | |
| 19 | +const LIMIT = 200 | |
| 20 | +const showAll = ref(false) | |
| 21 | + | |
| 22 | +watch([tab, search], () => { showAll.value = false }) | |
| 23 | + | |
| 24 | +const available = computed(() => props.items.filter(i => i.type === tab.value).sort((a, b) => a.name.localeCompare(b.name))) | |
| 25 | +const filtered = computed(() => { | |
| 26 | + const q = search.value.trim().toLowerCase() | |
| 27 | + return q ? available.value.filter(i => `${i.name} ${i.description ?? ''} ${i.tags.join(' ')}`.toLowerCase().includes(q)) : available.value | |
| 28 | +}) | |
| 29 | +const shown = computed(() => (showAll.value ? filtered.value : filtered.value.slice(0, LIMIT))) | |
| 30 | + | |
| 31 | +const tabs = computed(() => props.types.map(t => ({ | |
| 32 | + label: CAT_META[t].label, | |
| 33 | + icon: CAT_META[t].icon, | |
| 34 | + value: t, | |
| 35 | + badge: { label: String(selected.value[t]?.length ?? 0), color: (selected.value[t]?.length ? 'primary' : 'neutral') as 'primary' | 'neutral', variant: 'subtle' as const } | |
| 36 | +}))) | |
| 37 | + | |
| 38 | +const isOn = (name: string) => !!selected.value[tab.value]?.includes(name) | |
| 39 | + | |
| 40 | +function toggle(name: string, on: boolean | 'indeterminate') { | |
| 41 | + const cur = new Set(selected.value[tab.value] ?? []) | |
| 42 | + if (on === true) cur.add(name) | |
| 43 | + else cur.delete(name) | |
| 44 | + selected.value = { ...selected.value, [tab.value]: [...cur] } | |
| 45 | +} | |
| 46 | +</script> | |
| 47 | + | |
| 48 | +<template> | |
| 49 | + <div class="space-y-3"> | |
| 50 | + <UTabs v-model="tab" :items="tabs" :content="false" variant="link" size="sm" class="w-full" /> | |
| 51 | + <UInput v-model="search" icon="i-lucide-search" size="sm" placeholder="Search..." class="w-full" /> | |
| 52 | + <div class="overflow-y-auto rounded-lg border border-default" :class="height"> | |
| 53 | + <ul v-if="shown.length" class="divide-y divide-default"> | |
| 54 | + <li v-for="i in shown" :key="i.name" class="flex items-start gap-2 px-3 py-2 hover:bg-elevated/50"> | |
| 55 | + <UCheckbox | |
| 56 | + class="mt-0.5" | |
| 57 | + :model-value="!!locked[itemKey(i.type, i.name)] || isOn(i.name)" | |
| 58 | + :disabled="!!locked[itemKey(i.type, i.name)]" | |
| 59 | + :aria-label="i.name" | |
| 60 | + @update:model-value="(v) => toggle(i.name, v)" | |
| 61 | + /> | |
| 62 | + <div class="min-w-0 flex-1"> | |
| 63 | + <div class="flex items-center gap-2"> | |
| 64 | + <span class="truncate text-sm font-medium">{{ i.name }}</span> | |
| 65 | + <UBadge v-if="locked[itemKey(i.type, i.name)]" :label="locked[itemKey(i.type, i.name)]" size="sm" color="neutral" variant="subtle" /> | |
| 66 | + <UBadge v-for="t in i.tags.slice(0, 3)" :key="t" :label="t" size="sm" color="neutral" variant="outline" /> | |
| 67 | + </div> | |
| 68 | + <p v-if="i.description" class="line-clamp-1 text-xs text-muted" :title="i.description">{{ i.description }}</p> | |
| 69 | + </div> | |
| 70 | + </li> | |
| 71 | + <li v-if="filtered.length > shown.length" class="p-2 text-center"> | |
| 72 | + <UButton :label="`Show all ${filtered.length}`" size="xs" color="neutral" variant="link" @click="showAll = true" /> | |
| 73 | + </li> | |
| 74 | + </ul> | |
| 75 | + <p v-else class="p-6 text-center text-sm text-muted"> | |
| 76 | + {{ search ? 'No result.' : `No ${CAT_META[tab].label.toLowerCase()} in the catalog.` }} | |
| 77 | + </p> | |
| 78 | + </div> | |
| 79 | + </div> | |
| 80 | +</template> | |
| new file mode 100644 | |||
| @@ -0,0 +1,80 @@ | |||
| 1 | +<script setup lang="ts"> | ||
| 2 | +import type { CatalogItem, CatalogItemType } from '@skilly/core' | ||
| 3 | + | ||
| 4 | +/** | ||
| 5 | + * Checkbox picker over the catalog, per type with search. | ||
| 6 | + * `locked` maps `type/name` to a label: those rows are shown checked and disabled (already provided elsewhere). | ||
| 7 | + */ | ||
| 8 | +const props = withDefaults(defineProps<{ | ||
| 9 | + items: CatalogItem[] | ||
| 10 | + types?: CatalogItemType[] | ||
| 11 | + locked?: Record<string, string> | ||
| 12 | + height?: string | ||
| 13 | +}>(), { types: () => CAT_TYPES, locked: () => ({}), height: 'h-72' }) | ||
| 14 | + | ||
| 15 | +const selected = defineModel<Partial<Record<CatalogItemType, string[]>>>({ required: true }) | ||
| 16 | + | ||
| 17 | +const tab = ref<CatalogItemType>(props.types[0] ?? 'skill') | ||
| 18 | +const search = ref('') | ||
| 19 | +const LIMIT = 200 | ||
| 20 | +const showAll = ref(false) | ||
| 21 | + | ||
| 22 | +watch([tab, search], () => { showAll.value = false }) | ||
| 23 | + | ||
| 24 | +const available = computed(() => props.items.filter(i => i.type === tab.value).sort((a, b) => a.name.localeCompare(b.name))) | ||
| 25 | +const filtered = computed(() => { | ||
| 26 | + const q = search.value.trim().toLowerCase() | ||
| 27 | + return q ? available.value.filter(i => `${i.name} ${i.description ?? ''} ${i.tags.join(' ')}`.toLowerCase().includes(q)) : available.value | ||
| 28 | +}) | ||
| 29 | +const shown = computed(() => (showAll.value ? filtered.value : filtered.value.slice(0, LIMIT))) | ||
| 30 | + | ||
| 31 | +const tabs = computed(() => props.types.map(t => ({ | ||
| 32 | + label: CAT_META[t].label, | ||
| 33 | + icon: CAT_META[t].icon, | ||
| 34 | + value: t, | ||
| 35 | + badge: { label: String(selected.value[t]?.length ?? 0), color: (selected.value[t]?.length ? 'primary' : 'neutral') as 'primary' | 'neutral', variant: 'subtle' as const } | ||
| 36 | +}))) | ||
| 37 | + | ||
| 38 | +const isOn = (name: string) => !!selected.value[tab.value]?.includes(name) | ||
| 39 | + | ||
| 40 | +function toggle(name: string, on: boolean | 'indeterminate') { | ||
| 41 | + const cur = new Set(selected.value[tab.value] ?? []) | ||
| 42 | + if (on === true) cur.add(name) | ||
| 43 | + else cur.delete(name) | ||
| 44 | + selected.value = { ...selected.value, [tab.value]: [...cur] } | ||
| 45 | +} | ||
| 46 | +</script> | ||
| 47 | + | ||
| 48 | +<template> | ||
| 49 | + <div class="space-y-3"> | ||
| 50 | + <UTabs v-model="tab" :items="tabs" :content="false" variant="link" size="sm" class="w-full" /> | ||
| 51 | + <UInput v-model="search" icon="i-lucide-search" size="sm" placeholder="Search..." class="w-full" /> | ||
| 52 | + <div class="overflow-y-auto rounded-lg border border-default" :class="height"> | ||
| 53 | + <ul v-if="shown.length" class="divide-y divide-default"> | ||
| 54 | + <li v-for="i in shown" :key="i.name" class="flex items-start gap-2 px-3 py-2 hover:bg-elevated/50"> | ||
| 55 | + <UCheckbox | ||
| 56 | + class="mt-0.5" | ||
| 57 | + :model-value="!!locked[itemKey(i.type, i.name)] || isOn(i.name)" | ||
| 58 | + :disabled="!!locked[itemKey(i.type, i.name)]" | ||
| 59 | + :aria-label="i.name" | ||
| 60 | + @update:model-value="(v) => toggle(i.name, v)" | ||
| 61 | + /> | ||
| 62 | + <div class="min-w-0 flex-1"> | ||
| 63 | + <div class="flex items-center gap-2"> | ||
| 64 | + <span class="truncate text-sm font-medium">{{ i.name }}</span> | ||
| 65 | + <UBadge v-if="locked[itemKey(i.type, i.name)]" :label="locked[itemKey(i.type, i.name)]" size="sm" color="neutral" variant="subtle" /> | ||
| 66 | + <UBadge v-for="t in i.tags.slice(0, 3)" :key="t" :label="t" size="sm" color="neutral" variant="outline" /> | ||
| 67 | + </div> | ||
| 68 | + <p v-if="i.description" class="line-clamp-1 text-xs text-muted" :title="i.description">{{ i.description }}</p> | ||
| 69 | + </div> | ||
| 70 | + </li> | ||
| 71 | + <li v-if="filtered.length > shown.length" class="p-2 text-center"> | ||
| 72 | + <UButton :label="`Show all ${filtered.length}`" size="xs" color="neutral" variant="link" @click="showAll = true" /> | ||
| 73 | + </li> | ||
| 74 | + </ul> | ||
| 75 | + <p v-else class="p-6 text-center text-sm text-muted"> | ||
| 76 | + {{ search ? 'No result.' : `No ${CAT_META[tab].label.toLowerCase()} in the catalog.` }} | ||
| 77 | + </p> | ||
| 78 | + </div> | ||
| 79 | + </div> | ||
| 80 | +</template> | ||
modified
packages/ui/app/components/ItemTable.vue +96 -34 | @@ -1,10 +1,25 @@ | ||
| 1 | 1 | <script setup lang="ts"> |
| 2 | 2 | import type { |
| 3 | - AgentDetails, HookDetails, InstructionsDetails, InventoryItem, ItemType, McpDetails, RuleDetails, SkillDetails | |
| 3 | + AgentDetails, CatalogRef, HookDetails, InstructionsDetails, InventoryItem, ItemStatus, ItemType, McpDetails, RuleDetails, SkillDetails | |
| 4 | 4 | } from '@skilly/core' |
| 5 | 5 | |
| 6 | -defineProps<{ type: ItemType, items: InventoryItem[], filtered: boolean }>() | |
| 7 | -defineEmits<{ select: [item: InventoryItem] }>() | |
| 6 | +const props = defineProps<{ | |
| 7 | + type: ItemType | |
| 8 | + items: InventoryItem[] | |
| 9 | + filtered: boolean | |
| 10 | + statuses?: Record<string, ItemStatus> | |
| 11 | + /** Show checkboxes and row actions (catalog import, remove from global). */ | |
| 12 | + actionable?: boolean | |
| 13 | + /** Home scope: enables "Remove from global" on skills, rules and agents. */ | |
| 14 | + home?: boolean | |
| 15 | +}>() | |
| 16 | +const selection = defineModel<string[]>('selection', { default: () => [] }) | |
| 17 | +const emit = defineEmits<{ | |
| 18 | + select: [item: InventoryItem] | |
| 19 | + addToCatalog: [item: InventoryItem] | |
| 20 | + openCatalog: [ref: CatalogRef] | |
| 21 | + removeGlobal: [item: InventoryItem] | |
| 22 | +}>() | |
| 8 | 23 | |
| 9 | 24 | const skill = (i: InventoryItem) => i.details as SkillDetails |
| 10 | 25 | const rule = (i: InventoryItem) => i.details as RuleDetails |
| @@ -20,6 +35,26 @@ const VISIBILITY = { | ||
| 20 | 35 | 'off': { label: 'off', color: 'neutral' } |
| 21 | 36 | } as const |
| 22 | 37 | |
| 38 | +const canImport = (i: InventoryItem) => { | |
| 39 | + const st = props.statuses?.[i.id]?.state | |
| 40 | + return !!props.actionable && i.type !== 'instructions' && (st === 'not-in-catalog' || st === 'differs-from-catalog') | |
| 41 | +} | |
| 42 | +const canRemoveGlobal = (i: InventoryItem) => !!props.actionable && !!props.home && ['skill', 'rule', 'agent'].includes(i.type) | |
| 43 | +const selectable = (i: InventoryItem) => canImport(i) || canRemoveGlobal(i) | |
| 44 | + | |
| 45 | +function toggleRow(id: string, on: boolean | 'indeterminate') { | |
| 46 | + selection.value = on === true ? [...new Set([...selection.value, id])] : selection.value.filter(x => x !== id) | |
| 47 | +} | |
| 48 | +const selectableIds = computed(() => props.items.filter(selectable).map(i => i.id)) | |
| 49 | +const allState = computed(() => { | |
| 50 | + const n = selectableIds.value.filter(id => selection.value.includes(id)).length | |
| 51 | + return n === 0 ? false : n === selectableIds.value.length ? true : 'indeterminate' as const | |
| 52 | +}) | |
| 53 | +function toggleAll(on: boolean | 'indeterminate') { | |
| 54 | + const rest = selection.value.filter(id => !selectableIds.value.includes(id)) | |
| 55 | + selection.value = on === true ? [...rest, ...selectableIds.value] : rest | |
| 56 | +} | |
| 57 | + | |
| 23 | 58 | const TH = 'px-3 py-2 text-left text-xs font-medium uppercase tracking-wide text-muted' |
| 24 | 59 | const TD = 'px-3 py-2 align-middle' |
| 25 | 60 | const MAX_ENV = 4 |
| @@ -36,26 +71,36 @@ const MAX_ENV = 4 | ||
| 36 | 71 | </p> |
| 37 | 72 | </div> |
| 38 | 73 | |
| 74 | + <!-- table-fixed with the same widths for the leading columns keeps columns aligned from one tab to the next --> | |
| 39 | 75 | <div v-else class="overflow-x-auto rounded-xl border border-default"> |
| 40 | - <table class="w-full min-w-[720px] text-sm"> | |
| 76 | + <table class="w-full min-w-[960px] table-fixed text-sm"> | |
| 41 | 77 | <thead class="border-b border-default bg-elevated/40"> |
| 42 | - <tr v-if="type === 'skill'"> | |
| 43 | - <th :class="TH">Name</th><th :class="TH">Agent</th><th :class="TH">Visibility</th><th :class="TH">Origin</th> | |
| 44 | - </tr> | |
| 45 | - <tr v-else-if="type === 'rule'"> | |
| 46 | - <th :class="TH">Name</th><th :class="TH">Agent</th><th :class="TH">Applies to</th> | |
| 47 | - </tr> | |
| 48 | - <tr v-else-if="type === 'mcp'"> | |
| 49 | - <th :class="TH">Name</th><th :class="TH">Agent</th><th :class="TH">Transport</th><th :class="TH">Command / URL</th><th :class="TH">Env vars</th><th :class="TH">Scope</th><th :class="TH">Status</th> | |
| 50 | - </tr> | |
| 51 | - <tr v-else-if="type === 'hook'"> | |
| 52 | - <th :class="TH">Event</th><th :class="TH">Agent</th><th :class="TH">Matcher</th><th :class="TH">Command</th><th :class="TH">Handler</th> | |
| 53 | - </tr> | |
| 54 | - <tr v-else-if="type === 'agent'"> | |
| 55 | - <th :class="TH">Name</th><th :class="TH">Agent</th><th :class="TH">Model</th> | |
| 56 | - </tr> | |
| 57 | - <tr v-else> | |
| 58 | - <th :class="TH">File</th><th :class="TH">Agent</th><th :class="TH">Size</th><th :class="TH">Status</th><th :class="TH">Imports</th> | |
| 78 | + <tr> | |
| 79 | + <th v-if="actionable" :class="TH" class="w-10"> | |
| 80 | + <UCheckbox v-if="selectableIds.length" :model-value="allState" aria-label="Select all" @update:model-value="toggleAll" /> | |
| 81 | + </th> | |
| 82 | + <th :class="TH" class="w-[26%]">{{ type === 'hook' ? 'Event' : type === 'instructions' ? 'File' : 'Name' }}</th> | |
| 83 | + <th :class="TH" class="w-16">Agent</th> | |
| 84 | + <th :class="TH" class="w-44">Status</th> | |
| 85 | + <template v-if="type === 'skill'"> | |
| 86 | + <th :class="TH" class="w-28">Visibility</th><th :class="TH">Origin</th> | |
| 87 | + </template> | |
| 88 | + <template v-else-if="type === 'rule'"> | |
| 89 | + <th :class="TH">Applies to</th> | |
| 90 | + </template> | |
| 91 | + <template v-else-if="type === 'mcp'"> | |
| 92 | + <th :class="TH" class="w-24">Transport</th><th :class="TH">Command / URL</th><th :class="TH">Env vars</th><th :class="TH" class="w-20">Scope</th><th :class="TH" class="w-24">State</th> | |
| 93 | + </template> | |
| 94 | + <template v-else-if="type === 'hook'"> | |
| 95 | + <th :class="TH" class="w-28">Matcher</th><th :class="TH">Command</th><th :class="TH" class="w-24">Handler</th> | |
| 96 | + </template> | |
| 97 | + <template v-else-if="type === 'agent'"> | |
| 98 | + <th :class="TH">Model</th> | |
| 99 | + </template> | |
| 100 | + <template v-else> | |
| 101 | + <th :class="TH" class="w-20">Size</th><th :class="TH" class="w-28">Loaded</th><th :class="TH">Imports</th> | |
| 102 | + </template> | |
| 103 | + <th v-if="actionable" :class="TH" class="w-28"><span class="sr-only">Actions</span></th> | |
| 59 | 104 | </tr> |
| 60 | 105 | </thead> |
| 61 | 106 | |
| @@ -68,21 +113,24 @@ const MAX_ENV = 4 | ||
| 68 | 113 | @click="$emit('select', item)" |
| 69 | 114 | @keydown.enter="$emit('select', item)" |
| 70 | 115 | > |
| 116 | + <td v-if="actionable" :class="TD" @click.stop> | |
| 117 | + <UCheckbox v-if="selectable(item)" :model-value="selection.includes(item.id)" :aria-label="`Select ${item.name}`" @update:model-value="(v) => toggleRow(item.id, v)" /> | |
| 118 | + </td> | |
| 119 | + | |
| 71 | 120 | <!-- Hooks lead with the event, others with the name --> |
| 72 | - <td :class="TD" class="max-w-md"> | |
| 73 | - <div class="flex items-start gap-2"> | |
| 74 | - <div class="min-w-0"> | |
| 75 | - <div class="flex items-center gap-1.5"> | |
| 76 | - <span class="truncate font-medium text-highlighted">{{ type === 'hook' ? hook(item).event : item.name }}</span> | |
| 77 | - <UTooltip v-if="item.warnings.length" :text="item.warnings.join('\n')"> | |
| 78 | - <UIcon name="i-lucide-triangle-alert" class="size-4 shrink-0 text-warning" /> | |
| 79 | - </UTooltip> | |
| 80 | - </div> | |
| 81 | - <p v-if="item.description && type !== 'hook'" class="line-clamp-1 text-xs text-muted" :title="item.description">{{ item.description }}</p> | |
| 121 | + <td :class="TD"> | |
| 122 | + <div class="min-w-0"> | |
| 123 | + <div class="flex items-center gap-1.5"> | |
| 124 | + <span class="truncate font-medium text-highlighted" :title="type === 'hook' ? hook(item).event : item.name">{{ type === 'hook' ? hook(item).event : item.name }}</span> | |
| 125 | + <UTooltip v-if="item.warnings.length" :text="item.warnings.join('\n')"> | |
| 126 | + <UIcon name="i-lucide-triangle-alert" class="size-4 shrink-0 text-warning" /> | |
| 127 | + </UTooltip> | |
| 82 | 128 | </div> |
| 129 | + <p v-if="item.description && type !== 'hook'" class="line-clamp-1 text-xs text-muted" :title="item.description">{{ item.description }}</p> | |
| 83 | 130 | </div> |
| 84 | 131 | </td> |
| 85 | 132 | <td :class="TD"><AgentBadge :agent="item.agent" compact /></td> |
| 133 | + <td :class="TD"><StatusBadge :status="statuses?.[item.id]" /></td> | |
| 86 | 134 | |
| 87 | 135 | <!-- skills --> |
| 88 | 136 | <template v-if="type === 'skill'"> |
| @@ -119,7 +167,7 @@ const MAX_ENV = 4 | ||
| 119 | 167 | <!-- mcp --> |
| 120 | 168 | <template v-else-if="type === 'mcp'"> |
| 121 | 169 | <td :class="TD"><UBadge :label="mcp(item).transport" color="neutral" variant="subtle" size="sm" /></td> |
| 122 | - <td :class="TD" class="max-w-xs"> | |
| 170 | + <td :class="TD"> | |
| 123 | 171 | <span class="block truncate font-mono text-xs" :title="mcp(item).url ?? [mcp(item).command, ...(mcp(item).args ?? [])].join(' ')"> |
| 124 | 172 | {{ mcp(item).url ?? [mcp(item).command, ...(mcp(item).args ?? [])].filter(Boolean).join(' ') }} |
| 125 | 173 | </span> |
| @@ -138,8 +186,8 @@ const MAX_ENV = 4 | ||
| 138 | 186 | |
| 139 | 187 | <!-- hooks --> |
| 140 | 188 | <template v-else-if="type === 'hook'"> |
| 141 | - <td :class="TD"><span class="font-mono text-xs">{{ hook(item).matcher ?? '*' }}</span></td> | |
| 142 | - <td :class="TD" class="max-w-sm"> | |
| 189 | + <td :class="TD"><span class="block truncate font-mono text-xs">{{ hook(item).matcher ?? '*' }}</span></td> | |
| 190 | + <td :class="TD"> | |
| 143 | 191 | <span class="block truncate font-mono text-xs" :title="hook(item).command">{{ hook(item).command ?? '-' }}</span> |
| 144 | 192 | </td> |
| 145 | 193 | <td :class="TD"><UBadge :label="hook(item).handlerType" color="neutral" variant="subtle" size="sm" /></td> |
| @@ -166,6 +214,20 @@ const MAX_ENV = 4 | ||
| 166 | 214 | </div> |
| 167 | 215 | </td> |
| 168 | 216 | </template> |
| 217 | + | |
| 218 | + <td v-if="actionable" :class="TD" @click.stop> | |
| 219 | + <div class="flex justify-end gap-0.5"> | |
| 220 | + <UTooltip v-if="canImport(item)" text="Add to catalog"> | |
| 221 | + <UButton icon="i-lucide-library" size="xs" color="neutral" variant="ghost" aria-label="Add to catalog" @click="emit('addToCatalog', item)" /> | |
| 222 | + </UTooltip> | |
| 223 | + <UTooltip v-if="statuses?.[item.id]?.catalogRef" text="Open in catalog"> | |
| 224 | + <UButton icon="i-lucide-external-link" size="xs" color="neutral" variant="ghost" aria-label="Open in catalog" @click="emit('openCatalog', statuses![item.id]!.catalogRef!)" /> | |
| 225 | + </UTooltip> | |
| 226 | + <UTooltip v-if="canRemoveGlobal(item)" text="Remove from global"> | |
| 227 | + <UButton icon="i-lucide-globe-lock" size="xs" color="neutral" variant="ghost" aria-label="Remove from global" @click="emit('removeGlobal', item)" /> | |
| 228 | + </UTooltip> | |
| 229 | + </div> | |
| 230 | + </td> | |
| 169 | 231 | </tr> |
| 170 | 232 | </tbody> |
| 171 | 233 | </table> |
| @@ -1,10 +1,25 @@ | |||
| 1 | <script setup lang="ts"> | 1 | <script setup lang="ts"> |
| 2 | import type { | 2 | import type { |
| 3 | - AgentDetails, HookDetails, InstructionsDetails, InventoryItem, ItemType, McpDetails, RuleDetails, SkillDetails | 3 | + AgentDetails, CatalogRef, HookDetails, InstructionsDetails, InventoryItem, ItemStatus, ItemType, McpDetails, RuleDetails, SkillDetails |
| 4 | } from '@skilly/core' | 4 | } from '@skilly/core' |
| 5 | 5 | ||
| 6 | -defineProps<{ type: ItemType, items: InventoryItem[], filtered: boolean }>() | 6 | +const props = defineProps<{ |
| 7 | -defineEmits<{ select: [item: InventoryItem] }>() | 7 | + type: ItemType |
| 8 | + items: InventoryItem[] | ||
| 9 | + filtered: boolean | ||
| 10 | + statuses?: Record<string, ItemStatus> | ||
| 11 | + /** Show checkboxes and row actions (catalog import, remove from global). */ | ||
| 12 | + actionable?: boolean | ||
| 13 | + /** Home scope: enables "Remove from global" on skills, rules and agents. */ | ||
| 14 | + home?: boolean | ||
| 15 | +}>() | ||
| 16 | +const selection = defineModel<string[]>('selection', { default: () => [] }) | ||
| 17 | +const emit = defineEmits<{ | ||
| 18 | + select: [item: InventoryItem] | ||
| 19 | + addToCatalog: [item: InventoryItem] | ||
| 20 | + openCatalog: [ref: CatalogRef] | ||
| 21 | + removeGlobal: [item: InventoryItem] | ||
| 22 | +}>() | ||
| 8 | 23 | ||
| 9 | const skill = (i: InventoryItem) => i.details as SkillDetails | 24 | const skill = (i: InventoryItem) => i.details as SkillDetails |
| 10 | const rule = (i: InventoryItem) => i.details as RuleDetails | 25 | const rule = (i: InventoryItem) => i.details as RuleDetails |
| @@ -20,6 +35,26 @@ const VISIBILITY = { | |||
| 20 | 'off': { label: 'off', color: 'neutral' } | 35 | 'off': { label: 'off', color: 'neutral' } |
| 21 | } as const | 36 | } as const |
| 22 | 37 | ||
| 38 | +const canImport = (i: InventoryItem) => { | ||
| 39 | + const st = props.statuses?.[i.id]?.state | ||
| 40 | + return !!props.actionable && i.type !== 'instructions' && (st === 'not-in-catalog' || st === 'differs-from-catalog') | ||
| 41 | +} | ||
| 42 | +const canRemoveGlobal = (i: InventoryItem) => !!props.actionable && !!props.home && ['skill', 'rule', 'agent'].includes(i.type) | ||
| 43 | +const selectable = (i: InventoryItem) => canImport(i) || canRemoveGlobal(i) | ||
| 44 | + | ||
| 45 | +function toggleRow(id: string, on: boolean | 'indeterminate') { | ||
| 46 | + selection.value = on === true ? [...new Set([...selection.value, id])] : selection.value.filter(x => x !== id) | ||
| 47 | +} | ||
| 48 | +const selectableIds = computed(() => props.items.filter(selectable).map(i => i.id)) | ||
| 49 | +const allState = computed(() => { | ||
| 50 | + const n = selectableIds.value.filter(id => selection.value.includes(id)).length | ||
| 51 | + return n === 0 ? false : n === selectableIds.value.length ? true : 'indeterminate' as const | ||
| 52 | +}) | ||
| 53 | +function toggleAll(on: boolean | 'indeterminate') { | ||
| 54 | + const rest = selection.value.filter(id => !selectableIds.value.includes(id)) | ||
| 55 | + selection.value = on === true ? [...rest, ...selectableIds.value] : rest | ||
| 56 | +} | ||
| 57 | + | ||
| 23 | const TH = 'px-3 py-2 text-left text-xs font-medium uppercase tracking-wide text-muted' | 58 | const TH = 'px-3 py-2 text-left text-xs font-medium uppercase tracking-wide text-muted' |
| 24 | const TD = 'px-3 py-2 align-middle' | 59 | const TD = 'px-3 py-2 align-middle' |
| 25 | const MAX_ENV = 4 | 60 | const MAX_ENV = 4 |
| @@ -36,26 +71,36 @@ const MAX_ENV = 4 | |||
| 36 | </p> | 71 | </p> |
| 37 | </div> | 72 | </div> |
| 38 | 73 | ||
| 74 | + <!-- table-fixed with the same widths for the leading columns keeps columns aligned from one tab to the next --> | ||
| 39 | <div v-else class="overflow-x-auto rounded-xl border border-default"> | 75 | <div v-else class="overflow-x-auto rounded-xl border border-default"> |
| 40 | - <table class="w-full min-w-[720px] text-sm"> | 76 | + <table class="w-full min-w-[960px] table-fixed text-sm"> |
| 41 | <thead class="border-b border-default bg-elevated/40"> | 77 | <thead class="border-b border-default bg-elevated/40"> |
| 42 | - <tr v-if="type === 'skill'"> | 78 | + <tr> |
| 43 | - <th :class="TH">Name</th><th :class="TH">Agent</th><th :class="TH">Visibility</th><th :class="TH">Origin</th> | 79 | + <th v-if="actionable" :class="TH" class="w-10"> |
| 44 | - </tr> | 80 | + <UCheckbox v-if="selectableIds.length" :model-value="allState" aria-label="Select all" @update:model-value="toggleAll" /> |
| 45 | - <tr v-else-if="type === 'rule'"> | 81 | + </th> |
| 46 | - <th :class="TH">Name</th><th :class="TH">Agent</th><th :class="TH">Applies to</th> | 82 | + <th :class="TH" class="w-[26%]">{{ type === 'hook' ? 'Event' : type === 'instructions' ? 'File' : 'Name' }}</th> |
| 47 | - </tr> | 83 | + <th :class="TH" class="w-16">Agent</th> |
| 48 | - <tr v-else-if="type === 'mcp'"> | 84 | + <th :class="TH" class="w-44">Status</th> |
| 49 | - <th :class="TH">Name</th><th :class="TH">Agent</th><th :class="TH">Transport</th><th :class="TH">Command / URL</th><th :class="TH">Env vars</th><th :class="TH">Scope</th><th :class="TH">Status</th> | 85 | + <template v-if="type === 'skill'"> |
| 50 | - </tr> | 86 | + <th :class="TH" class="w-28">Visibility</th><th :class="TH">Origin</th> |
| 51 | - <tr v-else-if="type === 'hook'"> | 87 | + </template> |
| 52 | - <th :class="TH">Event</th><th :class="TH">Agent</th><th :class="TH">Matcher</th><th :class="TH">Command</th><th :class="TH">Handler</th> | 88 | + <template v-else-if="type === 'rule'"> |
| 53 | - </tr> | 89 | + <th :class="TH">Applies to</th> |
| 54 | - <tr v-else-if="type === 'agent'"> | 90 | + </template> |
| 55 | - <th :class="TH">Name</th><th :class="TH">Agent</th><th :class="TH">Model</th> | 91 | + <template v-else-if="type === 'mcp'"> |
| 56 | - </tr> | 92 | + <th :class="TH" class="w-24">Transport</th><th :class="TH">Command / URL</th><th :class="TH">Env vars</th><th :class="TH" class="w-20">Scope</th><th :class="TH" class="w-24">State</th> |
| 57 | - <tr v-else> | 93 | + </template> |
| 58 | - <th :class="TH">File</th><th :class="TH">Agent</th><th :class="TH">Size</th><th :class="TH">Status</th><th :class="TH">Imports</th> | 94 | + <template v-else-if="type === 'hook'"> |
| 95 | + <th :class="TH" class="w-28">Matcher</th><th :class="TH">Command</th><th :class="TH" class="w-24">Handler</th> | ||
| 96 | + </template> | ||
| 97 | + <template v-else-if="type === 'agent'"> | ||
| 98 | + <th :class="TH">Model</th> | ||
| 99 | + </template> | ||
| 100 | + <template v-else> | ||
| 101 | + <th :class="TH" class="w-20">Size</th><th :class="TH" class="w-28">Loaded</th><th :class="TH">Imports</th> | ||
| 102 | + </template> | ||
| 103 | + <th v-if="actionable" :class="TH" class="w-28"><span class="sr-only">Actions</span></th> | ||
| 59 | </tr> | 104 | </tr> |
| 60 | </thead> | 105 | </thead> |
| 61 | 106 | ||
| @@ -68,21 +113,24 @@ const MAX_ENV = 4 | |||
| 68 | @click="$emit('select', item)" | 113 | @click="$emit('select', item)" |
| 69 | @keydown.enter="$emit('select', item)" | 114 | @keydown.enter="$emit('select', item)" |
| 70 | > | 115 | > |
| 116 | + <td v-if="actionable" :class="TD" @click.stop> | ||
| 117 | + <UCheckbox v-if="selectable(item)" :model-value="selection.includes(item.id)" :aria-label="`Select ${item.name}`" @update:model-value="(v) => toggleRow(item.id, v)" /> | ||
| 118 | + </td> | ||
| 119 | + | ||
| 71 | <!-- Hooks lead with the event, others with the name --> | 120 | <!-- Hooks lead with the event, others with the name --> |
| 72 | - <td :class="TD" class="max-w-md"> | 121 | + <td :class="TD"> |
| 73 | - <div class="flex items-start gap-2"> | 122 | + <div class="min-w-0"> |
| 74 | - <div class="min-w-0"> | 123 | + <div class="flex items-center gap-1.5"> |
| 75 | - <div class="flex items-center gap-1.5"> | 124 | + <span class="truncate font-medium text-highlighted" :title="type === 'hook' ? hook(item).event : item.name">{{ type === 'hook' ? hook(item).event : item.name }}</span> |
| 76 | - <span class="truncate font-medium text-highlighted">{{ type === 'hook' ? hook(item).event : item.name }}</span> | 125 | + <UTooltip v-if="item.warnings.length" :text="item.warnings.join('\n')"> |
| 77 | - <UTooltip v-if="item.warnings.length" :text="item.warnings.join('\n')"> | 126 | + <UIcon name="i-lucide-triangle-alert" class="size-4 shrink-0 text-warning" /> |
| 78 | - <UIcon name="i-lucide-triangle-alert" class="size-4 shrink-0 text-warning" /> | 127 | + </UTooltip> |
| 79 | - </UTooltip> | ||
| 80 | - </div> | ||
| 81 | - <p v-if="item.description && type !== 'hook'" class="line-clamp-1 text-xs text-muted" :title="item.description">{{ item.description }}</p> | ||
| 82 | </div> | 128 | </div> |
| 129 | + <p v-if="item.description && type !== 'hook'" class="line-clamp-1 text-xs text-muted" :title="item.description">{{ item.description }}</p> | ||
| 83 | </div> | 130 | </div> |
| 84 | </td> | 131 | </td> |
| 85 | <td :class="TD"><AgentBadge :agent="item.agent" compact /></td> | 132 | <td :class="TD"><AgentBadge :agent="item.agent" compact /></td> |
| 133 | + <td :class="TD"><StatusBadge :status="statuses?.[item.id]" /></td> | ||
| 86 | 134 | ||
| 87 | <!-- skills --> | 135 | <!-- skills --> |
| 88 | <template v-if="type === 'skill'"> | 136 | <template v-if="type === 'skill'"> |
| @@ -119,7 +167,7 @@ const MAX_ENV = 4 | |||
| 119 | <!-- mcp --> | 167 | <!-- mcp --> |
| 120 | <template v-else-if="type === 'mcp'"> | 168 | <template v-else-if="type === 'mcp'"> |
| 121 | <td :class="TD"><UBadge :label="mcp(item).transport" color="neutral" variant="subtle" size="sm" /></td> | 169 | <td :class="TD"><UBadge :label="mcp(item).transport" color="neutral" variant="subtle" size="sm" /></td> |
| 122 | - <td :class="TD" class="max-w-xs"> | 170 | + <td :class="TD"> |
| 123 | <span class="block truncate font-mono text-xs" :title="mcp(item).url ?? [mcp(item).command, ...(mcp(item).args ?? [])].join(' ')"> | 171 | <span class="block truncate font-mono text-xs" :title="mcp(item).url ?? [mcp(item).command, ...(mcp(item).args ?? [])].join(' ')"> |
| 124 | {{ mcp(item).url ?? [mcp(item).command, ...(mcp(item).args ?? [])].filter(Boolean).join(' ') }} | 172 | {{ mcp(item).url ?? [mcp(item).command, ...(mcp(item).args ?? [])].filter(Boolean).join(' ') }} |
| 125 | </span> | 173 | </span> |
| @@ -138,8 +186,8 @@ const MAX_ENV = 4 | |||
| 138 | 186 | ||
| 139 | <!-- hooks --> | 187 | <!-- hooks --> |
| 140 | <template v-else-if="type === 'hook'"> | 188 | <template v-else-if="type === 'hook'"> |
| 141 | - <td :class="TD"><span class="font-mono text-xs">{{ hook(item).matcher ?? '*' }}</span></td> | 189 | + <td :class="TD"><span class="block truncate font-mono text-xs">{{ hook(item).matcher ?? '*' }}</span></td> |
| 142 | - <td :class="TD" class="max-w-sm"> | 190 | + <td :class="TD"> |
| 143 | <span class="block truncate font-mono text-xs" :title="hook(item).command">{{ hook(item).command ?? '-' }}</span> | 191 | <span class="block truncate font-mono text-xs" :title="hook(item).command">{{ hook(item).command ?? '-' }}</span> |
| 144 | </td> | 192 | </td> |
| 145 | <td :class="TD"><UBadge :label="hook(item).handlerType" color="neutral" variant="subtle" size="sm" /></td> | 193 | <td :class="TD"><UBadge :label="hook(item).handlerType" color="neutral" variant="subtle" size="sm" /></td> |
| @@ -166,6 +214,20 @@ const MAX_ENV = 4 | |||
| 166 | </div> | 214 | </div> |
| 167 | </td> | 215 | </td> |
| 168 | </template> | 216 | </template> |
| 217 | + | ||
| 218 | + <td v-if="actionable" :class="TD" @click.stop> | ||
| 219 | + <div class="flex justify-end gap-0.5"> | ||
| 220 | + <UTooltip v-if="canImport(item)" text="Add to catalog"> | ||
| 221 | + <UButton icon="i-lucide-library" size="xs" color="neutral" variant="ghost" aria-label="Add to catalog" @click="emit('addToCatalog', item)" /> | ||
| 222 | + </UTooltip> | ||
| 223 | + <UTooltip v-if="statuses?.[item.id]?.catalogRef" text="Open in catalog"> | ||
| 224 | + <UButton icon="i-lucide-external-link" size="xs" color="neutral" variant="ghost" aria-label="Open in catalog" @click="emit('openCatalog', statuses![item.id]!.catalogRef!)" /> | ||
| 225 | + </UTooltip> | ||
| 226 | + <UTooltip v-if="canRemoveGlobal(item)" text="Remove from global"> | ||
| 227 | + <UButton icon="i-lucide-globe-lock" size="xs" color="neutral" variant="ghost" aria-label="Remove from global" @click="emit('removeGlobal', item)" /> | ||
| 228 | + </UTooltip> | ||
| 229 | + </div> | ||
| 230 | + </td> | ||
| 169 | </tr> | 231 | </tr> |
| 170 | </tbody> | 232 | </tbody> |
| 171 | </table> | 233 | </table> |
added
packages/ui/app/components/McpForm.vue +60 -0 | new file mode 100644 | ||
| @@ -0,0 +1,60 @@ | ||
| 1 | +<script setup lang="ts"> | |
| 2 | +import type { McpTemplate } from '@skilly/core' | |
| 3 | + | |
| 4 | +/** Form for an MCP server template. The parent owns the object (v-model) and renders the YAML. */ | |
| 5 | +const t = defineModel<McpTemplate>({ required: true }) | |
| 6 | + | |
| 7 | +const TRANSPORTS = [ | |
| 8 | + { label: 'stdio (local command)', value: 'stdio' }, | |
| 9 | + { label: 'http (remote URL)', value: 'http' } | |
| 10 | +] | |
| 11 | + | |
| 12 | +const args = computed({ | |
| 13 | + get: () => t.value.args ?? [], | |
| 14 | + set: (v: string[]) => { t.value = { ...t.value, args: v } } | |
| 15 | +}) | |
| 16 | +const bearer = computed({ | |
| 17 | + get: () => t.value.auth?.bearerFromEnv ?? '', | |
| 18 | + set: (v: string) => { t.value = { ...t.value, auth: v ? { bearerFromEnv: v } : undefined } } | |
| 19 | +}) | |
| 20 | + | |
| 21 | +function patch(p: Partial<McpTemplate>) { | |
| 22 | + t.value = { ...t.value, ...p } | |
| 23 | +} | |
| 24 | +</script> | |
| 25 | + | |
| 26 | +<template> | |
| 27 | + <div class="space-y-4"> | |
| 28 | + <UFormField label="Description"> | |
| 29 | + <UInput :model-value="t.description ?? ''" class="w-full" placeholder="What this server provides" @update:model-value="(v: string) => patch({ description: v })" /> | |
| 30 | + </UFormField> | |
| 31 | + | |
| 32 | + <UFormField label="Transport"> | |
| 33 | + <USelect :model-value="t.transport" :items="TRANSPORTS" class="w-64" @update:model-value="(v: string) => patch({ transport: v as 'stdio' | 'http' })" /> | |
| 34 | + </UFormField> | |
| 35 | + | |
| 36 | + <template v-if="t.transport === 'stdio'"> | |
| 37 | + <UFormField label="Command" required> | |
| 38 | + <UInput :model-value="t.command ?? ''" class="w-full" placeholder="npx" :ui="{ base: 'font-mono' }" @update:model-value="(v: string) => patch({ command: v })" /> | |
| 39 | + </UFormField> | |
| 40 | + <UFormField label="Arguments" description="Press Enter to add each argument."> | |
| 41 | + <UInputTags v-model="args" class="w-full" placeholder="-y, @upstash/context7-mcp" /> | |
| 42 | + </UFormField> | |
| 43 | + <UFormField label="Environment variables" description="Variables passed to the server process."> | |
| 44 | + <EnvRows :model-value="t.env" key-label="Variable name" key-placeholder="API_KEY" @update:model-value="(v) => patch({ env: v })" /> | |
| 45 | + </UFormField> | |
| 46 | + </template> | |
| 47 | + | |
| 48 | + <template v-else> | |
| 49 | + <UFormField label="URL" required> | |
| 50 | + <UInput :model-value="t.url ?? ''" class="w-full" placeholder="https://example.com/mcp" :ui="{ base: 'font-mono' }" @update:model-value="(v: string) => patch({ url: v })" /> | |
| 51 | + </UFormField> | |
| 52 | + <UFormField label="Bearer token env var" description="Name of the environment variable holding the token (never the token itself)."> | |
| 53 | + <UInput v-model="bearer" class="w-72" placeholder="GITHUB_TOKEN" :ui="{ base: 'font-mono' }" /> | |
| 54 | + </UFormField> | |
| 55 | + <UFormField label="Headers"> | |
| 56 | + <EnvRows :model-value="t.headers" key-label="Header name" key-placeholder="X-Api-Key" @update:model-value="(v) => patch({ headers: v })" /> | |
| 57 | + </UFormField> | |
| 58 | + </template> | |
| 59 | + </div> | |
| 60 | +</template> | |
| new file mode 100644 | |||
| @@ -0,0 +1,60 @@ | |||
| 1 | +<script setup lang="ts"> | ||
| 2 | +import type { McpTemplate } from '@skilly/core' | ||
| 3 | + | ||
| 4 | +/** Form for an MCP server template. The parent owns the object (v-model) and renders the YAML. */ | ||
| 5 | +const t = defineModel<McpTemplate>({ required: true }) | ||
| 6 | + | ||
| 7 | +const TRANSPORTS = [ | ||
| 8 | + { label: 'stdio (local command)', value: 'stdio' }, | ||
| 9 | + { label: 'http (remote URL)', value: 'http' } | ||
| 10 | +] | ||
| 11 | + | ||
| 12 | +const args = computed({ | ||
| 13 | + get: () => t.value.args ?? [], | ||
| 14 | + set: (v: string[]) => { t.value = { ...t.value, args: v } } | ||
| 15 | +}) | ||
| 16 | +const bearer = computed({ | ||
| 17 | + get: () => t.value.auth?.bearerFromEnv ?? '', | ||
| 18 | + set: (v: string) => { t.value = { ...t.value, auth: v ? { bearerFromEnv: v } : undefined } } | ||
| 19 | +}) | ||
| 20 | + | ||
| 21 | +function patch(p: Partial<McpTemplate>) { | ||
| 22 | + t.value = { ...t.value, ...p } | ||
| 23 | +} | ||
| 24 | +</script> | ||
| 25 | + | ||
| 26 | +<template> | ||
| 27 | + <div class="space-y-4"> | ||
| 28 | + <UFormField label="Description"> | ||
| 29 | + <UInput :model-value="t.description ?? ''" class="w-full" placeholder="What this server provides" @update:model-value="(v: string) => patch({ description: v })" /> | ||
| 30 | + </UFormField> | ||
| 31 | + | ||
| 32 | + <UFormField label="Transport"> | ||
| 33 | + <USelect :model-value="t.transport" :items="TRANSPORTS" class="w-64" @update:model-value="(v: string) => patch({ transport: v as 'stdio' | 'http' })" /> | ||
| 34 | + </UFormField> | ||
| 35 | + | ||
| 36 | + <template v-if="t.transport === 'stdio'"> | ||
| 37 | + <UFormField label="Command" required> | ||
| 38 | + <UInput :model-value="t.command ?? ''" class="w-full" placeholder="npx" :ui="{ base: 'font-mono' }" @update:model-value="(v: string) => patch({ command: v })" /> | ||
| 39 | + </UFormField> | ||
| 40 | + <UFormField label="Arguments" description="Press Enter to add each argument."> | ||
| 41 | + <UInputTags v-model="args" class="w-full" placeholder="-y, @upstash/context7-mcp" /> | ||
| 42 | + </UFormField> | ||
| 43 | + <UFormField label="Environment variables" description="Variables passed to the server process."> | ||
| 44 | + <EnvRows :model-value="t.env" key-label="Variable name" key-placeholder="API_KEY" @update:model-value="(v) => patch({ env: v })" /> | ||
| 45 | + </UFormField> | ||
| 46 | + </template> | ||
| 47 | + | ||
| 48 | + <template v-else> | ||
| 49 | + <UFormField label="URL" required> | ||
| 50 | + <UInput :model-value="t.url ?? ''" class="w-full" placeholder="https://example.com/mcp" :ui="{ base: 'font-mono' }" @update:model-value="(v: string) => patch({ url: v })" /> | ||
| 51 | + </UFormField> | ||
| 52 | + <UFormField label="Bearer token env var" description="Name of the environment variable holding the token (never the token itself)."> | ||
| 53 | + <UInput v-model="bearer" class="w-72" placeholder="GITHUB_TOKEN" :ui="{ base: 'font-mono' }" /> | ||
| 54 | + </UFormField> | ||
| 55 | + <UFormField label="Headers"> | ||
| 56 | + <EnvRows :model-value="t.headers" key-label="Header name" key-placeholder="X-Api-Key" @update:model-value="(v) => patch({ headers: v })" /> | ||
| 57 | + </UFormField> | ||
| 58 | + </template> | ||
| 59 | + </div> | ||
| 60 | +</template> | ||
added
packages/ui/app/components/PresetEditor.vue +225 -0 | new file mode 100644 | ||
| @@ -0,0 +1,225 @@ | ||
| 1 | +<script setup lang="ts"> | |
| 2 | +import type { CatalogItemType, Preset } from '@skilly/core' | |
| 3 | + | |
| 4 | +/** Preset editor. `name` is undefined for a new preset. */ | |
| 5 | +const props = defineProps<{ name?: string }>() | |
| 6 | + | |
| 7 | +const { request } = useApi() | |
| 8 | +const catalog = useCatalog() | |
| 9 | +const scopeNames = useScopeNames() | |
| 10 | +const toast = useToast() | |
| 11 | + | |
| 12 | +const isNew = computed(() => !props.name) | |
| 13 | +const ready = ref(false) | |
| 14 | +const form = reactive({ | |
| 15 | + name: props.name ?? '', | |
| 16 | + description: '', | |
| 17 | + includes: [] as string[], | |
| 18 | + items: {} as Partial<Record<CatalogItemType, string[]>> | |
| 19 | +}) | |
| 20 | +const original = ref('') | |
| 21 | +const saving = ref(false) | |
| 22 | +const saveError = ref<string | null>(null) | |
| 23 | +const notFound = ref(false) | |
| 24 | + | |
| 25 | +const snapshot = () => JSON.stringify([form.name, form.description, form.includes, form.items]) | |
| 26 | +const dirty = computed(() => snapshot() !== original.value) | |
| 27 | + | |
| 28 | +async function init() { | |
| 29 | + if (!catalog.data.value) await catalog.refresh() | |
| 30 | + if (props.name) { | |
| 31 | + const p = catalog.presets.value.find(x => x.name === props.name) | |
| 32 | + if (!p) notFound.value = true | |
| 33 | + else { | |
| 34 | + form.description = p.description ?? '' | |
| 35 | + form.includes = [...p.includes] | |
| 36 | + form.items = structuredClone(toRaw(p.items)) | |
| 37 | + } | |
| 38 | + } | |
| 39 | + original.value = snapshot() | |
| 40 | + ready.value = true | |
| 41 | +} | |
| 42 | +onMounted(init) | |
| 43 | + | |
| 44 | +const includeChoices = computed(() => catalog.presets.value.map(p => p.name).filter(n => n !== props.name && n !== form.name)) | |
| 45 | +const renaming = computed(() => !isNew.value && form.name !== props.name) | |
| 46 | + | |
| 47 | +const problems = computed(() => { | |
| 48 | + const p: string[] = [] | |
| 49 | + if (!NAME_RE.test(form.name)) p.push('Name must be kebab-case: lowercase letters, digits, "." "_" "-".') | |
| 50 | + else if ((isNew.value || renaming.value) && catalog.presets.value.some(x => x.name === form.name)) p.push(`A preset named "${form.name}" already exists.`) | |
| 51 | + return p | |
| 52 | +}) | |
| 53 | + | |
| 54 | +const resolved = computed(() => resolveItems( | |
| 55 | + catalog.presets.value.filter(p => p.name !== props.name), | |
| 56 | + [], | |
| 57 | + {}, | |
| 58 | + { name: form.name || props.name || '(this preset)', includes: form.includes, items: form.items } | |
| 59 | +)) | |
| 60 | +const resolvedByType = computed(() => CAT_TYPES.map(t => ({ | |
| 61 | + type: t, | |
| 62 | + rows: resolved.value.entries.filter(e => e.type === t).sort((a, b) => a.name.localeCompare(b.name)) | |
| 63 | +})).filter(g => g.rows.length)) | |
| 64 | +const selfName = computed(() => form.name || props.name || '(this preset)') | |
| 65 | +const missingItems = (t: CatalogItemType, n: string) => !catalog.findItem(t, n) | |
| 66 | + | |
| 67 | +async function save() { | |
| 68 | + if (problems.value.length) return | |
| 69 | + saving.value = true | |
| 70 | + saveError.value = null | |
| 71 | + const items: Partial<Record<CatalogItemType, string[]>> = {} | |
| 72 | + for (const t of CAT_TYPES) if (form.items[t]?.length) items[t] = [...form.items[t]!].sort() | |
| 73 | + const body: Preset & { previousName?: string } = { | |
| 74 | + name: form.name, | |
| 75 | + description: form.description || undefined, | |
| 76 | + includes: form.includes, | |
| 77 | + items, | |
| 78 | + ...(renaming.value ? { previousName: props.name } : {}) | |
| 79 | + } | |
| 80 | + try { | |
| 81 | + await request(`/api/catalog/presets/${encodeURIComponent(form.name)}`, { method: 'PUT', body, silent: true }) | |
| 82 | + toast.add({ title: isNew.value ? 'Preset created' : 'Preset saved', description: form.name, icon: 'i-lucide-check', color: 'success' }) | |
| 83 | + await catalog.refresh() | |
| 84 | + original.value = snapshot() | |
| 85 | + if (isNew.value || renaming.value) await navigateTo(`/presets/${encodeURIComponent(form.name)}`, { replace: true }) | |
| 86 | + } catch (e) { | |
| 87 | + saveError.value = (e as Error).message | |
| 88 | + } finally { | |
| 89 | + saving.value = false | |
| 90 | + } | |
| 91 | +} | |
| 92 | + | |
| 93 | +onBeforeRouteLeave(() => !dirty.value || window.confirm('Discard unsaved changes?')) | |
| 94 | + | |
| 95 | +const usedBy = computed(() => (props.name ? catalog.presetUsedBy(props.name) : [])) | |
| 96 | +const delOpen = ref(false) | |
| 97 | +const deleting = ref(false) | |
| 98 | +const delConflict = ref<string | null>(null) | |
| 99 | + | |
| 100 | +async function doDelete(force = false) { | |
| 101 | + deleting.value = true | |
| 102 | + try { | |
| 103 | + await request(`/api/catalog/presets/${encodeURIComponent(props.name!)}`, { method: 'DELETE', query: force ? { force: '1' } : undefined, silent: true }) | |
| 104 | + original.value = snapshot() | |
| 105 | + toast.add({ title: 'Preset deleted', description: props.name, icon: 'i-lucide-check', color: 'success' }) | |
| 106 | + delOpen.value = false | |
| 107 | + await catalog.refresh() | |
| 108 | + await navigateTo('/presets') | |
| 109 | + } catch (e) { | |
| 110 | + const err = e as { status?: number, message: string } | |
| 111 | + if (err.status === 409) delConflict.value = err.message | |
| 112 | + else toast.add({ title: 'Delete failed', description: err.message, color: 'error', icon: 'i-lucide-circle-alert' }) | |
| 113 | + } finally { | |
| 114 | + deleting.value = false | |
| 115 | + } | |
| 116 | +} | |
| 117 | +function askDelete() { | |
| 118 | + delConflict.value = null | |
| 119 | + delOpen.value = true | |
| 120 | +} | |
| 121 | +</script> | |
| 122 | + | |
| 123 | +<template> | |
| 124 | + <UDashboardPanel id="preset-editor"> | |
| 125 | + <template #header> | |
| 126 | + <UDashboardNavbar icon="i-lucide-layers"> | |
| 127 | + <template #title> | |
| 128 | + <NuxtLink to="/presets" class="text-muted hover:text-default">Presets</NuxtLink> | |
| 129 | + <UIcon name="i-lucide-chevron-right" class="size-4 text-dimmed" /> | |
| 130 | + <span class="truncate">{{ isNew ? 'New' : name }}</span> | |
| 131 | + </template> | |
| 132 | + <template #right> | |
| 133 | + <UBadge v-if="dirty" label="Unsaved" color="warning" variant="subtle" /> | |
| 134 | + <UButton v-if="!isNew" label="Delete" icon="i-lucide-trash-2" color="error" variant="soft" @click="askDelete" /> | |
| 135 | + <UButton label="Save" icon="i-lucide-save" :loading="saving" :disabled="!dirty || !!problems.length" @click="save" /> | |
| 136 | + </template> | |
| 137 | + </UDashboardNavbar> | |
| 138 | + </template> | |
| 139 | + | |
| 140 | + <template #body> | |
| 141 | + <USkeleton v-if="!ready" class="h-72 w-full" /> | |
| 142 | + <UAlert v-else-if="notFound" color="warning" variant="subtle" icon="i-lucide-circle-help" title="Preset not found" description="It may have been renamed or deleted." :actions="[{ label: 'Back to presets', to: '/presets', color: 'neutral', variant: 'outline' }]" /> | |
| 143 | + | |
| 144 | + <div v-else class="grid items-start gap-6 xl:grid-cols-2"> | |
| 145 | + <div class="min-w-0 space-y-4"> | |
| 146 | + <UAlert v-if="saveError" color="error" variant="subtle" icon="i-lucide-circle-alert" title="Save failed" :description="saveError" class="whitespace-pre-wrap" /> | |
| 147 | + <div class="grid gap-4 sm:grid-cols-2"> | |
| 148 | + <UFormField :label="renaming ? 'Name (renaming)' : 'Name'" required> | |
| 149 | + <UInput v-model="form.name" class="w-full" placeholder="kebab-case-name" :ui="{ base: 'font-mono' }" /> | |
| 150 | + </UFormField> | |
| 151 | + <UFormField label="Description"> | |
| 152 | + <UInput v-model="form.description" class="w-full" /> | |
| 153 | + </UFormField> | |
| 154 | + </div> | |
| 155 | + <UAlert v-if="problems.length && (dirty || !isNew)" color="warning" variant="subtle" icon="i-lucide-triangle-alert" :title="problems.join(' ')" /> | |
| 156 | + | |
| 157 | + <UFormField label="Included presets" description="Their items are added to this preset (resolved recursively)."> | |
| 158 | + <USelectMenu v-model="form.includes" :items="includeChoices" multiple placeholder="None" class="w-full" /> | |
| 159 | + </UFormField> | |
| 160 | + | |
| 161 | + <div class="space-y-2"> | |
| 162 | + <h3 class="text-sm font-semibold">Items</h3> | |
| 163 | + <ItemPicker v-model="form.items" :items="catalog.items.value" /> | |
| 164 | + </div> | |
| 165 | + </div> | |
| 166 | + | |
| 167 | + <div class="min-w-0 space-y-4"> | |
| 168 | + <section class="space-y-2"> | |
| 169 | + <h3 class="text-sm font-semibold">Resolved content <span class="font-normal text-muted">({{ resolved.entries.length }} items)</span></h3> | |
| 170 | + <UAlert v-if="resolved.missingPresets.length" color="warning" variant="subtle" icon="i-lucide-triangle-alert" :title="`Unknown preset(s): ${resolved.missingPresets.join(', ')}`" /> | |
| 171 | + <UAlert v-if="resolved.cycles.length" color="warning" variant="subtle" icon="i-lucide-repeat" :title="`Include cycle ignored: ${resolved.cycles.join(', ')}`" /> | |
| 172 | + <p v-if="!resolvedByType.length" class="rounded-lg border border-dashed border-default p-6 text-center text-sm text-muted">Nothing selected yet.</p> | |
| 173 | + <div v-for="g in resolvedByType" :key="g.type" class="overflow-hidden rounded-lg border border-default"> | |
| 174 | + <div class="flex items-center gap-2 bg-elevated/40 px-3 py-1.5 text-xs font-medium text-muted"> | |
| 175 | + <UIcon :name="CAT_META[g.type].icon" class="size-3.5" />{{ CAT_META[g.type].label }} ({{ g.rows.length }}) | |
| 176 | + </div> | |
| 177 | + <ul class="divide-y divide-default text-sm"> | |
| 178 | + <li v-for="r in g.rows" :key="r.name" class="flex items-center gap-2 px-3 py-1.5"> | |
| 179 | + <NuxtLink v-if="!missingItems(r.type, r.name)" :to="`/catalog/${r.type}/${encodeURIComponent(r.name)}`" class="truncate hover:underline">{{ r.name }}</NuxtLink> | |
| 180 | + <span v-else class="truncate">{{ r.name }}</span> | |
| 181 | + <UBadge v-if="missingItems(r.type, r.name)" label="missing" size="sm" color="warning" variant="subtle" /> | |
| 182 | + <span class="ml-auto flex shrink-0 gap-1"> | |
| 183 | + <UBadge v-for="v in r.via.filter(v => v !== selfName)" :key="v" :label="`via ${v}`" size="sm" color="neutral" variant="subtle" /> | |
| 184 | + </span> | |
| 185 | + </li> | |
| 186 | + </ul> | |
| 187 | + </div> | |
| 188 | + </section> | |
| 189 | + | |
| 190 | + <section v-if="!isNew" class="space-y-1.5"> | |
| 191 | + <h3 class="text-sm font-semibold">Used by</h3> | |
| 192 | + <ul v-if="usedBy.length" class="space-y-1 text-sm"> | |
| 193 | + <li v-for="s in usedBy" :key="s"> | |
| 194 | + <NuxtLink :to="scopeLink(s)" class="flex items-center gap-1.5 text-primary hover:underline"> | |
| 195 | + <UIcon :name="s === 'home' ? 'i-lucide-house' : 'i-lucide-folder'" class="size-4" />{{ scopeLabel(s, scopeNames) }} | |
| 196 | + </NuxtLink> | |
| 197 | + </li> | |
| 198 | + </ul> | |
| 199 | + <p v-else class="text-xs text-muted">Not enabled in any scope.</p> | |
| 200 | + </section> | |
| 201 | + </div> | |
| 202 | + </div> | |
| 203 | + | |
| 204 | + <UModal v-model:open="delOpen" :title="`Delete ${name}`" description="This removes the preset from the catalog."> | |
| 205 | + <template #body> | |
| 206 | + <div class="space-y-3 text-sm"> | |
| 207 | + <p>Items of the preset are kept in the catalog. Scopes that enabled it will report the preset as missing.</p> | |
| 208 | + <UAlert v-if="delConflict || usedBy.length" color="warning" variant="subtle" icon="i-lucide-triangle-alert" :title="delConflict ? 'This preset is used' : 'Used by'"> | |
| 209 | + <template #description> | |
| 210 | + <p v-if="delConflict" class="mb-1">{{ delConflict }}</p> | |
| 211 | + <ul class="list-disc pl-4"><li v-for="s in usedBy" :key="s">{{ scopeLabel(s, scopeNames) }}</li></ul> | |
| 212 | + </template> | |
| 213 | + </UAlert> | |
| 214 | + </div> | |
| 215 | + </template> | |
| 216 | + <template #footer> | |
| 217 | + <div class="flex w-full justify-end gap-2"> | |
| 218 | + <UButton label="Cancel" color="neutral" variant="outline" @click="delOpen = false" /> | |
| 219 | + <UButton :label="delConflict ? 'Delete anyway' : 'Delete'" color="error" :loading="deleting" @click="doDelete(!!delConflict)" /> | |
| 220 | + </div> | |
| 221 | + </template> | |
| 222 | + </UModal> | |
| 223 | + </template> | |
| 224 | + </UDashboardPanel> | |
| 225 | +</template> | |
| new file mode 100644 | |||
| @@ -0,0 +1,225 @@ | |||
| 1 | +<script setup lang="ts"> | ||
| 2 | +import type { CatalogItemType, Preset } from '@skilly/core' | ||
| 3 | + | ||
| 4 | +/** Preset editor. `name` is undefined for a new preset. */ | ||
| 5 | +const props = defineProps<{ name?: string }>() | ||
| 6 | + | ||
| 7 | +const { request } = useApi() | ||
| 8 | +const catalog = useCatalog() | ||
| 9 | +const scopeNames = useScopeNames() | ||
| 10 | +const toast = useToast() | ||
| 11 | + | ||
| 12 | +const isNew = computed(() => !props.name) | ||
| 13 | +const ready = ref(false) | ||
| 14 | +const form = reactive({ | ||
| 15 | + name: props.name ?? '', | ||
| 16 | + description: '', | ||
| 17 | + includes: [] as string[], | ||
| 18 | + items: {} as Partial<Record<CatalogItemType, string[]>> | ||
| 19 | +}) | ||
| 20 | +const original = ref('') | ||
| 21 | +const saving = ref(false) | ||
| 22 | +const saveError = ref<string | null>(null) | ||
| 23 | +const notFound = ref(false) | ||
| 24 | + | ||
| 25 | +const snapshot = () => JSON.stringify([form.name, form.description, form.includes, form.items]) | ||
| 26 | +const dirty = computed(() => snapshot() !== original.value) | ||
| 27 | + | ||
| 28 | +async function init() { | ||
| 29 | + if (!catalog.data.value) await catalog.refresh() | ||
| 30 | + if (props.name) { | ||
| 31 | + const p = catalog.presets.value.find(x => x.name === props.name) | ||
| 32 | + if (!p) notFound.value = true | ||
| 33 | + else { | ||
| 34 | + form.description = p.description ?? '' | ||
| 35 | + form.includes = [...p.includes] | ||
| 36 | + form.items = structuredClone(toRaw(p.items)) | ||
| 37 | + } | ||
| 38 | + } | ||
| 39 | + original.value = snapshot() | ||
| 40 | + ready.value = true | ||
| 41 | +} | ||
| 42 | +onMounted(init) | ||
| 43 | + | ||
| 44 | +const includeChoices = computed(() => catalog.presets.value.map(p => p.name).filter(n => n !== props.name && n !== form.name)) | ||
| 45 | +const renaming = computed(() => !isNew.value && form.name !== props.name) | ||
| 46 | + | ||
| 47 | +const problems = computed(() => { | ||
| 48 | + const p: string[] = [] | ||
| 49 | + if (!NAME_RE.test(form.name)) p.push('Name must be kebab-case: lowercase letters, digits, "." "_" "-".') | ||
| 50 | + else if ((isNew.value || renaming.value) && catalog.presets.value.some(x => x.name === form.name)) p.push(`A preset named "${form.name}" already exists.`) | ||
| 51 | + return p | ||
| 52 | +}) | ||
| 53 | + | ||
| 54 | +const resolved = computed(() => resolveItems( | ||
| 55 | + catalog.presets.value.filter(p => p.name !== props.name), | ||
| 56 | + [], | ||
| 57 | + {}, | ||
| 58 | + { name: form.name || props.name || '(this preset)', includes: form.includes, items: form.items } | ||
| 59 | +)) | ||
| 60 | +const resolvedByType = computed(() => CAT_TYPES.map(t => ({ | ||
| 61 | + type: t, | ||
| 62 | + rows: resolved.value.entries.filter(e => e.type === t).sort((a, b) => a.name.localeCompare(b.name)) | ||
| 63 | +})).filter(g => g.rows.length)) | ||
| 64 | +const selfName = computed(() => form.name || props.name || '(this preset)') | ||
| 65 | +const missingItems = (t: CatalogItemType, n: string) => !catalog.findItem(t, n) | ||
| 66 | + | ||
| 67 | +async function save() { | ||
| 68 | + if (problems.value.length) return | ||
| 69 | + saving.value = true | ||
| 70 | + saveError.value = null | ||
| 71 | + const items: Partial<Record<CatalogItemType, string[]>> = {} | ||
| 72 | + for (const t of CAT_TYPES) if (form.items[t]?.length) items[t] = [...form.items[t]!].sort() | ||
| 73 | + const body: Preset & { previousName?: string } = { | ||
| 74 | + name: form.name, | ||
| 75 | + description: form.description || undefined, | ||
| 76 | + includes: form.includes, | ||
| 77 | + items, | ||
| 78 | + ...(renaming.value ? { previousName: props.name } : {}) | ||
| 79 | + } | ||
| 80 | + try { | ||
| 81 | + await request(`/api/catalog/presets/${encodeURIComponent(form.name)}`, { method: 'PUT', body, silent: true }) | ||
| 82 | + toast.add({ title: isNew.value ? 'Preset created' : 'Preset saved', description: form.name, icon: 'i-lucide-check', color: 'success' }) | ||
| 83 | + await catalog.refresh() | ||
| 84 | + original.value = snapshot() | ||
| 85 | + if (isNew.value || renaming.value) await navigateTo(`/presets/${encodeURIComponent(form.name)}`, { replace: true }) | ||
| 86 | + } catch (e) { | ||
| 87 | + saveError.value = (e as Error).message | ||
| 88 | + } finally { | ||
| 89 | + saving.value = false | ||
| 90 | + } | ||
| 91 | +} | ||
| 92 | + | ||
| 93 | +onBeforeRouteLeave(() => !dirty.value || window.confirm('Discard unsaved changes?')) | ||
| 94 | + | ||
| 95 | +const usedBy = computed(() => (props.name ? catalog.presetUsedBy(props.name) : [])) | ||
| 96 | +const delOpen = ref(false) | ||
| 97 | +const deleting = ref(false) | ||
| 98 | +const delConflict = ref<string | null>(null) | ||
| 99 | + | ||
| 100 | +async function doDelete(force = false) { | ||
| 101 | + deleting.value = true | ||
| 102 | + try { | ||
| 103 | + await request(`/api/catalog/presets/${encodeURIComponent(props.name!)}`, { method: 'DELETE', query: force ? { force: '1' } : undefined, silent: true }) | ||
| 104 | + original.value = snapshot() | ||
| 105 | + toast.add({ title: 'Preset deleted', description: props.name, icon: 'i-lucide-check', color: 'success' }) | ||
| 106 | + delOpen.value = false | ||
| 107 | + await catalog.refresh() | ||
| 108 | + await navigateTo('/presets') | ||
| 109 | + } catch (e) { | ||
| 110 | + const err = e as { status?: number, message: string } | ||
| 111 | + if (err.status === 409) delConflict.value = err.message | ||
| 112 | + else toast.add({ title: 'Delete failed', description: err.message, color: 'error', icon: 'i-lucide-circle-alert' }) | ||
| 113 | + } finally { | ||
| 114 | + deleting.value = false | ||
| 115 | + } | ||
| 116 | +} | ||
| 117 | +function askDelete() { | ||
| 118 | + delConflict.value = null | ||
| 119 | + delOpen.value = true | ||
| 120 | +} | ||
| 121 | +</script> | ||
| 122 | + | ||
| 123 | +<template> | ||
| 124 | + <UDashboardPanel id="preset-editor"> | ||
| 125 | + <template #header> | ||
| 126 | + <UDashboardNavbar icon="i-lucide-layers"> | ||
| 127 | + <template #title> | ||
| 128 | + <NuxtLink to="/presets" class="text-muted hover:text-default">Presets</NuxtLink> | ||
| 129 | + <UIcon name="i-lucide-chevron-right" class="size-4 text-dimmed" /> | ||
| 130 | + <span class="truncate">{{ isNew ? 'New' : name }}</span> | ||
| 131 | + </template> | ||
| 132 | + <template #right> | ||
| 133 | + <UBadge v-if="dirty" label="Unsaved" color="warning" variant="subtle" /> | ||
| 134 | + <UButton v-if="!isNew" label="Delete" icon="i-lucide-trash-2" color="error" variant="soft" @click="askDelete" /> | ||
| 135 | + <UButton label="Save" icon="i-lucide-save" :loading="saving" :disabled="!dirty || !!problems.length" @click="save" /> | ||
| 136 | + </template> | ||
| 137 | + </UDashboardNavbar> | ||
| 138 | + </template> | ||
| 139 | + | ||
| 140 | + <template #body> | ||
| 141 | + <USkeleton v-if="!ready" class="h-72 w-full" /> | ||
| 142 | + <UAlert v-else-if="notFound" color="warning" variant="subtle" icon="i-lucide-circle-help" title="Preset not found" description="It may have been renamed or deleted." :actions="[{ label: 'Back to presets', to: '/presets', color: 'neutral', variant: 'outline' }]" /> | ||
| 143 | + | ||
| 144 | + <div v-else class="grid items-start gap-6 xl:grid-cols-2"> | ||
| 145 | + <div class="min-w-0 space-y-4"> | ||
| 146 | + <UAlert v-if="saveError" color="error" variant="subtle" icon="i-lucide-circle-alert" title="Save failed" :description="saveError" class="whitespace-pre-wrap" /> | ||
| 147 | + <div class="grid gap-4 sm:grid-cols-2"> | ||
| 148 | + <UFormField :label="renaming ? 'Name (renaming)' : 'Name'" required> | ||
| 149 | + <UInput v-model="form.name" class="w-full" placeholder="kebab-case-name" :ui="{ base: 'font-mono' }" /> | ||
| 150 | + </UFormField> | ||
| 151 | + <UFormField label="Description"> | ||
| 152 | + <UInput v-model="form.description" class="w-full" /> | ||
| 153 | + </UFormField> | ||
| 154 | + </div> | ||
| 155 | + <UAlert v-if="problems.length && (dirty || !isNew)" color="warning" variant="subtle" icon="i-lucide-triangle-alert" :title="problems.join(' ')" /> | ||
| 156 | + | ||
| 157 | + <UFormField label="Included presets" description="Their items are added to this preset (resolved recursively)."> | ||
| 158 | + <USelectMenu v-model="form.includes" :items="includeChoices" multiple placeholder="None" class="w-full" /> | ||
| 159 | + </UFormField> | ||
| 160 | + | ||
| 161 | + <div class="space-y-2"> | ||
| 162 | + <h3 class="text-sm font-semibold">Items</h3> | ||
| 163 | + <ItemPicker v-model="form.items" :items="catalog.items.value" /> | ||
| 164 | + </div> | ||
| 165 | + </div> | ||
| 166 | + | ||
| 167 | + <div class="min-w-0 space-y-4"> | ||
| 168 | + <section class="space-y-2"> | ||
| 169 | + <h3 class="text-sm font-semibold">Resolved content <span class="font-normal text-muted">({{ resolved.entries.length }} items)</span></h3> | ||
| 170 | + <UAlert v-if="resolved.missingPresets.length" color="warning" variant="subtle" icon="i-lucide-triangle-alert" :title="`Unknown preset(s): ${resolved.missingPresets.join(', ')}`" /> | ||
| 171 | + <UAlert v-if="resolved.cycles.length" color="warning" variant="subtle" icon="i-lucide-repeat" :title="`Include cycle ignored: ${resolved.cycles.join(', ')}`" /> | ||
| 172 | + <p v-if="!resolvedByType.length" class="rounded-lg border border-dashed border-default p-6 text-center text-sm text-muted">Nothing selected yet.</p> | ||
| 173 | + <div v-for="g in resolvedByType" :key="g.type" class="overflow-hidden rounded-lg border border-default"> | ||
| 174 | + <div class="flex items-center gap-2 bg-elevated/40 px-3 py-1.5 text-xs font-medium text-muted"> | ||
| 175 | + <UIcon :name="CAT_META[g.type].icon" class="size-3.5" />{{ CAT_META[g.type].label }} ({{ g.rows.length }}) | ||
| 176 | + </div> | ||
| 177 | + <ul class="divide-y divide-default text-sm"> | ||
| 178 | + <li v-for="r in g.rows" :key="r.name" class="flex items-center gap-2 px-3 py-1.5"> | ||
| 179 | + <NuxtLink v-if="!missingItems(r.type, r.name)" :to="`/catalog/${r.type}/${encodeURIComponent(r.name)}`" class="truncate hover:underline">{{ r.name }}</NuxtLink> | ||
| 180 | + <span v-else class="truncate">{{ r.name }}</span> | ||
| 181 | + <UBadge v-if="missingItems(r.type, r.name)" label="missing" size="sm" color="warning" variant="subtle" /> | ||
| 182 | + <span class="ml-auto flex shrink-0 gap-1"> | ||
| 183 | + <UBadge v-for="v in r.via.filter(v => v !== selfName)" :key="v" :label="`via ${v}`" size="sm" color="neutral" variant="subtle" /> | ||
| 184 | + </span> | ||
| 185 | + </li> | ||
| 186 | + </ul> | ||
| 187 | + </div> | ||
| 188 | + </section> | ||
| 189 | + | ||
| 190 | + <section v-if="!isNew" class="space-y-1.5"> | ||
| 191 | + <h3 class="text-sm font-semibold">Used by</h3> | ||
| 192 | + <ul v-if="usedBy.length" class="space-y-1 text-sm"> | ||
| 193 | + <li v-for="s in usedBy" :key="s"> | ||
| 194 | + <NuxtLink :to="scopeLink(s)" class="flex items-center gap-1.5 text-primary hover:underline"> | ||
| 195 | + <UIcon :name="s === 'home' ? 'i-lucide-house' : 'i-lucide-folder'" class="size-4" />{{ scopeLabel(s, scopeNames) }} | ||
| 196 | + </NuxtLink> | ||
| 197 | + </li> | ||
| 198 | + </ul> | ||
| 199 | + <p v-else class="text-xs text-muted">Not enabled in any scope.</p> | ||
| 200 | + </section> | ||
| 201 | + </div> | ||
| 202 | + </div> | ||
| 203 | + | ||
| 204 | + <UModal v-model:open="delOpen" :title="`Delete ${name}`" description="This removes the preset from the catalog."> | ||
| 205 | + <template #body> | ||
| 206 | + <div class="space-y-3 text-sm"> | ||
| 207 | + <p>Items of the preset are kept in the catalog. Scopes that enabled it will report the preset as missing.</p> | ||
| 208 | + <UAlert v-if="delConflict || usedBy.length" color="warning" variant="subtle" icon="i-lucide-triangle-alert" :title="delConflict ? 'This preset is used' : 'Used by'"> | ||
| 209 | + <template #description> | ||
| 210 | + <p v-if="delConflict" class="mb-1">{{ delConflict }}</p> | ||
| 211 | + <ul class="list-disc pl-4"><li v-for="s in usedBy" :key="s">{{ scopeLabel(s, scopeNames) }}</li></ul> | ||
| 212 | + </template> | ||
| 213 | + </UAlert> | ||
| 214 | + </div> | ||
| 215 | + </template> | ||
| 216 | + <template #footer> | ||
| 217 | + <div class="flex w-full justify-end gap-2"> | ||
| 218 | + <UButton label="Cancel" color="neutral" variant="outline" @click="delOpen = false" /> | ||
| 219 | + <UButton :label="delConflict ? 'Delete anyway' : 'Delete'" color="error" :loading="deleting" @click="doDelete(!!delConflict)" /> | ||
| 220 | + </div> | ||
| 221 | + </template> | ||
| 222 | + </UModal> | ||
| 223 | + </template> | ||
| 224 | + </UDashboardPanel> | ||
| 225 | +</template> | ||
added
packages/ui/app/components/RemoveFromGlobalModal.vue +113 -0 | new file mode 100644 | ||
| @@ -0,0 +1,113 @@ | ||
| 1 | +<script setup lang="ts"> | |
| 2 | +import type { InventoryItem, RemoveFromGlobalResult } from '@skilly/core' | |
| 3 | + | |
| 4 | +const props = defineProps<{ items: InventoryItem[] }>() | |
| 5 | +const open = defineModel<boolean>('open', { default: false }) | |
| 6 | +const emit = defineEmits<{ done: [] }>() | |
| 7 | + | |
| 8 | +const { request } = useApi() | |
| 9 | +const { projects } = useProjects() | |
| 10 | +const toast = useToast() | |
| 11 | + | |
| 12 | +const chosen = ref<string[]>([]) | |
| 13 | +const search = ref('') | |
| 14 | +const busy = ref(false) | |
| 15 | +const result = ref<RemoveFromGlobalResult | null>(null) | |
| 16 | +const error = ref<string | null>(null) | |
| 17 | + | |
| 18 | +const list = computed(() => { | |
| 19 | + const q = search.value.trim().toLowerCase() | |
| 20 | + return projects.value.filter(p => p.exists && (!q || `${p.name} ${p.path}`.toLowerCase().includes(q))) | |
| 21 | +}) | |
| 22 | + | |
| 23 | +watch(open, (v) => { | |
| 24 | + if (v) { | |
| 25 | + chosen.value = [] | |
| 26 | + search.value = '' | |
| 27 | + result.value = null | |
| 28 | + error.value = null | |
| 29 | + } | |
| 30 | +}) | |
| 31 | + | |
| 32 | +function toggle(id: string, on: boolean | 'indeterminate') { | |
| 33 | + chosen.value = on === true ? [...new Set([...chosen.value, id])] : chosen.value.filter(x => x !== id) | |
| 34 | +} | |
| 35 | + | |
| 36 | +async function submit() { | |
| 37 | + busy.value = true | |
| 38 | + error.value = null | |
| 39 | + try { | |
| 40 | + result.value = await request<RemoveFromGlobalResult>('/api/home/remove-from-global', { | |
| 41 | + method: 'POST', | |
| 42 | + body: { itemIds: props.items.map(i => i.id), enableIn: chosen.value }, | |
| 43 | + silent: true | |
| 44 | + }) | |
| 45 | + toast.add({ | |
| 46 | + title: `${result.value.removed.length} removed from Home`, | |
| 47 | + color: result.value.errors.length ? 'warning' : 'success', | |
| 48 | + icon: 'i-lucide-check' | |
| 49 | + }) | |
| 50 | + emit('done') | |
| 51 | + } catch (e) { | |
| 52 | + error.value = (e as Error).message | |
| 53 | + } finally { | |
| 54 | + busy.value = false | |
| 55 | + } | |
| 56 | +} | |
| 57 | + | |
| 58 | +const nameOf = (id: string) => props.items.find(i => i.id === id)?.name ?? id | |
| 59 | +</script> | |
| 60 | + | |
| 61 | +<template> | |
| 62 | + <UModal v-model:open="open" title="Remove from global" description="Move these items out of Home and into the catalog." :ui="{ content: 'max-w-xl' }"> | |
| 63 | + <template #body> | |
| 64 | + <div v-if="result" class="space-y-3 text-sm"> | |
| 65 | + <UAlert v-if="result.removed.length" color="success" variant="subtle" icon="i-lucide-check" :title="`${result.removed.length} item(s) removed from Home`" :description="result.removed.map(nameOf).join(', ')" /> | |
| 66 | + <UAlert v-if="result.imported.length" color="info" variant="subtle" icon="i-lucide-library" :title="`${result.imported.length} item(s) in the catalog`" :description="result.imported.map(i => `${i.type}/${i.name}`).join(', ')" /> | |
| 67 | + <UAlert v-if="result.errors.length" color="error" variant="subtle" icon="i-lucide-circle-alert" :title="`${result.errors.length} error(s)`"> | |
| 68 | + <template #description> | |
| 69 | + <ul class="list-disc pl-4"><li v-for="e in result.errors" :key="e.id"><span class="font-medium">{{ nameOf(e.id) }}</span>: {{ e.message }}</li></ul> | |
| 70 | + </template> | |
| 71 | + </UAlert> | |
| 72 | + </div> | |
| 73 | + <div v-else class="space-y-4"> | |
| 74 | + <UAlert v-if="error" color="error" variant="subtle" icon="i-lucide-circle-alert" title="Request failed" :description="error" /> | |
| 75 | + <ol class="list-decimal space-y-1 pl-5 text-sm text-muted"> | |
| 76 | + <li>Each item is <strong class="text-default">copied to the catalog</strong> (unless an identical one is already there).</li> | |
| 77 | + <li>It is <strong class="text-default">enabled in the projects you choose</strong> below and written into them.</li> | |
| 78 | + <li>It is then <strong class="text-default">removed from Home</strong>. Symbolic links are only unlinked: their target is never deleted.</li> | |
| 79 | + </ol> | |
| 80 | + | |
| 81 | + <div> | |
| 82 | + <p class="mb-1 text-xs font-medium text-muted">Items ({{ items.length }})</p> | |
| 83 | + <ul class="max-h-28 divide-y divide-default overflow-auto rounded-lg border border-default text-sm"> | |
| 84 | + <li v-for="i in items" :key="i.id" class="flex items-center gap-2 px-3 py-1"> | |
| 85 | + <AgentBadge :agent="i.agent" compact /><span class="truncate font-medium">{{ i.name }}</span><span class="text-xs text-muted">{{ i.type }}</span> | |
| 86 | + <UBadge v-if="i.source.symlinkTarget" label="symlink" size="sm" color="neutral" variant="subtle" class="ml-auto" /> | |
| 87 | + </li> | |
| 88 | + </ul> | |
| 89 | + </div> | |
| 90 | + | |
| 91 | + <div class="space-y-1.5"> | |
| 92 | + <p class="text-xs font-medium text-muted">Enable in these projects ({{ chosen.length }} selected)</p> | |
| 93 | + <UInput v-if="projects.length > 6" v-model="search" icon="i-lucide-search" size="sm" placeholder="Search projects..." class="w-full" /> | |
| 94 | + <ul v-if="list.length" class="max-h-48 divide-y divide-default overflow-auto rounded-lg border border-default text-sm"> | |
| 95 | + <li v-for="p in list" :key="p.id" class="flex items-center gap-2 px-3 py-1.5 hover:bg-elevated/50"> | |
| 96 | + <UCheckbox :model-value="chosen.includes(p.id)" :aria-label="p.name" @update:model-value="(v) => toggle(p.id, v)" /> | |
| 97 | + <span class="font-medium">{{ p.name }}</span> | |
| 98 | + <span class="truncate font-mono text-xs text-muted" :title="p.path">{{ p.path }}</span> | |
| 99 | + </li> | |
| 100 | + </ul> | |
| 101 | + <p v-else class="rounded-lg border border-dashed border-default p-4 text-center text-xs text-muted">No project available. The items will only be moved to the catalog.</p> | |
| 102 | + <p v-if="!chosen.length" class="text-xs text-warning">No project selected: the items will disappear from Home and be available in the catalog only.</p> | |
| 103 | + </div> | |
| 104 | + </div> | |
| 105 | + </template> | |
| 106 | + <template #footer> | |
| 107 | + <div class="flex w-full justify-end gap-2"> | |
| 108 | + <UButton :label="result ? 'Close' : 'Cancel'" color="neutral" variant="outline" @click="open = false" /> | |
| 109 | + <UButton v-if="!result" label="Remove from global" icon="i-lucide-globe-lock" color="error" :loading="busy" @click="submit" /> | |
| 110 | + </div> | |
| 111 | + </template> | |
| 112 | + </UModal> | |
| 113 | +</template> | |
| new file mode 100644 | |||
| @@ -0,0 +1,113 @@ | |||
| 1 | +<script setup lang="ts"> | ||
| 2 | +import type { InventoryItem, RemoveFromGlobalResult } from '@skilly/core' | ||
| 3 | + | ||
| 4 | +const props = defineProps<{ items: InventoryItem[] }>() | ||
| 5 | +const open = defineModel<boolean>('open', { default: false }) | ||
| 6 | +const emit = defineEmits<{ done: [] }>() | ||
| 7 | + | ||
| 8 | +const { request } = useApi() | ||
| 9 | +const { projects } = useProjects() | ||
| 10 | +const toast = useToast() | ||
| 11 | + | ||
| 12 | +const chosen = ref<string[]>([]) | ||
| 13 | +const search = ref('') | ||
| 14 | +const busy = ref(false) | ||
| 15 | +const result = ref<RemoveFromGlobalResult | null>(null) | ||
| 16 | +const error = ref<string | null>(null) | ||
| 17 | + | ||
| 18 | +const list = computed(() => { | ||
| 19 | + const q = search.value.trim().toLowerCase() | ||
| 20 | + return projects.value.filter(p => p.exists && (!q || `${p.name} ${p.path}`.toLowerCase().includes(q))) | ||
| 21 | +}) | ||
| 22 | + | ||
| 23 | +watch(open, (v) => { | ||
| 24 | + if (v) { | ||
| 25 | + chosen.value = [] | ||
| 26 | + search.value = '' | ||
| 27 | + result.value = null | ||
| 28 | + error.value = null | ||
| 29 | + } | ||
| 30 | +}) | ||
| 31 | + | ||
| 32 | +function toggle(id: string, on: boolean | 'indeterminate') { | ||
| 33 | + chosen.value = on === true ? [...new Set([...chosen.value, id])] : chosen.value.filter(x => x !== id) | ||
| 34 | +} | ||
| 35 | + | ||
| 36 | +async function submit() { | ||
| 37 | + busy.value = true | ||
| 38 | + error.value = null | ||
| 39 | + try { | ||
| 40 | + result.value = await request<RemoveFromGlobalResult>('/api/home/remove-from-global', { | ||
| 41 | + method: 'POST', | ||
| 42 | + body: { itemIds: props.items.map(i => i.id), enableIn: chosen.value }, | ||
| 43 | + silent: true | ||
| 44 | + }) | ||
| 45 | + toast.add({ | ||
| 46 | + title: `${result.value.removed.length} removed from Home`, | ||
| 47 | + color: result.value.errors.length ? 'warning' : 'success', | ||
| 48 | + icon: 'i-lucide-check' | ||
| 49 | + }) | ||
| 50 | + emit('done') | ||
| 51 | + } catch (e) { | ||
| 52 | + error.value = (e as Error).message | ||
| 53 | + } finally { | ||
| 54 | + busy.value = false | ||
| 55 | + } | ||
| 56 | +} | ||
| 57 | + | ||
| 58 | +const nameOf = (id: string) => props.items.find(i => i.id === id)?.name ?? id | ||
| 59 | +</script> | ||
| 60 | + | ||
| 61 | +<template> | ||
| 62 | + <UModal v-model:open="open" title="Remove from global" description="Move these items out of Home and into the catalog." :ui="{ content: 'max-w-xl' }"> | ||
| 63 | + <template #body> | ||
| 64 | + <div v-if="result" class="space-y-3 text-sm"> | ||
| 65 | + <UAlert v-if="result.removed.length" color="success" variant="subtle" icon="i-lucide-check" :title="`${result.removed.length} item(s) removed from Home`" :description="result.removed.map(nameOf).join(', ')" /> | ||
| 66 | + <UAlert v-if="result.imported.length" color="info" variant="subtle" icon="i-lucide-library" :title="`${result.imported.length} item(s) in the catalog`" :description="result.imported.map(i => `${i.type}/${i.name}`).join(', ')" /> | ||
| 67 | + <UAlert v-if="result.errors.length" color="error" variant="subtle" icon="i-lucide-circle-alert" :title="`${result.errors.length} error(s)`"> | ||
| 68 | + <template #description> | ||
| 69 | + <ul class="list-disc pl-4"><li v-for="e in result.errors" :key="e.id"><span class="font-medium">{{ nameOf(e.id) }}</span>: {{ e.message }}</li></ul> | ||
| 70 | + </template> | ||
| 71 | + </UAlert> | ||
| 72 | + </div> | ||
| 73 | + <div v-else class="space-y-4"> | ||
| 74 | + <UAlert v-if="error" color="error" variant="subtle" icon="i-lucide-circle-alert" title="Request failed" :description="error" /> | ||
| 75 | + <ol class="list-decimal space-y-1 pl-5 text-sm text-muted"> | ||
| 76 | + <li>Each item is <strong class="text-default">copied to the catalog</strong> (unless an identical one is already there).</li> | ||
| 77 | + <li>It is <strong class="text-default">enabled in the projects you choose</strong> below and written into them.</li> | ||
| 78 | + <li>It is then <strong class="text-default">removed from Home</strong>. Symbolic links are only unlinked: their target is never deleted.</li> | ||
| 79 | + </ol> | ||
| 80 | + | ||
| 81 | + <div> | ||
| 82 | + <p class="mb-1 text-xs font-medium text-muted">Items ({{ items.length }})</p> | ||
| 83 | + <ul class="max-h-28 divide-y divide-default overflow-auto rounded-lg border border-default text-sm"> | ||
| 84 | + <li v-for="i in items" :key="i.id" class="flex items-center gap-2 px-3 py-1"> | ||
| 85 | + <AgentBadge :agent="i.agent" compact /><span class="truncate font-medium">{{ i.name }}</span><span class="text-xs text-muted">{{ i.type }}</span> | ||
| 86 | + <UBadge v-if="i.source.symlinkTarget" label="symlink" size="sm" color="neutral" variant="subtle" class="ml-auto" /> | ||
| 87 | + </li> | ||
| 88 | + </ul> | ||
| 89 | + </div> | ||
| 90 | + | ||
| 91 | + <div class="space-y-1.5"> | ||
| 92 | + <p class="text-xs font-medium text-muted">Enable in these projects ({{ chosen.length }} selected)</p> | ||
| 93 | + <UInput v-if="projects.length > 6" v-model="search" icon="i-lucide-search" size="sm" placeholder="Search projects..." class="w-full" /> | ||
| 94 | + <ul v-if="list.length" class="max-h-48 divide-y divide-default overflow-auto rounded-lg border border-default text-sm"> | ||
| 95 | + <li v-for="p in list" :key="p.id" class="flex items-center gap-2 px-3 py-1.5 hover:bg-elevated/50"> | ||
| 96 | + <UCheckbox :model-value="chosen.includes(p.id)" :aria-label="p.name" @update:model-value="(v) => toggle(p.id, v)" /> | ||
| 97 | + <span class="font-medium">{{ p.name }}</span> | ||
| 98 | + <span class="truncate font-mono text-xs text-muted" :title="p.path">{{ p.path }}</span> | ||
| 99 | + </li> | ||
| 100 | + </ul> | ||
| 101 | + <p v-else class="rounded-lg border border-dashed border-default p-4 text-center text-xs text-muted">No project available. The items will only be moved to the catalog.</p> | ||
| 102 | + <p v-if="!chosen.length" class="text-xs text-warning">No project selected: the items will disappear from Home and be available in the catalog only.</p> | ||
| 103 | + </div> | ||
| 104 | + </div> | ||
| 105 | + </template> | ||
| 106 | + <template #footer> | ||
| 107 | + <div class="flex w-full justify-end gap-2"> | ||
| 108 | + <UButton :label="result ? 'Close' : 'Cancel'" color="neutral" variant="outline" @click="open = false" /> | ||
| 109 | + <UButton v-if="!result" label="Remove from global" icon="i-lucide-globe-lock" color="error" :loading="busy" @click="submit" /> | ||
| 110 | + </div> | ||
| 111 | + </template> | ||
| 112 | + </UModal> | ||
| 113 | +</template> | ||
added
packages/ui/app/components/ScopeConfigBar.vue +69 -0 | new file mode 100644 | ||
| @@ -0,0 +1,69 @@ | ||
| 1 | +<script setup lang="ts"> | |
| 2 | +import type { AgentId, Preset, ScopeConfig } from '@skilly/core' | |
| 3 | + | |
| 4 | +/** Targets and preset toggles, always visible above the tabs. Changes are emitted as a new config. */ | |
| 5 | +const props = defineProps<{ config: ScopeConfig, presets: Preset[], catalogExists: boolean, saving: boolean }>() | |
| 6 | +const emit = defineEmits<{ 'update:config': [c: ScopeConfig] }>() | |
| 7 | + | |
| 8 | +const AGENT_LIST: AgentId[] = ['claude', 'codex'] | |
| 9 | + | |
| 10 | +function toggleTarget(a: AgentId, on: boolean | 'indeterminate') { | |
| 11 | + const c = cloneConfig(props.config) | |
| 12 | + c.targets = on === true ? [...new Set([...c.targets, a])] : c.targets.filter(t => t !== a) | |
| 13 | + emit('update:config', c) | |
| 14 | +} | |
| 15 | +function togglePreset(name: string) { | |
| 16 | + const c = cloneConfig(props.config) | |
| 17 | + c.presets = toggleIn(c.presets, name, !c.presets.includes(name)) | |
| 18 | + emit('update:config', c) | |
| 19 | +} | |
| 20 | + | |
| 21 | +const known = computed(() => new Set(props.presets.map(p => p.name))) | |
| 22 | +const unknown = computed(() => props.config.presets.filter(p => !known.value.has(p))) | |
| 23 | +const count = (p: Preset) => CAT_TYPES.reduce((n, t) => n + (p.items[t]?.length ?? 0), 0) | |
| 24 | +</script> | |
| 25 | + | |
| 26 | +<template> | |
| 27 | + <div class="space-y-3 rounded-xl border border-default p-3"> | |
| 28 | + <div class="flex flex-wrap items-center gap-x-6 gap-y-2"> | |
| 29 | + <div class="flex items-center gap-3"> | |
| 30 | + <span class="text-xs font-medium uppercase tracking-wide text-muted">Targets</span> | |
| 31 | + <UCheckbox | |
| 32 | + v-for="a in AGENT_LIST" | |
| 33 | + :key="a" | |
| 34 | + :model-value="config.targets.includes(a)" | |
| 35 | + :label="AGENT_LABELS[a]" | |
| 36 | + :aria-label="`Write files for ${AGENT_LABELS[a]}`" | |
| 37 | + @update:model-value="(v) => toggleTarget(a, v)" | |
| 38 | + /> | |
| 39 | + </div> | |
| 40 | + <span v-if="!config.targets.length" class="text-xs text-warning">No target agent: nothing will be written.</span> | |
| 41 | + <span class="ml-auto flex items-center gap-1.5 text-xs text-muted"> | |
| 42 | + <template v-if="saving"><UIcon name="i-lucide-loader-circle" class="size-3.5 animate-spin" />Saving...</template> | |
| 43 | + <template v-else><UIcon name="i-lucide-check" class="size-3.5" />Saved</template> | |
| 44 | + </span> | |
| 45 | + </div> | |
| 46 | + | |
| 47 | + <div class="flex flex-wrap items-center gap-2"> | |
| 48 | + <span class="mr-1 text-xs font-medium uppercase tracking-wide text-muted">Presets</span> | |
| 49 | + <UTooltip v-for="p in presets" :key="p.name" :text="p.description || `${count(p)} items`"> | |
| 50 | + <UButton | |
| 51 | + :label="p.name" | |
| 52 | + :icon="config.presets.includes(p.name) ? 'i-lucide-check' : 'i-lucide-plus'" | |
| 53 | + size="xs" | |
| 54 | + :color="config.presets.includes(p.name) ? 'primary' : 'neutral'" | |
| 55 | + :variant="config.presets.includes(p.name) ? 'soft' : 'outline'" | |
| 56 | + :aria-pressed="config.presets.includes(p.name)" | |
| 57 | + @click="togglePreset(p.name)" | |
| 58 | + /> | |
| 59 | + </UTooltip> | |
| 60 | + <UTooltip v-for="p in unknown" :key="p" text="Enabled in the configuration but not found in the catalog"> | |
| 61 | + <UButton :label="p" icon="i-lucide-triangle-alert" size="xs" color="warning" variant="soft" @click="togglePreset(p)" /> | |
| 62 | + </UTooltip> | |
| 63 | + <span v-if="!presets.length && !unknown.length" class="text-xs text-muted"> | |
| 64 | + {{ catalogExists ? 'No preset yet.' : 'No catalog yet.' }} | |
| 65 | + <NuxtLink :to="catalogExists ? '/presets/new' : '/catalog'" class="text-primary hover:underline">{{ catalogExists ? 'Create one' : 'Create the catalog' }}</NuxtLink> | |
| 66 | + </span> | |
| 67 | + </div> | |
| 68 | + </div> | |
| 69 | +</template> | |
| new file mode 100644 | |||
| @@ -0,0 +1,69 @@ | |||
| 1 | +<script setup lang="ts"> | ||
| 2 | +import type { AgentId, Preset, ScopeConfig } from '@skilly/core' | ||
| 3 | + | ||
| 4 | +/** Targets and preset toggles, always visible above the tabs. Changes are emitted as a new config. */ | ||
| 5 | +const props = defineProps<{ config: ScopeConfig, presets: Preset[], catalogExists: boolean, saving: boolean }>() | ||
| 6 | +const emit = defineEmits<{ 'update:config': [c: ScopeConfig] }>() | ||
| 7 | + | ||
| 8 | +const AGENT_LIST: AgentId[] = ['claude', 'codex'] | ||
| 9 | + | ||
| 10 | +function toggleTarget(a: AgentId, on: boolean | 'indeterminate') { | ||
| 11 | + const c = cloneConfig(props.config) | ||
| 12 | + c.targets = on === true ? [...new Set([...c.targets, a])] : c.targets.filter(t => t !== a) | ||
| 13 | + emit('update:config', c) | ||
| 14 | +} | ||
| 15 | +function togglePreset(name: string) { | ||
| 16 | + const c = cloneConfig(props.config) | ||
| 17 | + c.presets = toggleIn(c.presets, name, !c.presets.includes(name)) | ||
| 18 | + emit('update:config', c) | ||
| 19 | +} | ||
| 20 | + | ||
| 21 | +const known = computed(() => new Set(props.presets.map(p => p.name))) | ||
| 22 | +const unknown = computed(() => props.config.presets.filter(p => !known.value.has(p))) | ||
| 23 | +const count = (p: Preset) => CAT_TYPES.reduce((n, t) => n + (p.items[t]?.length ?? 0), 0) | ||
| 24 | +</script> | ||
| 25 | + | ||
| 26 | +<template> | ||
| 27 | + <div class="space-y-3 rounded-xl border border-default p-3"> | ||
| 28 | + <div class="flex flex-wrap items-center gap-x-6 gap-y-2"> | ||
| 29 | + <div class="flex items-center gap-3"> | ||
| 30 | + <span class="text-xs font-medium uppercase tracking-wide text-muted">Targets</span> | ||
| 31 | + <UCheckbox | ||
| 32 | + v-for="a in AGENT_LIST" | ||
| 33 | + :key="a" | ||
| 34 | + :model-value="config.targets.includes(a)" | ||
| 35 | + :label="AGENT_LABELS[a]" | ||
| 36 | + :aria-label="`Write files for ${AGENT_LABELS[a]}`" | ||
| 37 | + @update:model-value="(v) => toggleTarget(a, v)" | ||
| 38 | + /> | ||
| 39 | + </div> | ||
| 40 | + <span v-if="!config.targets.length" class="text-xs text-warning">No target agent: nothing will be written.</span> | ||
| 41 | + <span class="ml-auto flex items-center gap-1.5 text-xs text-muted"> | ||
| 42 | + <template v-if="saving"><UIcon name="i-lucide-loader-circle" class="size-3.5 animate-spin" />Saving...</template> | ||
| 43 | + <template v-else><UIcon name="i-lucide-check" class="size-3.5" />Saved</template> | ||
| 44 | + </span> | ||
| 45 | + </div> | ||
| 46 | + | ||
| 47 | + <div class="flex flex-wrap items-center gap-2"> | ||
| 48 | + <span class="mr-1 text-xs font-medium uppercase tracking-wide text-muted">Presets</span> | ||
| 49 | + <UTooltip v-for="p in presets" :key="p.name" :text="p.description || `${count(p)} items`"> | ||
| 50 | + <UButton | ||
| 51 | + :label="p.name" | ||
| 52 | + :icon="config.presets.includes(p.name) ? 'i-lucide-check' : 'i-lucide-plus'" | ||
| 53 | + size="xs" | ||
| 54 | + :color="config.presets.includes(p.name) ? 'primary' : 'neutral'" | ||
| 55 | + :variant="config.presets.includes(p.name) ? 'soft' : 'outline'" | ||
| 56 | + :aria-pressed="config.presets.includes(p.name)" | ||
| 57 | + @click="togglePreset(p.name)" | ||
| 58 | + /> | ||
| 59 | + </UTooltip> | ||
| 60 | + <UTooltip v-for="p in unknown" :key="p" text="Enabled in the configuration but not found in the catalog"> | ||
| 61 | + <UButton :label="p" icon="i-lucide-triangle-alert" size="xs" color="warning" variant="soft" @click="togglePreset(p)" /> | ||
| 62 | + </UTooltip> | ||
| 63 | + <span v-if="!presets.length && !unknown.length" class="text-xs text-muted"> | ||
| 64 | + {{ catalogExists ? 'No preset yet.' : 'No catalog yet.' }} | ||
| 65 | + <NuxtLink :to="catalogExists ? '/presets/new' : '/catalog'" class="text-primary hover:underline">{{ catalogExists ? 'Create one' : 'Create the catalog' }}</NuxtLink> | ||
| 66 | + </span> | ||
| 67 | + </div> | ||
| 68 | + </div> | ||
| 69 | +</template> | ||
added
packages/ui/app/components/ScopeConfigPanel.vue +282 -0 | new file mode 100644 | ||
| @@ -0,0 +1,282 @@ | ||
| 1 | +<script setup lang="ts"> | |
| 2 | +import type { AgentId, CatalogItem, CatalogItemType, Preset, ScopeConfig } from '@skilly/core' | |
| 3 | + | |
| 4 | +/** "Configuration" tab: enabled items (exclude, automatic / on demand) and hidden global items. */ | |
| 5 | +const props = defineProps<{ config: ScopeConfig, items: CatalogItem[], presets: Preset[], home: boolean }>() | |
| 6 | +const emit = defineEmits<{ 'update:config': [c: ScopeConfig] }>() | |
| 7 | + | |
| 8 | +const { home: homeInv, refresh: refreshHome, loading: homeLoading } = useHomeInventory() | |
| 9 | + | |
| 10 | +// Home manages skills, rules and agents (its MCP servers and hooks are read-only); projects manage every type. | |
| 11 | +const ADD_TYPES = computed<CatalogItemType[]>(() => (props.home ? ['skill', 'rule', 'agent'] : ['skill', 'rule', 'agent', 'mcp', 'hook'])) | |
| 12 | + | |
| 13 | +const resolved = computed(() => resolveItems(props.presets, props.config.presets, props.config.add)) | |
| 14 | +const rows = computed(() => resolved.value.entries | |
| 15 | + .slice() | |
| 16 | + .sort((a, b) => CAT_TYPES.indexOf(a.type) - CAT_TYPES.indexOf(b.type) || a.name.localeCompare(b.name))) | |
| 17 | +const inCatalog = (t: CatalogItemType, n: string) => props.items.some(i => i.type === t && i.name === n) | |
| 18 | + | |
| 19 | +function matchOf(list: string[] | undefined, name: string): { exact: boolean, pattern?: string } | null { | |
| 20 | + const l = list ?? [] | |
| 21 | + if (l.includes(name)) return { exact: true } | |
| 22 | + const g = l.find(p => p.includes('*') && globMatch(p, name)) | |
| 23 | + return g ? { exact: false, pattern: g } : null | |
| 24 | +} | |
| 25 | + | |
| 26 | +function mutate(fn: (c: ScopeConfig) => void) { | |
| 27 | + const c = cloneConfig(props.config) | |
| 28 | + fn(c) | |
| 29 | + emit('update:config', c) | |
| 30 | +} | |
| 31 | + | |
| 32 | +function setIncluded(t: CatalogItemType, name: string, included: boolean) { | |
| 33 | + mutate((c) => { | |
| 34 | + const list = toggleIn(c.exclude[t] ?? [], name, !included) | |
| 35 | + if (list.length) c.exclude[t] = list | |
| 36 | + else delete c.exclude[t] | |
| 37 | + }) | |
| 38 | +} | |
| 39 | +function removeAdded(t: CatalogItemType, name: string) { | |
| 40 | + mutate((c) => { | |
| 41 | + const list = (c.add[t] ?? []).filter(x => x !== name) | |
| 42 | + if (list.length) c.add[t] = list | |
| 43 | + else delete c.add[t] | |
| 44 | + }) | |
| 45 | +} | |
| 46 | +function setOnDemand(name: string, on: boolean) { | |
| 47 | + mutate((c) => { c.onDemand.skills = toggleIn(c.onDemand.skills, name, on) }) | |
| 48 | +} | |
| 49 | + | |
| 50 | +/* ------------------------------------------------ Add items */ | |
| 51 | + | |
| 52 | +const pickerOpen = ref(false) | |
| 53 | +const pickerSel = ref<Partial<Record<CatalogItemType, string[]>>>({}) | |
| 54 | +const locked = computed(() => { | |
| 55 | + const m: Record<string, string> = {} | |
| 56 | + for (const e of resolved.value.entries) { | |
| 57 | + const preset = e.via.find(v => v !== 'add') | |
| 58 | + if (preset) m[`${e.type}/${e.name}`] = `via ${preset}` | |
| 59 | + } | |
| 60 | + return m | |
| 61 | +}) | |
| 62 | +const pickable = computed(() => props.items.filter(i => ADD_TYPES.value.includes(i.type))) | |
| 63 | + | |
| 64 | +function openPicker() { | |
| 65 | + pickerSel.value = structuredClone(toRaw(props.config.add)) | |
| 66 | + pickerOpen.value = true | |
| 67 | +} | |
| 68 | +function confirmPicker() { | |
| 69 | + mutate((c) => { | |
| 70 | + for (const t of ADD_TYPES.value) { | |
| 71 | + const l = pickerSel.value[t] ?? [] | |
| 72 | + if (l.length) c.add[t] = l | |
| 73 | + else delete c.add[t] | |
| 74 | + } | |
| 75 | + }) | |
| 76 | + pickerOpen.value = false | |
| 77 | +} | |
| 78 | + | |
| 79 | +/* ------------------------------------------------ Hide global (projects) */ | |
| 80 | + | |
| 81 | +const hideTab = ref<'skills' | 'mcp'>('skills') | |
| 82 | +const hideSearch = ref('') | |
| 83 | +const HIDE_LIMIT = 100 | |
| 84 | + | |
| 85 | +onMounted(() => { | |
| 86 | + if (!props.home && !homeInv.value) refreshHome() | |
| 87 | +}) | |
| 88 | + | |
| 89 | +const homeNames = computed(() => { | |
| 90 | + const out: Record<'skills' | 'mcp', Map<string, Set<AgentId>>> = { skills: new Map(), mcp: new Map() } | |
| 91 | + for (const i of homeInv.value?.items ?? []) { | |
| 92 | + const bucket = i.type === 'skill' ? out.skills : i.type === 'mcp' ? out.mcp : null | |
| 93 | + if (!bucket) continue | |
| 94 | + bucket.set(i.name, (bucket.get(i.name) ?? new Set()).add(i.agent)) | |
| 95 | + } | |
| 96 | + return out | |
| 97 | +}) | |
| 98 | +const hideRows = computed(() => { | |
| 99 | + const q = hideSearch.value.trim().toLowerCase() | |
| 100 | + return [...homeNames.value[hideTab.value].entries()] | |
| 101 | + .filter(([n]) => !q || n.toLowerCase().includes(q)) | |
| 102 | + .sort((a, b) => a[0].localeCompare(b[0])) | |
| 103 | +}) | |
| 104 | +const hideTabs = computed(() => [ | |
| 105 | + { label: 'Skills', value: 'skills', icon: TYPE_META.skill.icon, badge: { label: String(homeNames.value.skills.size), color: 'neutral' as const, variant: 'subtle' as const } }, | |
| 106 | + { label: 'MCP servers', value: 'mcp', icon: TYPE_META.mcp.icon, badge: { label: String(homeNames.value.mcp.size), color: 'neutral' as const, variant: 'subtle' as const } } | |
| 107 | +]) | |
| 108 | +function setHidden(name: string, on: boolean | 'indeterminate') { | |
| 109 | + mutate((c) => { c.hideGlobal[hideTab.value] = toggleIn(c.hideGlobal[hideTab.value], name, on === true) }) | |
| 110 | +} | |
| 111 | + | |
| 112 | +const patterns = computed(() => { | |
| 113 | + const out: { where: string, pattern: string }[] = [] | |
| 114 | + for (const [t, l] of Object.entries(props.config.exclude)) for (const p of l ?? []) if (p.includes('*')) out.push({ where: `exclude ${t}`, pattern: p }) | |
| 115 | + for (const p of props.config.onDemand.skills) if (p.includes('*')) out.push({ where: 'on demand', pattern: p }) | |
| 116 | + if (!props.home) { | |
| 117 | + for (const p of props.config.hideGlobal.skills) if (p.includes('*')) out.push({ where: 'hide skills', pattern: p }) | |
| 118 | + for (const p of props.config.hideGlobal.mcp) if (p.includes('*')) out.push({ where: 'hide mcp', pattern: p }) | |
| 119 | + } | |
| 120 | + return out | |
| 121 | +}) | |
| 122 | +</script> | |
| 123 | + | |
| 124 | +<template> | |
| 125 | + <div class="space-y-8"> | |
| 126 | + <!-- Enabled items --> | |
| 127 | + <section class="space-y-3"> | |
| 128 | + <div class="flex items-center gap-3"> | |
| 129 | + <div> | |
| 130 | + <h2 class="font-semibold">Enabled items <span class="font-normal text-muted">({{ rows.length }})</span></h2> | |
| 131 | + <p class="text-xs text-muted">From enabled presets and items added one by one. Files are written when you apply the changes.</p> | |
| 132 | + </div> | |
| 133 | + <UButton class="ml-auto" label="Add items" icon="i-lucide-plus" :disabled="!items.length" @click="openPicker" /> | |
| 134 | + </div> | |
| 135 | + <UAlert v-if="resolved.missingPresets.length" color="warning" variant="subtle" icon="i-lucide-triangle-alert" :title="`Preset(s) not found in the catalog: ${resolved.missingPresets.join(', ')}`" /> | |
| 136 | + <UAlert v-if="home" color="neutral" variant="subtle" icon="i-lucide-info" title="Home MCP servers and hooks are not managed in v1" description="Their entries in your user configuration are listed read-only in the MCP and Hooks tabs." /> | |
| 137 | + | |
| 138 | + <div v-if="!rows.length" class="flex flex-col items-center gap-2 rounded-xl border border-dashed border-default py-10 text-center"> | |
| 139 | + <UIcon name="i-lucide-layers" class="size-8 text-dimmed" /> | |
| 140 | + <p class="text-sm text-muted">Nothing enabled yet. Toggle a preset above, or add items one by one.</p> | |
| 141 | + </div> | |
| 142 | + <div v-else class="overflow-x-auto rounded-xl border border-default"> | |
| 143 | + <table class="w-full min-w-[720px] table-fixed text-sm"> | |
| 144 | + <thead class="border-b border-default bg-elevated/40"> | |
| 145 | + <tr> | |
| 146 | + <th class="w-[26%] px-3 py-2 text-left text-xs font-medium uppercase tracking-wide text-muted">Name</th> | |
| 147 | + <th class="px-3 py-2 text-left text-xs font-medium uppercase tracking-wide text-muted">Source</th> | |
| 148 | + <th class="w-52 px-3 py-2 text-left text-xs font-medium uppercase tracking-wide text-muted"> | |
| 149 | + <span class="inline-flex items-center gap-1"> | |
| 150 | + Mode | |
| 151 | + <UTooltip :content="{ side: 'top' }"> | |
| 152 | + <UIcon name="i-lucide-circle-help" class="size-3.5" /> | |
| 153 | + <template #content> | |
| 154 | + <p class="max-w-xs whitespace-normal text-xs normal-case"> | |
| 155 | + Automatic: the agent sees the skill and uses it when relevant. On demand: installed but hidden from the agent; | |
| 156 | + you invoke it yourself with /name in Claude Code or $name in Codex. | |
| 157 | + </p> | |
| 158 | + </template> | |
| 159 | + </UTooltip> | |
| 160 | + </span> | |
| 161 | + </th> | |
| 162 | + <th class="w-36 px-3 py-2 text-left text-xs font-medium uppercase tracking-wide text-muted">Include</th> | |
| 163 | + </tr> | |
| 164 | + </thead> | |
| 165 | + <tbody class="divide-y divide-default"> | |
| 166 | + <tr v-for="r in rows" :key="`${r.type}/${r.name}`" :class="matchOf(config.exclude[r.type], r.name) ? 'opacity-60' : ''"> | |
| 167 | + <td class="px-3 py-2 align-middle"> | |
| 168 | + <div class="flex items-center gap-2"> | |
| 169 | + <UIcon :name="CAT_META[r.type].icon" class="size-4 shrink-0 text-muted" /> | |
| 170 | + <NuxtLink v-if="inCatalog(r.type, r.name)" :to="`/catalog/${r.type}/${encodeURIComponent(r.name)}`" class="truncate font-medium hover:underline">{{ r.name }}</NuxtLink> | |
| 171 | + <span v-else class="truncate font-medium">{{ r.name }}</span> | |
| 172 | + <UTooltip v-if="!inCatalog(r.type, r.name)" text="Not found in the catalog: it will be skipped"> | |
| 173 | + <UBadge label="missing" size="sm" color="warning" variant="subtle" /> | |
| 174 | + </UTooltip> | |
| 175 | + </div> | |
| 176 | + </td> | |
| 177 | + <td class="px-3 py-2 align-middle"> | |
| 178 | + <div class="flex flex-wrap gap-1"> | |
| 179 | + <UBadge v-for="v in r.via" :key="v" :label="v === 'add' ? 'added' : `preset: ${v}`" size="sm" :color="v === 'add' ? 'primary' : 'neutral'" variant="subtle" /> | |
| 180 | + </div> | |
| 181 | + </td> | |
| 182 | + <td class="px-3 py-2 align-middle"> | |
| 183 | + <template v-if="r.type === 'skill'"> | |
| 184 | + <UFieldGroup size="xs"> | |
| 185 | + <UButton | |
| 186 | + label="Automatic" | |
| 187 | + :color="matchOf(config.onDemand.skills, r.name) ? 'neutral' : 'primary'" | |
| 188 | + :variant="matchOf(config.onDemand.skills, r.name) ? 'outline' : 'soft'" | |
| 189 | + :disabled="matchOf(config.onDemand.skills, r.name)?.exact === false" | |
| 190 | + @click="setOnDemand(r.name, false)" | |
| 191 | + /> | |
| 192 | + <UButton | |
| 193 | + label="On demand" | |
| 194 | + :color="matchOf(config.onDemand.skills, r.name) ? 'primary' : 'neutral'" | |
| 195 | + :variant="matchOf(config.onDemand.skills, r.name) ? 'soft' : 'outline'" | |
| 196 | + @click="setOnDemand(r.name, true)" | |
| 197 | + /> | |
| 198 | + </UFieldGroup> | |
| 199 | + <p v-if="matchOf(config.onDemand.skills, r.name)?.exact === false" class="mt-0.5 font-mono text-[11px] text-muted">matches {{ matchOf(config.onDemand.skills, r.name)!.pattern }}</p> | |
| 200 | + </template> | |
| 201 | + <span v-else class="text-xs text-dimmed">-</span> | |
| 202 | + </td> | |
| 203 | + <td class="px-3 py-2 align-middle"> | |
| 204 | + <div class="flex items-center gap-2"> | |
| 205 | + <template v-if="r.via.some(v => v !== 'add')"> | |
| 206 | + <USwitch | |
| 207 | + size="sm" | |
| 208 | + :model-value="!matchOf(config.exclude[r.type], r.name)" | |
| 209 | + :disabled="matchOf(config.exclude[r.type], r.name)?.exact === false" | |
| 210 | + :aria-label="`Include ${r.name}`" | |
| 211 | + @update:model-value="(v: boolean) => setIncluded(r.type, r.name, v)" | |
| 212 | + /> | |
| 213 | + <span class="text-xs text-muted">{{ matchOf(config.exclude[r.type], r.name) ? (matchOf(config.exclude[r.type], r.name)!.exact ? 'excluded' : matchOf(config.exclude[r.type], r.name)!.pattern) : 'included' }}</span> | |
| 214 | + </template> | |
| 215 | + <UButton v-if="r.via.includes('add')" icon="i-lucide-x" size="xs" color="neutral" variant="ghost" aria-label="Remove from this scope" class="ml-auto" @click="removeAdded(r.type, r.name)" /> | |
| 216 | + </div> | |
| 217 | + </td> | |
| 218 | + </tr> | |
| 219 | + </tbody> | |
| 220 | + </table> | |
| 221 | + </div> | |
| 222 | + <p v-if="patterns.length" class="flex flex-wrap items-center gap-1.5 text-xs text-muted"> | |
| 223 | + Patterns kept as is: | |
| 224 | + <UBadge v-for="(p, i) in patterns" :key="i" :label="`${p.where}: ${p.pattern}`" size="sm" color="neutral" variant="outline" class="font-mono" /> | |
| 225 | + </p> | |
| 226 | + </section> | |
| 227 | + | |
| 228 | + <!-- Hide global (projects only) --> | |
| 229 | + <section v-if="!home" class="space-y-3"> | |
| 230 | + <div> | |
| 231 | + <h2 class="font-semibold">Hide global items</h2> | |
| 232 | + <p class="text-xs text-muted">Hide Home skills and MCP servers in this project so they do not clutter its context.</p> | |
| 233 | + </div> | |
| 234 | + <UAlert | |
| 235 | + color="neutral" | |
| 236 | + variant="subtle" | |
| 237 | + icon="i-lucide-info" | |
| 238 | + title="Agent support" | |
| 239 | + description="Claude Code can hide global skills (skillOverrides) and MCP servers (deniedMcpServers) per project. Codex cannot hide them per project: it can only disable them globally in ~/.codex/config.toml." | |
| 240 | + /> | |
| 241 | + <div class="flex flex-wrap items-center gap-3"> | |
| 242 | + <UTabs v-model="hideTab" :items="hideTabs" :content="false" variant="pill" color="neutral" size="sm" class="w-auto" /> | |
| 243 | + <UInput v-model="hideSearch" icon="i-lucide-search" size="sm" placeholder="Search..." class="w-64" /> | |
| 244 | + </div> | |
| 245 | + <USkeleton v-if="homeLoading && !homeInv" class="h-24 w-full" /> | |
| 246 | + <p v-else-if="!hideRows.length" class="rounded-lg border border-dashed border-default p-6 text-center text-sm text-muted">No global {{ hideTab === 'skills' ? 'skill' : 'MCP server' }} found in Home.</p> | |
| 247 | + <ul v-else class="divide-y divide-default overflow-hidden rounded-xl border border-default"> | |
| 248 | + <li v-for="[name, agents] in hideRows.slice(0, HIDE_LIMIT)" :key="name" class="flex items-center gap-3 px-3 py-1.5"> | |
| 249 | + <USwitch | |
| 250 | + size="sm" | |
| 251 | + :model-value="!!matchOf(config.hideGlobal[hideTab], name)" | |
| 252 | + :disabled="matchOf(config.hideGlobal[hideTab], name)?.exact === false" | |
| 253 | + :aria-label="`Hide ${name}`" | |
| 254 | + @update:model-value="(v: boolean) => setHidden(name, v)" | |
| 255 | + /> | |
| 256 | + <span class="min-w-0 flex-1 truncate text-sm font-medium">{{ name }}</span> | |
| 257 | + <span v-if="matchOf(config.hideGlobal[hideTab], name)?.exact === false" class="font-mono text-[11px] text-muted">matches {{ matchOf(config.hideGlobal[hideTab], name)!.pattern }}</span> | |
| 258 | + <div class="flex gap-1"> | |
| 259 | + <UTooltip v-for="a in [...agents]" :key="a" :text="a === 'claude' ? 'Can be hidden' : 'Codex cannot hide it per project'"> | |
| 260 | + <span :class="a === 'claude' ? '' : 'opacity-50'"><AgentBadge :agent="a" compact /></span> | |
| 261 | + </UTooltip> | |
| 262 | + </div> | |
| 263 | + </li> | |
| 264 | + <li v-if="hideRows.length > HIDE_LIMIT" class="p-2 text-center text-xs text-muted">{{ hideRows.length - HIDE_LIMIT }} more: refine the search.</li> | |
| 265 | + </ul> | |
| 266 | + </section> | |
| 267 | + | |
| 268 | + <!-- Add items --> | |
| 269 | + <UModal v-model:open="pickerOpen" title="Add items" description="Pick items from the catalog to enable in this scope." :ui="{ content: 'max-w-2xl' }"> | |
| 270 | + <template #body> | |
| 271 | + <ItemPicker v-model="pickerSel" :items="pickable" :types="ADD_TYPES" :locked="locked" height="h-80" /> | |
| 272 | + <p v-if="home" class="mt-2 text-xs text-muted">Home can receive skills, rules and agents. Its MCP servers and hooks are not managed in v1.</p> | |
| 273 | + </template> | |
| 274 | + <template #footer> | |
| 275 | + <div class="flex w-full justify-end gap-2"> | |
| 276 | + <UButton label="Cancel" color="neutral" variant="outline" @click="pickerOpen = false" /> | |
| 277 | + <UButton label="Apply selection" icon="i-lucide-check" @click="confirmPicker" /> | |
| 278 | + </div> | |
| 279 | + </template> | |
| 280 | + </UModal> | |
| 281 | + </div> | |
| 282 | +</template> | |
| new file mode 100644 | |||
| @@ -0,0 +1,282 @@ | |||
| 1 | +<script setup lang="ts"> | ||
| 2 | +import type { AgentId, CatalogItem, CatalogItemType, Preset, ScopeConfig } from '@skilly/core' | ||
| 3 | + | ||
| 4 | +/** "Configuration" tab: enabled items (exclude, automatic / on demand) and hidden global items. */ | ||
| 5 | +const props = defineProps<{ config: ScopeConfig, items: CatalogItem[], presets: Preset[], home: boolean }>() | ||
| 6 | +const emit = defineEmits<{ 'update:config': [c: ScopeConfig] }>() | ||
| 7 | + | ||
| 8 | +const { home: homeInv, refresh: refreshHome, loading: homeLoading } = useHomeInventory() | ||
| 9 | + | ||
| 10 | +// Home manages skills, rules and agents (its MCP servers and hooks are read-only); projects manage every type. | ||
| 11 | +const ADD_TYPES = computed<CatalogItemType[]>(() => (props.home ? ['skill', 'rule', 'agent'] : ['skill', 'rule', 'agent', 'mcp', 'hook'])) | ||
| 12 | + | ||
| 13 | +const resolved = computed(() => resolveItems(props.presets, props.config.presets, props.config.add)) | ||
| 14 | +const rows = computed(() => resolved.value.entries | ||
| 15 | + .slice() | ||
| 16 | + .sort((a, b) => CAT_TYPES.indexOf(a.type) - CAT_TYPES.indexOf(b.type) || a.name.localeCompare(b.name))) | ||
| 17 | +const inCatalog = (t: CatalogItemType, n: string) => props.items.some(i => i.type === t && i.name === n) | ||
| 18 | + | ||
| 19 | +function matchOf(list: string[] | undefined, name: string): { exact: boolean, pattern?: string } | null { | ||
| 20 | + const l = list ?? [] | ||
| 21 | + if (l.includes(name)) return { exact: true } | ||
| 22 | + const g = l.find(p => p.includes('*') && globMatch(p, name)) | ||
| 23 | + return g ? { exact: false, pattern: g } : null | ||
| 24 | +} | ||
| 25 | + | ||
| 26 | +function mutate(fn: (c: ScopeConfig) => void) { | ||
| 27 | + const c = cloneConfig(props.config) | ||
| 28 | + fn(c) | ||
| 29 | + emit('update:config', c) | ||
| 30 | +} | ||
| 31 | + | ||
| 32 | +function setIncluded(t: CatalogItemType, name: string, included: boolean) { | ||
| 33 | + mutate((c) => { | ||
| 34 | + const list = toggleIn(c.exclude[t] ?? [], name, !included) | ||
| 35 | + if (list.length) c.exclude[t] = list | ||
| 36 | + else delete c.exclude[t] | ||
| 37 | + }) | ||
| 38 | +} | ||
| 39 | +function removeAdded(t: CatalogItemType, name: string) { | ||
| 40 | + mutate((c) => { | ||
| 41 | + const list = (c.add[t] ?? []).filter(x => x !== name) | ||
| 42 | + if (list.length) c.add[t] = list | ||
| 43 | + else delete c.add[t] | ||
| 44 | + }) | ||
| 45 | +} | ||
| 46 | +function setOnDemand(name: string, on: boolean) { | ||
| 47 | + mutate((c) => { c.onDemand.skills = toggleIn(c.onDemand.skills, name, on) }) | ||
| 48 | +} | ||
| 49 | + | ||
| 50 | +/* ------------------------------------------------ Add items */ | ||
| 51 | + | ||
| 52 | +const pickerOpen = ref(false) | ||
| 53 | +const pickerSel = ref<Partial<Record<CatalogItemType, string[]>>>({}) | ||
| 54 | +const locked = computed(() => { | ||
| 55 | + const m: Record<string, string> = {} | ||
| 56 | + for (const e of resolved.value.entries) { | ||
| 57 | + const preset = e.via.find(v => v !== 'add') | ||
| 58 | + if (preset) m[`${e.type}/${e.name}`] = `via ${preset}` | ||
| 59 | + } | ||
| 60 | + return m | ||
| 61 | +}) | ||
| 62 | +const pickable = computed(() => props.items.filter(i => ADD_TYPES.value.includes(i.type))) | ||
| 63 | + | ||
| 64 | +function openPicker() { | ||
| 65 | + pickerSel.value = structuredClone(toRaw(props.config.add)) | ||
| 66 | + pickerOpen.value = true | ||
| 67 | +} | ||
| 68 | +function confirmPicker() { | ||
| 69 | + mutate((c) => { | ||
| 70 | + for (const t of ADD_TYPES.value) { | ||
| 71 | + const l = pickerSel.value[t] ?? [] | ||
| 72 | + if (l.length) c.add[t] = l | ||
| 73 | + else delete c.add[t] | ||
| 74 | + } | ||
| 75 | + }) | ||
| 76 | + pickerOpen.value = false | ||
| 77 | +} | ||
| 78 | + | ||
| 79 | +/* ------------------------------------------------ Hide global (projects) */ | ||
| 80 | + | ||
| 81 | +const hideTab = ref<'skills' | 'mcp'>('skills') | ||
| 82 | +const hideSearch = ref('') | ||
| 83 | +const HIDE_LIMIT = 100 | ||
| 84 | + | ||
| 85 | +onMounted(() => { | ||
| 86 | + if (!props.home && !homeInv.value) refreshHome() | ||
| 87 | +}) | ||
| 88 | + | ||
| 89 | +const homeNames = computed(() => { | ||
| 90 | + const out: Record<'skills' | 'mcp', Map<string, Set<AgentId>>> = { skills: new Map(), mcp: new Map() } | ||
| 91 | + for (const i of homeInv.value?.items ?? []) { | ||
| 92 | + const bucket = i.type === 'skill' ? out.skills : i.type === 'mcp' ? out.mcp : null | ||
| 93 | + if (!bucket) continue | ||
| 94 | + bucket.set(i.name, (bucket.get(i.name) ?? new Set()).add(i.agent)) | ||
| 95 | + } | ||
| 96 | + return out | ||
| 97 | +}) | ||
| 98 | +const hideRows = computed(() => { | ||
| 99 | + const q = hideSearch.value.trim().toLowerCase() | ||
| 100 | + return [...homeNames.value[hideTab.value].entries()] | ||
| 101 | + .filter(([n]) => !q || n.toLowerCase().includes(q)) | ||
| 102 | + .sort((a, b) => a[0].localeCompare(b[0])) | ||
| 103 | +}) | ||
| 104 | +const hideTabs = computed(() => [ | ||
| 105 | + { label: 'Skills', value: 'skills', icon: TYPE_META.skill.icon, badge: { label: String(homeNames.value.skills.size), color: 'neutral' as const, variant: 'subtle' as const } }, | ||
| 106 | + { label: 'MCP servers', value: 'mcp', icon: TYPE_META.mcp.icon, badge: { label: String(homeNames.value.mcp.size), color: 'neutral' as const, variant: 'subtle' as const } } | ||
| 107 | +]) | ||
| 108 | +function setHidden(name: string, on: boolean | 'indeterminate') { | ||
| 109 | + mutate((c) => { c.hideGlobal[hideTab.value] = toggleIn(c.hideGlobal[hideTab.value], name, on === true) }) | ||
| 110 | +} | ||
| 111 | + | ||
| 112 | +const patterns = computed(() => { | ||
| 113 | + const out: { where: string, pattern: string }[] = [] | ||
| 114 | + for (const [t, l] of Object.entries(props.config.exclude)) for (const p of l ?? []) if (p.includes('*')) out.push({ where: `exclude ${t}`, pattern: p }) | ||
| 115 | + for (const p of props.config.onDemand.skills) if (p.includes('*')) out.push({ where: 'on demand', pattern: p }) | ||
| 116 | + if (!props.home) { | ||
| 117 | + for (const p of props.config.hideGlobal.skills) if (p.includes('*')) out.push({ where: 'hide skills', pattern: p }) | ||
| 118 | + for (const p of props.config.hideGlobal.mcp) if (p.includes('*')) out.push({ where: 'hide mcp', pattern: p }) | ||
| 119 | + } | ||
| 120 | + return out | ||
| 121 | +}) | ||
| 122 | +</script> | ||
| 123 | + | ||
| 124 | +<template> | ||
| 125 | + <div class="space-y-8"> | ||
| 126 | + <!-- Enabled items --> | ||
| 127 | + <section class="space-y-3"> | ||
| 128 | + <div class="flex items-center gap-3"> | ||
| 129 | + <div> | ||
| 130 | + <h2 class="font-semibold">Enabled items <span class="font-normal text-muted">({{ rows.length }})</span></h2> | ||
| 131 | + <p class="text-xs text-muted">From enabled presets and items added one by one. Files are written when you apply the changes.</p> | ||
| 132 | + </div> | ||
| 133 | + <UButton class="ml-auto" label="Add items" icon="i-lucide-plus" :disabled="!items.length" @click="openPicker" /> | ||
| 134 | + </div> | ||
| 135 | + <UAlert v-if="resolved.missingPresets.length" color="warning" variant="subtle" icon="i-lucide-triangle-alert" :title="`Preset(s) not found in the catalog: ${resolved.missingPresets.join(', ')}`" /> | ||
| 136 | + <UAlert v-if="home" color="neutral" variant="subtle" icon="i-lucide-info" title="Home MCP servers and hooks are not managed in v1" description="Their entries in your user configuration are listed read-only in the MCP and Hooks tabs." /> | ||
| 137 | + | ||
| 138 | + <div v-if="!rows.length" class="flex flex-col items-center gap-2 rounded-xl border border-dashed border-default py-10 text-center"> | ||
| 139 | + <UIcon name="i-lucide-layers" class="size-8 text-dimmed" /> | ||
| 140 | + <p class="text-sm text-muted">Nothing enabled yet. Toggle a preset above, or add items one by one.</p> | ||
| 141 | + </div> | ||
| 142 | + <div v-else class="overflow-x-auto rounded-xl border border-default"> | ||
| 143 | + <table class="w-full min-w-[720px] table-fixed text-sm"> | ||
| 144 | + <thead class="border-b border-default bg-elevated/40"> | ||
| 145 | + <tr> | ||
| 146 | + <th class="w-[26%] px-3 py-2 text-left text-xs font-medium uppercase tracking-wide text-muted">Name</th> | ||
| 147 | + <th class="px-3 py-2 text-left text-xs font-medium uppercase tracking-wide text-muted">Source</th> | ||
| 148 | + <th class="w-52 px-3 py-2 text-left text-xs font-medium uppercase tracking-wide text-muted"> | ||
| 149 | + <span class="inline-flex items-center gap-1"> | ||
| 150 | + Mode | ||
| 151 | + <UTooltip :content="{ side: 'top' }"> | ||
| 152 | + <UIcon name="i-lucide-circle-help" class="size-3.5" /> | ||
| 153 | + <template #content> | ||
| 154 | + <p class="max-w-xs whitespace-normal text-xs normal-case"> | ||
| 155 | + Automatic: the agent sees the skill and uses it when relevant. On demand: installed but hidden from the agent; | ||
| 156 | + you invoke it yourself with /name in Claude Code or $name in Codex. | ||
| 157 | + </p> | ||
| 158 | + </template> | ||
| 159 | + </UTooltip> | ||
| 160 | + </span> | ||
| 161 | + </th> | ||
| 162 | + <th class="w-36 px-3 py-2 text-left text-xs font-medium uppercase tracking-wide text-muted">Include</th> | ||
| 163 | + </tr> | ||
| 164 | + </thead> | ||
| 165 | + <tbody class="divide-y divide-default"> | ||
| 166 | + <tr v-for="r in rows" :key="`${r.type}/${r.name}`" :class="matchOf(config.exclude[r.type], r.name) ? 'opacity-60' : ''"> | ||
| 167 | + <td class="px-3 py-2 align-middle"> | ||
| 168 | + <div class="flex items-center gap-2"> | ||
| 169 | + <UIcon :name="CAT_META[r.type].icon" class="size-4 shrink-0 text-muted" /> | ||
| 170 | + <NuxtLink v-if="inCatalog(r.type, r.name)" :to="`/catalog/${r.type}/${encodeURIComponent(r.name)}`" class="truncate font-medium hover:underline">{{ r.name }}</NuxtLink> | ||
| 171 | + <span v-else class="truncate font-medium">{{ r.name }}</span> | ||
| 172 | + <UTooltip v-if="!inCatalog(r.type, r.name)" text="Not found in the catalog: it will be skipped"> | ||
| 173 | + <UBadge label="missing" size="sm" color="warning" variant="subtle" /> | ||
| 174 | + </UTooltip> | ||
| 175 | + </div> | ||
| 176 | + </td> | ||
| 177 | + <td class="px-3 py-2 align-middle"> | ||
| 178 | + <div class="flex flex-wrap gap-1"> | ||
| 179 | + <UBadge v-for="v in r.via" :key="v" :label="v === 'add' ? 'added' : `preset: ${v}`" size="sm" :color="v === 'add' ? 'primary' : 'neutral'" variant="subtle" /> | ||
| 180 | + </div> | ||
| 181 | + </td> | ||
| 182 | + <td class="px-3 py-2 align-middle"> | ||
| 183 | + <template v-if="r.type === 'skill'"> | ||
| 184 | + <UFieldGroup size="xs"> | ||
| 185 | + <UButton | ||
| 186 | + label="Automatic" | ||
| 187 | + :color="matchOf(config.onDemand.skills, r.name) ? 'neutral' : 'primary'" | ||
| 188 | + :variant="matchOf(config.onDemand.skills, r.name) ? 'outline' : 'soft'" | ||
| 189 | + :disabled="matchOf(config.onDemand.skills, r.name)?.exact === false" | ||
| 190 | + @click="setOnDemand(r.name, false)" | ||
| 191 | + /> | ||
| 192 | + <UButton | ||
| 193 | + label="On demand" | ||
| 194 | + :color="matchOf(config.onDemand.skills, r.name) ? 'primary' : 'neutral'" | ||
| 195 | + :variant="matchOf(config.onDemand.skills, r.name) ? 'soft' : 'outline'" | ||
| 196 | + @click="setOnDemand(r.name, true)" | ||
| 197 | + /> | ||
| 198 | + </UFieldGroup> | ||
| 199 | + <p v-if="matchOf(config.onDemand.skills, r.name)?.exact === false" class="mt-0.5 font-mono text-[11px] text-muted">matches {{ matchOf(config.onDemand.skills, r.name)!.pattern }}</p> | ||
| 200 | + </template> | ||
| 201 | + <span v-else class="text-xs text-dimmed">-</span> | ||
| 202 | + </td> | ||
| 203 | + <td class="px-3 py-2 align-middle"> | ||
| 204 | + <div class="flex items-center gap-2"> | ||
| 205 | + <template v-if="r.via.some(v => v !== 'add')"> | ||
| 206 | + <USwitch | ||
| 207 | + size="sm" | ||
| 208 | + :model-value="!matchOf(config.exclude[r.type], r.name)" | ||
| 209 | + :disabled="matchOf(config.exclude[r.type], r.name)?.exact === false" | ||
| 210 | + :aria-label="`Include ${r.name}`" | ||
| 211 | + @update:model-value="(v: boolean) => setIncluded(r.type, r.name, v)" | ||
| 212 | + /> | ||
| 213 | + <span class="text-xs text-muted">{{ matchOf(config.exclude[r.type], r.name) ? (matchOf(config.exclude[r.type], r.name)!.exact ? 'excluded' : matchOf(config.exclude[r.type], r.name)!.pattern) : 'included' }}</span> | ||
| 214 | + </template> | ||
| 215 | + <UButton v-if="r.via.includes('add')" icon="i-lucide-x" size="xs" color="neutral" variant="ghost" aria-label="Remove from this scope" class="ml-auto" @click="removeAdded(r.type, r.name)" /> | ||
| 216 | + </div> | ||
| 217 | + </td> | ||
| 218 | + </tr> | ||
| 219 | + </tbody> | ||
| 220 | + </table> | ||
| 221 | + </div> | ||
| 222 | + <p v-if="patterns.length" class="flex flex-wrap items-center gap-1.5 text-xs text-muted"> | ||
| 223 | + Patterns kept as is: | ||
| 224 | + <UBadge v-for="(p, i) in patterns" :key="i" :label="`${p.where}: ${p.pattern}`" size="sm" color="neutral" variant="outline" class="font-mono" /> | ||
| 225 | + </p> | ||
| 226 | + </section> | ||
| 227 | + | ||
| 228 | + <!-- Hide global (projects only) --> | ||
| 229 | + <section v-if="!home" class="space-y-3"> | ||
| 230 | + <div> | ||
| 231 | + <h2 class="font-semibold">Hide global items</h2> | ||
| 232 | + <p class="text-xs text-muted">Hide Home skills and MCP servers in this project so they do not clutter its context.</p> | ||
| 233 | + </div> | ||
| 234 | + <UAlert | ||
| 235 | + color="neutral" | ||
| 236 | + variant="subtle" | ||
| 237 | + icon="i-lucide-info" | ||
| 238 | + title="Agent support" | ||
| 239 | + description="Claude Code can hide global skills (skillOverrides) and MCP servers (deniedMcpServers) per project. Codex cannot hide them per project: it can only disable them globally in ~/.codex/config.toml." | ||
| 240 | + /> | ||
| 241 | + <div class="flex flex-wrap items-center gap-3"> | ||
| 242 | + <UTabs v-model="hideTab" :items="hideTabs" :content="false" variant="pill" color="neutral" size="sm" class="w-auto" /> | ||
| 243 | + <UInput v-model="hideSearch" icon="i-lucide-search" size="sm" placeholder="Search..." class="w-64" /> | ||
| 244 | + </div> | ||
| 245 | + <USkeleton v-if="homeLoading && !homeInv" class="h-24 w-full" /> | ||
| 246 | + <p v-else-if="!hideRows.length" class="rounded-lg border border-dashed border-default p-6 text-center text-sm text-muted">No global {{ hideTab === 'skills' ? 'skill' : 'MCP server' }} found in Home.</p> | ||
| 247 | + <ul v-else class="divide-y divide-default overflow-hidden rounded-xl border border-default"> | ||
| 248 | + <li v-for="[name, agents] in hideRows.slice(0, HIDE_LIMIT)" :key="name" class="flex items-center gap-3 px-3 py-1.5"> | ||
| 249 | + <USwitch | ||
| 250 | + size="sm" | ||
| 251 | + :model-value="!!matchOf(config.hideGlobal[hideTab], name)" | ||
| 252 | + :disabled="matchOf(config.hideGlobal[hideTab], name)?.exact === false" | ||
| 253 | + :aria-label="`Hide ${name}`" | ||
| 254 | + @update:model-value="(v: boolean) => setHidden(name, v)" | ||
| 255 | + /> | ||
| 256 | + <span class="min-w-0 flex-1 truncate text-sm font-medium">{{ name }}</span> | ||
| 257 | + <span v-if="matchOf(config.hideGlobal[hideTab], name)?.exact === false" class="font-mono text-[11px] text-muted">matches {{ matchOf(config.hideGlobal[hideTab], name)!.pattern }}</span> | ||
| 258 | + <div class="flex gap-1"> | ||
| 259 | + <UTooltip v-for="a in [...agents]" :key="a" :text="a === 'claude' ? 'Can be hidden' : 'Codex cannot hide it per project'"> | ||
| 260 | + <span :class="a === 'claude' ? '' : 'opacity-50'"><AgentBadge :agent="a" compact /></span> | ||
| 261 | + </UTooltip> | ||
| 262 | + </div> | ||
| 263 | + </li> | ||
| 264 | + <li v-if="hideRows.length > HIDE_LIMIT" class="p-2 text-center text-xs text-muted">{{ hideRows.length - HIDE_LIMIT }} more: refine the search.</li> | ||
| 265 | + </ul> | ||
| 266 | + </section> | ||
| 267 | + | ||
| 268 | + <!-- Add items --> | ||
| 269 | + <UModal v-model:open="pickerOpen" title="Add items" description="Pick items from the catalog to enable in this scope." :ui="{ content: 'max-w-2xl' }"> | ||
| 270 | + <template #body> | ||
| 271 | + <ItemPicker v-model="pickerSel" :items="pickable" :types="ADD_TYPES" :locked="locked" height="h-80" /> | ||
| 272 | + <p v-if="home" class="mt-2 text-xs text-muted">Home can receive skills, rules and agents. Its MCP servers and hooks are not managed in v1.</p> | ||
| 273 | + </template> | ||
| 274 | + <template #footer> | ||
| 275 | + <div class="flex w-full justify-end gap-2"> | ||
| 276 | + <UButton label="Cancel" color="neutral" variant="outline" @click="pickerOpen = false" /> | ||
| 277 | + <UButton label="Apply selection" icon="i-lucide-check" @click="confirmPicker" /> | ||
| 278 | + </div> | ||
| 279 | + </template> | ||
| 280 | + </UModal> | ||
| 281 | + </div> | ||
| 282 | +</template> | ||
modified
packages/ui/app/components/ScopeView.vue +231 -35 | @@ -1,20 +1,29 @@ | ||
| 1 | 1 | <script setup lang="ts"> |
| 2 | -import type { AgentId, InventoryItem, ItemType, ScopeInventory } from '@skilly/core' | |
| 2 | +import type { AgentId, CatalogRef, InventoryItem, ItemType, ScopeConfig, ScopeState } from '@skilly/core' | |
| 3 | 3 | |
| 4 | 4 | const props = defineProps<{ kind: 'home' | 'project', projectId?: string }>() |
| 5 | 5 | |
| 6 | 6 | const { request } = useApi() |
| 7 | 7 | const { projects, remove, refresh: refreshProjects } = useProjects() |
| 8 | +const catalog = useCatalog() | |
| 8 | 9 | const toast = useToast() |
| 9 | 10 | const router = useRouter() |
| 10 | 11 | |
| 11 | -const inventory = ref<ScopeInventory | null>(null) | |
| 12 | +const scopeId = computed(() => (props.kind === 'home' ? 'home' : props.projectId!)) | |
| 13 | +const state = ref<ScopeState | null>(null) | |
| 14 | +const inventory = computed(() => state.value?.inventory ?? null) | |
| 15 | +const statuses = computed(() => state.value?.statuses ?? {}) | |
| 16 | +const plan = computed(() => state.value?.plan ?? null) | |
| 12 | 17 | const loading = ref(true) |
| 13 | 18 | const error = ref<string | null>(null) |
| 14 | 19 | |
| 20 | +// Local editable copy of the config: edits are shown at once and saved (debounced) in the background. | |
| 21 | +const config = ref<ScopeConfig | null>(null) | |
| 22 | +const saving = ref(false) | |
| 23 | + | |
| 15 | 24 | const agentFilter = ref<'all' | AgentId>('all') |
| 16 | 25 | const search = ref('') |
| 17 | -const tab = ref<ItemType>('skill') | |
| 26 | +const tab = ref<ItemType | 'config'>('skill') | |
| 18 | 27 | const selected = ref<InventoryItem | null>(null) |
| 19 | 28 | const detailOpen = ref(false) |
| 20 | 29 | const removeOpen = ref(false) |
| @@ -29,8 +38,8 @@ const folderMissing = computed(() => props.kind === 'project' && project.value & | ||
| 29 | 38 | async function load(showLoading = true) { |
| 30 | 39 | if (showLoading) loading.value = true |
| 31 | 40 | try { |
| 32 | - const url = props.kind === 'home' ? '/api/home' : `/api/projects/${encodeURIComponent(props.projectId!)}/inventory` | |
| 33 | - inventory.value = await request<ScopeInventory>(url, { silent: true }) | |
| 41 | + state.value = await request<ScopeState>(`/api/scopes/${encodeURIComponent(scopeId.value)}/state`, { silent: true }) | |
| 42 | + config.value = state.value.config ? cloneConfig(state.value.config) : null | |
| 34 | 43 | error.value = null |
| 35 | 44 | } catch (e) { |
| 36 | 45 | error.value = (e as Error).message |
| @@ -46,7 +55,53 @@ async function rescan() { | ||
| 46 | 55 | if (!error.value) toast.add({ title: 'Rescan complete', icon: 'i-lucide-check', color: 'success' }) |
| 47 | 56 | } |
| 48 | 57 | |
| 49 | -onMounted(() => load()) | |
| 58 | +onMounted(async () => { | |
| 59 | + if (!catalog.data.value) catalog.refresh() | |
| 60 | + await load() | |
| 61 | + if (config.value) tab.value = 'config' | |
| 62 | +}) | |
| 63 | + | |
| 64 | +/* ------------------------------------------------ Config */ | |
| 65 | + | |
| 66 | +let timer: ReturnType<typeof setTimeout> | undefined | |
| 67 | +let seq = 0 | |
| 68 | + | |
| 69 | +function saveConfig(next: ScopeConfig, immediate = false) { | |
| 70 | + config.value = next | |
| 71 | + const mine = ++seq | |
| 72 | + saving.value = true | |
| 73 | + clearTimeout(timer) | |
| 74 | + const run = async () => { | |
| 75 | + try { | |
| 76 | + const res = await request<ScopeState>(`/api/scopes/${encodeURIComponent(scopeId.value)}/config`, { method: 'PUT', body: next, silent: true }) | |
| 77 | + state.value = res | |
| 78 | + // Only adopt the server copy when no newer edit happened meanwhile. | |
| 79 | + if (mine === seq) config.value = res.config ? cloneConfig(res.config) : next | |
| 80 | + } catch (e) { | |
| 81 | + toast.add({ title: 'Could not save the configuration', description: (e as Error).message, color: 'error', icon: 'i-lucide-circle-alert' }) | |
| 82 | + if (mine === seq) await load(false) | |
| 83 | + } finally { | |
| 84 | + if (mine === seq) saving.value = false | |
| 85 | + } | |
| 86 | + } | |
| 87 | + if (immediate) run() | |
| 88 | + else timer = setTimeout(run, 400) | |
| 89 | +} | |
| 90 | +onBeforeUnmount(() => clearTimeout(timer)) | |
| 91 | + | |
| 92 | +const setupTargets = ref<AgentId[]>([]) | |
| 93 | +watch(inventory, (inv) => { | |
| 94 | + if (!setupTargets.value.length) setupTargets.value = inv?.agentsDetected.length ? [...inv.agentsDetected] : ['claude', 'codex'] | |
| 95 | +}, { immediate: true }) | |
| 96 | +function toggleSetupTarget(a: AgentId, on: boolean | 'indeterminate') { | |
| 97 | + setupTargets.value = on === true ? [...new Set([...setupTargets.value, a])] : setupTargets.value.filter(x => x !== a) | |
| 98 | +} | |
| 99 | +async function enableManagement() { | |
| 100 | + saveConfig(emptyConfig(setupTargets.value), true) | |
| 101 | + tab.value = 'config' | |
| 102 | +} | |
| 103 | + | |
| 104 | +/* ------------------------------------------------ Inventory */ | |
| 50 | 105 | |
| 51 | 106 | const matches = (item: InventoryItem, q: string) => { |
| 52 | 107 | const d = item.details |
| @@ -76,12 +131,19 @@ const byType = computed(() => { | ||
| 76 | 131 | return m |
| 77 | 132 | }) |
| 78 | 133 | |
| 79 | -const tabs = computed(() => TYPE_ORDER.map(t => ({ | |
| 80 | - label: TYPE_META[t].label, | |
| 81 | - icon: TYPE_META[t].icon, | |
| 82 | - value: t, | |
| 83 | - badge: { label: String(byType.value[t].length), color: 'neutral' as const, variant: 'subtle' as const } | |
| 84 | -}))) | |
| 134 | +const enabledCount = computed(() => (config.value ? resolveItems(catalog.presets.value, config.value.presets, config.value.add).entries.length : 0)) | |
| 135 | + | |
| 136 | +const tabs = computed(() => [ | |
| 137 | + ...(config.value | |
| 138 | + ? [{ label: 'Configuration', icon: 'i-lucide-sliders-horizontal', value: 'config', badge: { label: String(enabledCount.value), color: 'neutral' as const, variant: 'subtle' as const } }] | |
| 139 | + : []), | |
| 140 | + ...TYPE_ORDER.map(t => ({ | |
| 141 | + label: TYPE_META[t].label, | |
| 142 | + icon: TYPE_META[t].icon, | |
| 143 | + value: t, | |
| 144 | + badge: { label: String(byType.value[t].length), color: 'neutral' as const, variant: 'subtle' as const } | |
| 145 | + })) | |
| 146 | +]) | |
| 85 | 147 | |
| 86 | 148 | const agentOptions = [ |
| 87 | 149 | { label: 'All', value: 'all' }, |
| @@ -117,6 +179,51 @@ async function confirmRemove() { | ||
| 117 | 179 | removing.value = false |
| 118 | 180 | } |
| 119 | 181 | } |
| 182 | + | |
| 183 | +/* ------------------------------------------------ Selection & actions */ | |
| 184 | + | |
| 185 | +const selection = ref<string[]>([]) | |
| 186 | +watch(tab, () => { selection.value = [] }) | |
| 187 | +const selectedItems = computed(() => (inventory.value?.items ?? []).filter(i => selection.value.includes(i.id))) | |
| 188 | +const importable = computed(() => selectedItems.value.filter(i => ['not-in-catalog', 'differs-from-catalog'].includes(statuses.value[i.id]?.state ?? ''))) | |
| 189 | +const removable = computed(() => (props.kind === 'home' ? selectedItems.value.filter(i => ['skill', 'rule', 'agent'].includes(i.type)) : [])) | |
| 190 | + | |
| 191 | +const importItems = ref<InventoryItem[]>([]) | |
| 192 | +const importOpen = ref(false) | |
| 193 | +function askImport(items: InventoryItem[]) { | |
| 194 | + importItems.value = items | |
| 195 | + importOpen.value = true | |
| 196 | +} | |
| 197 | +const globalItems = ref<InventoryItem[]>([]) | |
| 198 | +const globalOpen = ref(false) | |
| 199 | +function askRemoveGlobal(items: InventoryItem[]) { | |
| 200 | + globalItems.value = items | |
| 201 | + globalOpen.value = true | |
| 202 | +} | |
| 203 | +function openInCatalog(ref: CatalogRef) { | |
| 204 | + navigateTo(`/catalog/${ref.type}/${encodeURIComponent(ref.name)}`) | |
| 205 | +} | |
| 206 | +async function afterCatalogChange() { | |
| 207 | + selection.value = [] | |
| 208 | + await Promise.all([load(false), catalog.refresh()]) | |
| 209 | +} | |
| 210 | + | |
| 211 | +/* ------------------------------------------------ Plan */ | |
| 212 | + | |
| 213 | +const pending = computed(() => (plan.value?.actions ?? []).filter(a => a.kind !== 'unchanged')) | |
| 214 | +const conflictCount = computed(() => pending.value.filter(a => a.kind === 'conflict').length) | |
| 215 | +const applyOpen = ref(false) | |
| 216 | +function onApplied(next: ScopeState) { | |
| 217 | + state.value = next | |
| 218 | + config.value = next.config ? cloneConfig(next.config) : null | |
| 219 | + selection.value = [] | |
| 220 | + if (props.kind === 'project') refreshProjects() | |
| 221 | +} | |
| 222 | + | |
| 223 | +watch(config, (c) => { if (!c && tab.value === 'config') tab.value = 'skill' }) | |
| 224 | + | |
| 225 | +const homeReadOnly = computed(() => props.kind === 'home' && (tab.value === 'mcp' || tab.value === 'hook')) | |
| 226 | +const AGENT_LIST: AgentId[] = ['claude', 'codex'] | |
| 120 | 227 | </script> |
| 121 | 228 | |
| 122 | 229 | <template> |
| @@ -175,7 +282,7 @@ async function confirmRemove() { | ||
| 175 | 282 | color="error" |
| 176 | 283 | variant="subtle" |
| 177 | 284 | icon="i-lucide-circle-alert" |
| 178 | - title="Could not load the inventory" | |
| 285 | + title="Could not load this scope" | |
| 179 | 286 | :description="error" |
| 180 | 287 | :actions="[{ label: 'Retry', color: 'neutral', variant: 'outline', onClick: () => load() }]" |
| 181 | 288 | /> |
| @@ -237,39 +344,128 @@ async function confirmRemove() { | ||
| 237 | 344 | </template> |
| 238 | 345 | </UAlert> |
| 239 | 346 | |
| 240 | - <!-- Filters --> | |
| 241 | - <div class="flex flex-wrap items-center gap-3"> | |
| 242 | - <UTabs | |
| 243 | - v-model="agentFilter" | |
| 244 | - :items="agentOptions" | |
| 245 | - :content="false" | |
| 246 | - size="sm" | |
| 247 | - variant="pill" | |
| 248 | - color="neutral" | |
| 249 | - class="w-auto" | |
| 250 | - /> | |
| 251 | - <UInput | |
| 252 | - v-model="search" | |
| 253 | - icon="i-lucide-search" | |
| 254 | - placeholder="Search name, description, command..." | |
| 255 | - class="w-72" | |
| 256 | - > | |
| 257 | - <template v-if="search" #trailing> | |
| 258 | - <UButton icon="i-lucide-x" size="xs" color="neutral" variant="link" aria-label="Clear search" @click="search = ''" /> | |
| 259 | - </template> | |
| 260 | - </UInput> | |
| 347 | + <!-- Not managed yet --> | |
| 348 | + <div v-if="!config" class="flex flex-wrap items-center gap-x-6 gap-y-3 rounded-xl border border-dashed border-default p-4"> | |
| 349 | + <div class="min-w-0 flex-1 basis-72"> | |
| 350 | + <h2 class="flex items-center gap-2 font-semibold"><UIcon name="i-lucide-wand-sparkles" class="size-4 text-primary" />{{ kind === 'home' ? 'Manage Home with skilly' : 'Manage this project with skilly' }}</h2> | |
| 351 | + <p class="text-sm text-muted"> | |
| 352 | + Enable presets and catalog items, switch skills to on demand and keep files in sync. | |
| 353 | + {{ kind === 'home' ? 'Settings are stored in ~/.skilly/home.json.' : 'A skilly.json file is created at the project root; commit it with the project.' }} | |
| 354 | + </p> | |
| 355 | + </div> | |
| 356 | + <div class="flex items-center gap-4"> | |
| 357 | + <span class="text-xs font-medium uppercase tracking-wide text-muted">Targets</span> | |
| 358 | + <UCheckbox v-for="a in AGENT_LIST" :key="a" :model-value="setupTargets.includes(a)" :label="AGENT_LABELS[a]" @update:model-value="(v) => toggleSetupTarget(a, v)" /> | |
| 359 | + </div> | |
| 360 | + <UButton label="Start managing" icon="i-lucide-check" :loading="saving" :disabled="!setupTargets.length" @click="enableManagement" /> | |
| 261 | 361 | </div> |
| 262 | 362 | |
| 363 | + <!-- Targets and presets --> | |
| 364 | + <ScopeConfigBar | |
| 365 | + v-else | |
| 366 | + :config="config" | |
| 367 | + :presets="catalog.presets.value" | |
| 368 | + :catalog-exists="catalog.exists.value" | |
| 369 | + :saving="saving" | |
| 370 | + @update:config="saveConfig" | |
| 371 | + /> | |
| 372 | + | |
| 263 | 373 | <!-- Type tabs --> |
| 264 | 374 | <UTabs v-model="tab" :items="tabs" :content="false" variant="link" class="w-full" /> |
| 265 | 375 | |
| 266 | - <ItemTable :type="tab" :items="byType[tab]" :filtered="!!search || agentFilter !== 'all'" @select="open" /> | |
| 376 | + <!-- Configuration tab --> | |
| 377 | + <ScopeConfigPanel | |
| 378 | + v-if="tab === 'config' && config" | |
| 379 | + :config="config" | |
| 380 | + :items="catalog.items.value" | |
| 381 | + :presets="catalog.presets.value" | |
| 382 | + :home="kind === 'home'" | |
| 383 | + @update:config="saveConfig" | |
| 384 | + /> | |
| 385 | + | |
| 386 | + <!-- Inventory tabs --> | |
| 387 | + <template v-else> | |
| 388 | + <div class="flex flex-wrap items-center gap-3"> | |
| 389 | + <UTabs | |
| 390 | + v-model="agentFilter" | |
| 391 | + :items="agentOptions" | |
| 392 | + :content="false" | |
| 393 | + size="sm" | |
| 394 | + variant="pill" | |
| 395 | + color="neutral" | |
| 396 | + class="w-auto" | |
| 397 | + /> | |
| 398 | + <UInput | |
| 399 | + v-model="search" | |
| 400 | + icon="i-lucide-search" | |
| 401 | + placeholder="Search name, description, command..." | |
| 402 | + class="w-72" | |
| 403 | + > | |
| 404 | + <template v-if="search" #trailing> | |
| 405 | + <UButton icon="i-lucide-x" size="xs" color="neutral" variant="link" aria-label="Clear search" @click="search = ''" /> | |
| 406 | + </template> | |
| 407 | + </UInput> | |
| 408 | + </div> | |
| 409 | + | |
| 410 | + <UAlert | |
| 411 | + v-if="homeReadOnly" | |
| 412 | + color="neutral" | |
| 413 | + variant="subtle" | |
| 414 | + icon="i-lucide-info" | |
| 415 | + :title="`Home ${tab === 'mcp' ? 'MCP servers' : 'hooks'} are not managed in v1`" | |
| 416 | + description="They are entries of your user configuration files, shown read-only. You can still add them to the catalog." | |
| 417 | + /> | |
| 418 | + | |
| 419 | + <!-- Bulk actions --> | |
| 420 | + <div v-if="selection.length" class="flex flex-wrap items-center gap-2 rounded-lg border border-primary/40 bg-primary/5 px-3 py-2 text-sm"> | |
| 421 | + <span class="font-medium">{{ selection.length }} selected</span> | |
| 422 | + <UButton v-if="importable.length" :label="`Add ${importable.length} to catalog`" icon="i-lucide-library" size="xs" @click="askImport(importable)" /> | |
| 423 | + <UButton v-if="removable.length" :label="`Remove ${removable.length} from global`" icon="i-lucide-globe-lock" size="xs" color="error" variant="soft" @click="askRemoveGlobal(removable)" /> | |
| 424 | + <UButton label="Clear" size="xs" color="neutral" variant="ghost" class="ml-auto" @click="selection = []" /> | |
| 425 | + </div> | |
| 426 | + | |
| 427 | + <ItemTable | |
| 428 | + v-model:selection="selection" | |
| 429 | + :type="tab as ItemType" | |
| 430 | + :items="byType[tab as ItemType]" | |
| 431 | + :filtered="!!search || agentFilter !== 'all'" | |
| 432 | + :statuses="statuses" | |
| 433 | + :home="kind === 'home'" | |
| 434 | + actionable | |
| 435 | + @select="open" | |
| 436 | + @add-to-catalog="(i) => askImport([i])" | |
| 437 | + @open-catalog="openInCatalog" | |
| 438 | + @remove-global="(i) => askRemoveGlobal([i])" | |
| 439 | + /> | |
| 440 | + </template> | |
| 441 | + | |
| 442 | + <!-- Pending changes --> | |
| 443 | + <div | |
| 444 | + v-if="plan && (pending.length || plan.missing.length)" | |
| 445 | + class="sticky bottom-0 z-10 flex flex-wrap items-center gap-3 rounded-xl border bg-default/95 px-4 py-3 shadow-lg backdrop-blur" | |
| 446 | + :class="conflictCount ? 'border-warning' : 'border-primary'" | |
| 447 | + > | |
| 448 | + <UIcon :name="conflictCount ? 'i-lucide-triangle-alert' : 'i-lucide-git-pull-request-arrow'" class="size-5 shrink-0" :class="conflictCount ? 'text-warning' : 'text-primary'" /> | |
| 449 | + <div class="min-w-0 flex-1 text-sm"> | |
| 450 | + <template v-if="pending.length"> | |
| 451 | + <span class="font-semibold">{{ pending.length }} change{{ pending.length > 1 ? 's' : '' }} to apply</span> | |
| 452 | + <span v-if="conflictCount" class="text-warning"> - {{ conflictCount }} conflict{{ conflictCount > 1 ? 's' : '' }} to resolve</span> | |
| 453 | + </template> | |
| 454 | + <span v-else class="font-semibold">{{ plan.missing.length }} item(s) referenced but not found in the catalog</span> | |
| 455 | + <span v-if="saving" class="ml-2 text-xs text-muted">saving...</span> | |
| 456 | + </div> | |
| 457 | + <UButton label="Review and apply" icon="i-lucide-list-checks" :disabled="saving || !pending.length" @click="applyOpen = true" /> | |
| 458 | + </div> | |
| 267 | 459 | </div> |
| 268 | 460 | </template> |
| 269 | 461 | </UDashboardPanel> |
| 270 | 462 | |
| 271 | 463 | <ItemDetail v-model:open="detailOpen" :item="selected" /> |
| 272 | 464 | |
| 465 | + <ApplyModal v-if="plan" v-model:open="applyOpen" :scope="scopeId" :plan="plan" :home="kind === 'home'" @applied="onApplied" /> | |
| 466 | + <AddToCatalogModal v-model:open="importOpen" :scope="scopeId" :items="importItems" :statuses="statuses" @done="afterCatalogChange" /> | |
| 467 | + <RemoveFromGlobalModal v-model:open="globalOpen" :items="globalItems" @done="afterCatalogChange" /> | |
| 468 | + | |
| 273 | 469 | <UModal v-model:open="removeOpen" title="Remove project from skilly" description="This only forgets the project in skilly."> |
| 274 | 470 | <template #body> |
| 275 | 471 | <p class="text-sm"> |
| @@ -1,20 +1,29 @@ | |||
| 1 | <script setup lang="ts"> | 1 | <script setup lang="ts"> |
| 2 | -import type { AgentId, InventoryItem, ItemType, ScopeInventory } from '@skilly/core' | 2 | +import type { AgentId, CatalogRef, InventoryItem, ItemType, ScopeConfig, ScopeState } from '@skilly/core' |
| 3 | 3 | ||
| 4 | const props = defineProps<{ kind: 'home' | 'project', projectId?: string }>() | 4 | const props = defineProps<{ kind: 'home' | 'project', projectId?: string }>() |
| 5 | 5 | ||
| 6 | const { request } = useApi() | 6 | const { request } = useApi() |
| 7 | const { projects, remove, refresh: refreshProjects } = useProjects() | 7 | const { projects, remove, refresh: refreshProjects } = useProjects() |
| 8 | +const catalog = useCatalog() | ||
| 8 | const toast = useToast() | 9 | const toast = useToast() |
| 9 | const router = useRouter() | 10 | const router = useRouter() |
| 10 | 11 | ||
| 11 | -const inventory = ref<ScopeInventory | null>(null) | 12 | +const scopeId = computed(() => (props.kind === 'home' ? 'home' : props.projectId!)) |
| 13 | +const state = ref<ScopeState | null>(null) | ||
| 14 | +const inventory = computed(() => state.value?.inventory ?? null) | ||
| 15 | +const statuses = computed(() => state.value?.statuses ?? {}) | ||
| 16 | +const plan = computed(() => state.value?.plan ?? null) | ||
| 12 | const loading = ref(true) | 17 | const loading = ref(true) |
| 13 | const error = ref<string | null>(null) | 18 | const error = ref<string | null>(null) |
| 14 | 19 | ||
| 20 | +// Local editable copy of the config: edits are shown at once and saved (debounced) in the background. | ||
| 21 | +const config = ref<ScopeConfig | null>(null) | ||
| 22 | +const saving = ref(false) | ||
| 23 | + | ||
| 15 | const agentFilter = ref<'all' | AgentId>('all') | 24 | const agentFilter = ref<'all' | AgentId>('all') |
| 16 | const search = ref('') | 25 | const search = ref('') |
| 17 | -const tab = ref<ItemType>('skill') | 26 | +const tab = ref<ItemType | 'config'>('skill') |
| 18 | const selected = ref<InventoryItem | null>(null) | 27 | const selected = ref<InventoryItem | null>(null) |
| 19 | const detailOpen = ref(false) | 28 | const detailOpen = ref(false) |
| 20 | const removeOpen = ref(false) | 29 | const removeOpen = ref(false) |
| @@ -29,8 +38,8 @@ const folderMissing = computed(() => props.kind === 'project' && project.value & | |||
| 29 | async function load(showLoading = true) { | 38 | async function load(showLoading = true) { |
| 30 | if (showLoading) loading.value = true | 39 | if (showLoading) loading.value = true |
| 31 | try { | 40 | try { |
| 32 | - const url = props.kind === 'home' ? '/api/home' : `/api/projects/${encodeURIComponent(props.projectId!)}/inventory` | 41 | + state.value = await request<ScopeState>(`/api/scopes/${encodeURIComponent(scopeId.value)}/state`, { silent: true }) |
| 33 | - inventory.value = await request<ScopeInventory>(url, { silent: true }) | 42 | + config.value = state.value.config ? cloneConfig(state.value.config) : null |
| 34 | error.value = null | 43 | error.value = null |
| 35 | } catch (e) { | 44 | } catch (e) { |
| 36 | error.value = (e as Error).message | 45 | error.value = (e as Error).message |
| @@ -46,7 +55,53 @@ async function rescan() { | |||
| 46 | if (!error.value) toast.add({ title: 'Rescan complete', icon: 'i-lucide-check', color: 'success' }) | 55 | if (!error.value) toast.add({ title: 'Rescan complete', icon: 'i-lucide-check', color: 'success' }) |
| 47 | } | 56 | } |
| 48 | 57 | ||
| 49 | -onMounted(() => load()) | 58 | +onMounted(async () => { |
| 59 | + if (!catalog.data.value) catalog.refresh() | ||
| 60 | + await load() | ||
| 61 | + if (config.value) tab.value = 'config' | ||
| 62 | +}) | ||
| 63 | + | ||
| 64 | +/* ------------------------------------------------ Config */ | ||
| 65 | + | ||
| 66 | +let timer: ReturnType<typeof setTimeout> | undefined | ||
| 67 | +let seq = 0 | ||
| 68 | + | ||
| 69 | +function saveConfig(next: ScopeConfig, immediate = false) { | ||
| 70 | + config.value = next | ||
| 71 | + const mine = ++seq | ||
| 72 | + saving.value = true | ||
| 73 | + clearTimeout(timer) | ||
| 74 | + const run = async () => { | ||
| 75 | + try { | ||
| 76 | + const res = await request<ScopeState>(`/api/scopes/${encodeURIComponent(scopeId.value)}/config`, { method: 'PUT', body: next, silent: true }) | ||
| 77 | + state.value = res | ||
| 78 | + // Only adopt the server copy when no newer edit happened meanwhile. | ||
| 79 | + if (mine === seq) config.value = res.config ? cloneConfig(res.config) : next | ||
| 80 | + } catch (e) { | ||
| 81 | + toast.add({ title: 'Could not save the configuration', description: (e as Error).message, color: 'error', icon: 'i-lucide-circle-alert' }) | ||
| 82 | + if (mine === seq) await load(false) | ||
| 83 | + } finally { | ||
| 84 | + if (mine === seq) saving.value = false | ||
| 85 | + } | ||
| 86 | + } | ||
| 87 | + if (immediate) run() | ||
| 88 | + else timer = setTimeout(run, 400) | ||
| 89 | +} | ||
| 90 | +onBeforeUnmount(() => clearTimeout(timer)) | ||
| 91 | + | ||
| 92 | +const setupTargets = ref<AgentId[]>([]) | ||
| 93 | +watch(inventory, (inv) => { | ||
| 94 | + if (!setupTargets.value.length) setupTargets.value = inv?.agentsDetected.length ? [...inv.agentsDetected] : ['claude', 'codex'] | ||
| 95 | +}, { immediate: true }) | ||
| 96 | +function toggleSetupTarget(a: AgentId, on: boolean | 'indeterminate') { | ||
| 97 | + setupTargets.value = on === true ? [...new Set([...setupTargets.value, a])] : setupTargets.value.filter(x => x !== a) | ||
| 98 | +} | ||
| 99 | +async function enableManagement() { | ||
| 100 | + saveConfig(emptyConfig(setupTargets.value), true) | ||
| 101 | + tab.value = 'config' | ||
| 102 | +} | ||
| 103 | + | ||
| 104 | +/* ------------------------------------------------ Inventory */ | ||
| 50 | 105 | ||
| 51 | const matches = (item: InventoryItem, q: string) => { | 106 | const matches = (item: InventoryItem, q: string) => { |
| 52 | const d = item.details | 107 | const d = item.details |
| @@ -76,12 +131,19 @@ const byType = computed(() => { | |||
| 76 | return m | 131 | return m |
| 77 | }) | 132 | }) |
| 78 | 133 | ||
| 79 | -const tabs = computed(() => TYPE_ORDER.map(t => ({ | 134 | +const enabledCount = computed(() => (config.value ? resolveItems(catalog.presets.value, config.value.presets, config.value.add).entries.length : 0)) |
| 80 | - label: TYPE_META[t].label, | 135 | + |
| 81 | - icon: TYPE_META[t].icon, | 136 | +const tabs = computed(() => [ |
| 82 | - value: t, | 137 | + ...(config.value |
| 83 | - badge: { label: String(byType.value[t].length), color: 'neutral' as const, variant: 'subtle' as const } | 138 | + ? [{ label: 'Configuration', icon: 'i-lucide-sliders-horizontal', value: 'config', badge: { label: String(enabledCount.value), color: 'neutral' as const, variant: 'subtle' as const } }] |
| 84 | -}))) | 139 | + : []), |
| 140 | + ...TYPE_ORDER.map(t => ({ | ||
| 141 | + label: TYPE_META[t].label, | ||
| 142 | + icon: TYPE_META[t].icon, | ||
| 143 | + value: t, | ||
| 144 | + badge: { label: String(byType.value[t].length), color: 'neutral' as const, variant: 'subtle' as const } | ||
| 145 | + })) | ||
| 146 | +]) | ||
| 85 | 147 | ||
| 86 | const agentOptions = [ | 148 | const agentOptions = [ |
| 87 | { label: 'All', value: 'all' }, | 149 | { label: 'All', value: 'all' }, |
| @@ -117,6 +179,51 @@ async function confirmRemove() { | |||
| 117 | removing.value = false | 179 | removing.value = false |
| 118 | } | 180 | } |
| 119 | } | 181 | } |
| 182 | + | ||
| 183 | +/* ------------------------------------------------ Selection & actions */ | ||
| 184 | + | ||
| 185 | +const selection = ref<string[]>([]) | ||
| 186 | +watch(tab, () => { selection.value = [] }) | ||
| 187 | +const selectedItems = computed(() => (inventory.value?.items ?? []).filter(i => selection.value.includes(i.id))) | ||
| 188 | +const importable = computed(() => selectedItems.value.filter(i => ['not-in-catalog', 'differs-from-catalog'].includes(statuses.value[i.id]?.state ?? ''))) | ||
| 189 | +const removable = computed(() => (props.kind === 'home' ? selectedItems.value.filter(i => ['skill', 'rule', 'agent'].includes(i.type)) : [])) | ||
| 190 | + | ||
| 191 | +const importItems = ref<InventoryItem[]>([]) | ||
| 192 | +const importOpen = ref(false) | ||
| 193 | +function askImport(items: InventoryItem[]) { | ||
| 194 | + importItems.value = items | ||
| 195 | + importOpen.value = true | ||
| 196 | +} | ||
| 197 | +const globalItems = ref<InventoryItem[]>([]) | ||
| 198 | +const globalOpen = ref(false) | ||
| 199 | +function askRemoveGlobal(items: InventoryItem[]) { | ||
| 200 | + globalItems.value = items | ||
| 201 | + globalOpen.value = true | ||
| 202 | +} | ||
| 203 | +function openInCatalog(ref: CatalogRef) { | ||
| 204 | + navigateTo(`/catalog/${ref.type}/${encodeURIComponent(ref.name)}`) | ||
| 205 | +} | ||
| 206 | +async function afterCatalogChange() { | ||
| 207 | + selection.value = [] | ||
| 208 | + await Promise.all([load(false), catalog.refresh()]) | ||
| 209 | +} | ||
| 210 | + | ||
| 211 | +/* ------------------------------------------------ Plan */ | ||
| 212 | + | ||
| 213 | +const pending = computed(() => (plan.value?.actions ?? []).filter(a => a.kind !== 'unchanged')) | ||
| 214 | +const conflictCount = computed(() => pending.value.filter(a => a.kind === 'conflict').length) | ||
| 215 | +const applyOpen = ref(false) | ||
| 216 | +function onApplied(next: ScopeState) { | ||
| 217 | + state.value = next | ||
| 218 | + config.value = next.config ? cloneConfig(next.config) : null | ||
| 219 | + selection.value = [] | ||
| 220 | + if (props.kind === 'project') refreshProjects() | ||
| 221 | +} | ||
| 222 | + | ||
| 223 | +watch(config, (c) => { if (!c && tab.value === 'config') tab.value = 'skill' }) | ||
| 224 | + | ||
| 225 | +const homeReadOnly = computed(() => props.kind === 'home' && (tab.value === 'mcp' || tab.value === 'hook')) | ||
| 226 | +const AGENT_LIST: AgentId[] = ['claude', 'codex'] | ||
| 120 | </script> | 227 | </script> |
| 121 | 228 | ||
| 122 | <template> | 229 | <template> |
| @@ -175,7 +282,7 @@ async function confirmRemove() { | |||
| 175 | color="error" | 282 | color="error" |
| 176 | variant="subtle" | 283 | variant="subtle" |
| 177 | icon="i-lucide-circle-alert" | 284 | icon="i-lucide-circle-alert" |
| 178 | - title="Could not load the inventory" | 285 | + title="Could not load this scope" |
| 179 | :description="error" | 286 | :description="error" |
| 180 | :actions="[{ label: 'Retry', color: 'neutral', variant: 'outline', onClick: () => load() }]" | 287 | :actions="[{ label: 'Retry', color: 'neutral', variant: 'outline', onClick: () => load() }]" |
| 181 | /> | 288 | /> |
| @@ -237,39 +344,128 @@ async function confirmRemove() { | |||
| 237 | </template> | 344 | </template> |
| 238 | </UAlert> | 345 | </UAlert> |
| 239 | 346 | ||
| 240 | - <!-- Filters --> | 347 | + <!-- Not managed yet --> |
| 241 | - <div class="flex flex-wrap items-center gap-3"> | 348 | + <div v-if="!config" class="flex flex-wrap items-center gap-x-6 gap-y-3 rounded-xl border border-dashed border-default p-4"> |
| 242 | - <UTabs | 349 | + <div class="min-w-0 flex-1 basis-72"> |
| 243 | - v-model="agentFilter" | 350 | + <h2 class="flex items-center gap-2 font-semibold"><UIcon name="i-lucide-wand-sparkles" class="size-4 text-primary" />{{ kind === 'home' ? 'Manage Home with skilly' : 'Manage this project with skilly' }}</h2> |
| 244 | - :items="agentOptions" | 351 | + <p class="text-sm text-muted"> |
| 245 | - :content="false" | 352 | + Enable presets and catalog items, switch skills to on demand and keep files in sync. |
| 246 | - size="sm" | 353 | + {{ kind === 'home' ? 'Settings are stored in ~/.skilly/home.json.' : 'A skilly.json file is created at the project root; commit it with the project.' }} |
| 247 | - variant="pill" | 354 | + </p> |
| 248 | - color="neutral" | 355 | + </div> |
| 249 | - class="w-auto" | 356 | + <div class="flex items-center gap-4"> |
| 250 | - /> | 357 | + <span class="text-xs font-medium uppercase tracking-wide text-muted">Targets</span> |
| 251 | - <UInput | 358 | + <UCheckbox v-for="a in AGENT_LIST" :key="a" :model-value="setupTargets.includes(a)" :label="AGENT_LABELS[a]" @update:model-value="(v) => toggleSetupTarget(a, v)" /> |
| 252 | - v-model="search" | 359 | + </div> |
| 253 | - icon="i-lucide-search" | 360 | + <UButton label="Start managing" icon="i-lucide-check" :loading="saving" :disabled="!setupTargets.length" @click="enableManagement" /> |
| 254 | - placeholder="Search name, description, command..." | ||
| 255 | - class="w-72" | ||
| 256 | - > | ||
| 257 | - <template v-if="search" #trailing> | ||
| 258 | - <UButton icon="i-lucide-x" size="xs" color="neutral" variant="link" aria-label="Clear search" @click="search = ''" /> | ||
| 259 | - </template> | ||
| 260 | - </UInput> | ||
| 261 | </div> | 361 | </div> |
| 262 | 362 | ||
| 363 | + <!-- Targets and presets --> | ||
| 364 | + <ScopeConfigBar | ||
| 365 | + v-else | ||
| 366 | + :config="config" | ||
| 367 | + :presets="catalog.presets.value" | ||
| 368 | + :catalog-exists="catalog.exists.value" | ||
| 369 | + :saving="saving" | ||
| 370 | + @update:config="saveConfig" | ||
| 371 | + /> | ||
| 372 | + | ||
| 263 | <!-- Type tabs --> | 373 | <!-- Type tabs --> |
| 264 | <UTabs v-model="tab" :items="tabs" :content="false" variant="link" class="w-full" /> | 374 | <UTabs v-model="tab" :items="tabs" :content="false" variant="link" class="w-full" /> |
| 265 | 375 | ||
| 266 | - <ItemTable :type="tab" :items="byType[tab]" :filtered="!!search || agentFilter !== 'all'" @select="open" /> | 376 | + <!-- Configuration tab --> |
| 377 | + <ScopeConfigPanel | ||
| 378 | + v-if="tab === 'config' && config" | ||
| 379 | + :config="config" | ||
| 380 | + :items="catalog.items.value" | ||
| 381 | + :presets="catalog.presets.value" | ||
| 382 | + :home="kind === 'home'" | ||
| 383 | + @update:config="saveConfig" | ||
| 384 | + /> | ||
| 385 | + | ||
| 386 | + <!-- Inventory tabs --> | ||
| 387 | + <template v-else> | ||
| 388 | + <div class="flex flex-wrap items-center gap-3"> | ||
| 389 | + <UTabs | ||
| 390 | + v-model="agentFilter" | ||
| 391 | + :items="agentOptions" | ||
| 392 | + :content="false" | ||
| 393 | + size="sm" | ||
| 394 | + variant="pill" | ||
| 395 | + color="neutral" | ||
| 396 | + class="w-auto" | ||
| 397 | + /> | ||
| 398 | + <UInput | ||
| 399 | + v-model="search" | ||
| 400 | + icon="i-lucide-search" | ||
| 401 | + placeholder="Search name, description, command..." | ||
| 402 | + class="w-72" | ||
| 403 | + > | ||
| 404 | + <template v-if="search" #trailing> | ||
| 405 | + <UButton icon="i-lucide-x" size="xs" color="neutral" variant="link" aria-label="Clear search" @click="search = ''" /> | ||
| 406 | + </template> | ||
| 407 | + </UInput> | ||
| 408 | + </div> | ||
| 409 | + | ||
| 410 | + <UAlert | ||
| 411 | + v-if="homeReadOnly" | ||
| 412 | + color="neutral" | ||
| 413 | + variant="subtle" | ||
| 414 | + icon="i-lucide-info" | ||
| 415 | + :title="`Home ${tab === 'mcp' ? 'MCP servers' : 'hooks'} are not managed in v1`" | ||
| 416 | + description="They are entries of your user configuration files, shown read-only. You can still add them to the catalog." | ||
| 417 | + /> | ||
| 418 | + | ||
| 419 | + <!-- Bulk actions --> | ||
| 420 | + <div v-if="selection.length" class="flex flex-wrap items-center gap-2 rounded-lg border border-primary/40 bg-primary/5 px-3 py-2 text-sm"> | ||
| 421 | + <span class="font-medium">{{ selection.length }} selected</span> | ||
| 422 | + <UButton v-if="importable.length" :label="`Add ${importable.length} to catalog`" icon="i-lucide-library" size="xs" @click="askImport(importable)" /> | ||
| 423 | + <UButton v-if="removable.length" :label="`Remove ${removable.length} from global`" icon="i-lucide-globe-lock" size="xs" color="error" variant="soft" @click="askRemoveGlobal(removable)" /> | ||
| 424 | + <UButton label="Clear" size="xs" color="neutral" variant="ghost" class="ml-auto" @click="selection = []" /> | ||
| 425 | + </div> | ||
| 426 | + | ||
| 427 | + <ItemTable | ||
| 428 | + v-model:selection="selection" | ||
| 429 | + :type="tab as ItemType" | ||
| 430 | + :items="byType[tab as ItemType]" | ||
| 431 | + :filtered="!!search || agentFilter !== 'all'" | ||
| 432 | + :statuses="statuses" | ||
| 433 | + :home="kind === 'home'" | ||
| 434 | + actionable | ||
| 435 | + @select="open" | ||
| 436 | + @add-to-catalog="(i) => askImport([i])" | ||
| 437 | + @open-catalog="openInCatalog" | ||
| 438 | + @remove-global="(i) => askRemoveGlobal([i])" | ||
| 439 | + /> | ||
| 440 | + </template> | ||
| 441 | + | ||
| 442 | + <!-- Pending changes --> | ||
| 443 | + <div | ||
| 444 | + v-if="plan && (pending.length || plan.missing.length)" | ||
| 445 | + class="sticky bottom-0 z-10 flex flex-wrap items-center gap-3 rounded-xl border bg-default/95 px-4 py-3 shadow-lg backdrop-blur" | ||
| 446 | + :class="conflictCount ? 'border-warning' : 'border-primary'" | ||
| 447 | + > | ||
| 448 | + <UIcon :name="conflictCount ? 'i-lucide-triangle-alert' : 'i-lucide-git-pull-request-arrow'" class="size-5 shrink-0" :class="conflictCount ? 'text-warning' : 'text-primary'" /> | ||
| 449 | + <div class="min-w-0 flex-1 text-sm"> | ||
| 450 | + <template v-if="pending.length"> | ||
| 451 | + <span class="font-semibold">{{ pending.length }} change{{ pending.length > 1 ? 's' : '' }} to apply</span> | ||
| 452 | + <span v-if="conflictCount" class="text-warning"> - {{ conflictCount }} conflict{{ conflictCount > 1 ? 's' : '' }} to resolve</span> | ||
| 453 | + </template> | ||
| 454 | + <span v-else class="font-semibold">{{ plan.missing.length }} item(s) referenced but not found in the catalog</span> | ||
| 455 | + <span v-if="saving" class="ml-2 text-xs text-muted">saving...</span> | ||
| 456 | + </div> | ||
| 457 | + <UButton label="Review and apply" icon="i-lucide-list-checks" :disabled="saving || !pending.length" @click="applyOpen = true" /> | ||
| 458 | + </div> | ||
| 267 | </div> | 459 | </div> |
| 268 | </template> | 460 | </template> |
| 269 | </UDashboardPanel> | 461 | </UDashboardPanel> |
| 270 | 462 | ||
| 271 | <ItemDetail v-model:open="detailOpen" :item="selected" /> | 463 | <ItemDetail v-model:open="detailOpen" :item="selected" /> |
| 272 | 464 | ||
| 465 | + <ApplyModal v-if="plan" v-model:open="applyOpen" :scope="scopeId" :plan="plan" :home="kind === 'home'" @applied="onApplied" /> | ||
| 466 | + <AddToCatalogModal v-model:open="importOpen" :scope="scopeId" :items="importItems" :statuses="statuses" @done="afterCatalogChange" /> | ||
| 467 | + <RemoveFromGlobalModal v-model:open="globalOpen" :items="globalItems" @done="afterCatalogChange" /> | ||
| 468 | + | ||
| 273 | <UModal v-model:open="removeOpen" title="Remove project from skilly" description="This only forgets the project in skilly."> | 469 | <UModal v-model:open="removeOpen" title="Remove project from skilly" description="This only forgets the project in skilly."> |
| 274 | <template #body> | 470 | <template #body> |
| 275 | <p class="text-sm"> | 471 | <p class="text-sm"> |
added
packages/ui/app/components/StatusBadge.vue +17 -0 | new file mode 100644 | ||
| @@ -0,0 +1,17 @@ | ||
| 1 | +<script setup lang="ts"> | |
| 2 | +import type { ItemStatus } from '@skilly/core' | |
| 3 | + | |
| 4 | +defineProps<{ status?: ItemStatus }>() | |
| 5 | +</script> | |
| 6 | + | |
| 7 | +<template> | |
| 8 | + <div v-if="status" class="flex flex-wrap gap-1"> | |
| 9 | + <UTooltip :text="STATUS_META[status.state].hint"> | |
| 10 | + <UBadge :label="STATUS_META[status.state].label" :color="STATUS_META[status.state].color" :icon="STATUS_META[status.state].icon" variant="subtle" size="sm" /> | |
| 11 | + </UTooltip> | |
| 12 | + <UTooltip v-if="status.managed" text="Written by skilly: it keeps this file up to date."> | |
| 13 | + <UBadge label="managed" icon="i-lucide-link" color="neutral" variant="outline" size="sm" /> | |
| 14 | + </UTooltip> | |
| 15 | + </div> | |
| 16 | + <span v-else class="text-xs text-dimmed">-</span> | |
| 17 | +</template> | |
| new file mode 100644 | |||
| @@ -0,0 +1,17 @@ | |||
| 1 | +<script setup lang="ts"> | ||
| 2 | +import type { ItemStatus } from '@skilly/core' | ||
| 3 | + | ||
| 4 | +defineProps<{ status?: ItemStatus }>() | ||
| 5 | +</script> | ||
| 6 | + | ||
| 7 | +<template> | ||
| 8 | + <div v-if="status" class="flex flex-wrap gap-1"> | ||
| 9 | + <UTooltip :text="STATUS_META[status.state].hint"> | ||
| 10 | + <UBadge :label="STATUS_META[status.state].label" :color="STATUS_META[status.state].color" :icon="STATUS_META[status.state].icon" variant="subtle" size="sm" /> | ||
| 11 | + </UTooltip> | ||
| 12 | + <UTooltip v-if="status.managed" text="Written by skilly: it keeps this file up to date."> | ||
| 13 | + <UBadge label="managed" icon="i-lucide-link" color="neutral" variant="outline" size="sm" /> | ||
| 14 | + </UTooltip> | ||
| 15 | + </div> | ||
| 16 | + <span v-else class="text-xs text-dimmed">-</span> | ||
| 17 | +</template> | ||
modified
packages/ui/app/composables/useApi.ts +4 -4 | @@ -9,9 +9,9 @@ export class ApiRequestError extends Error { | ||
| 9 | 9 | } |
| 10 | 10 | |
| 11 | 11 | export interface ApiOptions { |
| 12 | - method?: 'GET' | 'POST' | 'DELETE' | |
| 13 | - body?: Record<string, unknown> | |
| 14 | - query?: Record<string, string | undefined> | |
| 12 | + method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | |
| 13 | + body?: unknown | |
| 14 | + query?: Record<string, string | number | boolean | undefined> | |
| 15 | 15 | /** Do not show an error toast (the caller handles the error itself). */ |
| 16 | 16 | silent?: boolean |
| 17 | 17 | } |
| @@ -23,7 +23,7 @@ export function useApi() { | ||
| 23 | 23 | try { |
| 24 | 24 | return await $fetch<T>(path, { |
| 25 | 25 | method: opts.method ?? 'GET', |
| 26 | - body: opts.body, | |
| 26 | + body: opts.body as Record<string, unknown> | undefined, | |
| 27 | 27 | query: opts.query, |
| 28 | 28 | headers: { 'x-skilly-token': getToken() } |
| 29 | 29 | }) |
| @@ -9,9 +9,9 @@ export class ApiRequestError extends Error { | |||
| 9 | } | 9 | } |
| 10 | 10 | ||
| 11 | export interface ApiOptions { | 11 | export interface ApiOptions { |
| 12 | - method?: 'GET' | 'POST' | 'DELETE' | 12 | + method?: 'GET' | 'POST' | 'PUT' | 'DELETE' |
| 13 | - body?: Record<string, unknown> | 13 | + body?: unknown |
| 14 | - query?: Record<string, string | undefined> | 14 | + query?: Record<string, string | number | boolean | undefined> |
| 15 | /** Do not show an error toast (the caller handles the error itself). */ | 15 | /** Do not show an error toast (the caller handles the error itself). */ |
| 16 | silent?: boolean | 16 | silent?: boolean |
| 17 | } | 17 | } |
| @@ -23,7 +23,7 @@ export function useApi() { | |||
| 23 | try { | 23 | try { |
| 24 | return await $fetch<T>(path, { | 24 | return await $fetch<T>(path, { |
| 25 | method: opts.method ?? 'GET', | 25 | method: opts.method ?? 'GET', |
| 26 | - body: opts.body, | 26 | + body: opts.body as Record<string, unknown> | undefined, |
| 27 | query: opts.query, | 27 | query: opts.query, |
| 28 | headers: { 'x-skilly-token': getToken() } | 28 | headers: { 'x-skilly-token': getToken() } |
| 29 | }) | 29 | }) |
added
packages/ui/app/composables/useCatalog.ts +48 -0 | new file mode 100644 | ||
| @@ -0,0 +1,48 @@ | ||
| 1 | +import type { CatalogItem, CatalogResponse, GitStatus, Preset } from '@skilly/core' | |
| 2 | + | |
| 3 | +/** Shared catalog snapshot (items, presets, usage, git status). */ | |
| 4 | +export function useCatalog() { | |
| 5 | + const { request } = useApi() | |
| 6 | + const data = useState<CatalogResponse | null>('skilly-catalog', () => null) | |
| 7 | + const loading = useState('skilly-catalog-loading', () => false) | |
| 8 | + const error = useState<string | null>('skilly-catalog-error', () => null) | |
| 9 | + | |
| 10 | + async function refresh() { | |
| 11 | + loading.value = true | |
| 12 | + try { | |
| 13 | + data.value = await request<CatalogResponse>('/api/catalog', { silent: true }) | |
| 14 | + error.value = null | |
| 15 | + } catch (e) { | |
| 16 | + error.value = (e as Error).message | |
| 17 | + } finally { | |
| 18 | + loading.value = false | |
| 19 | + } | |
| 20 | + } | |
| 21 | + | |
| 22 | + async function init() { | |
| 23 | + data.value = await request<CatalogResponse>('/api/catalog/init', { method: 'POST' }) | |
| 24 | + error.value = null | |
| 25 | + } | |
| 26 | + | |
| 27 | + const exists = computed(() => !!data.value?.snapshot.exists) | |
| 28 | + const items = computed<CatalogItem[]>(() => data.value?.snapshot.items ?? []) | |
| 29 | + const presets = computed<Preset[]>(() => data.value?.snapshot.presets ?? []) | |
| 30 | + const git = computed<GitStatus | null>(() => data.value?.git ?? null) | |
| 31 | + const usage = computed(() => data.value?.usage ?? {}) | |
| 32 | + | |
| 33 | + const usedBy = (type: string, name: string) => usage.value[itemKey(type, name)] ?? [] | |
| 34 | + const presetUsedBy = (name: string) => usage.value[`preset/${name}`] ?? [] | |
| 35 | + const findItem = (type: string, name: string) => items.value.find(i => i.type === type && i.name === name) | |
| 36 | + | |
| 37 | + function setGit(g: GitStatus) { | |
| 38 | + if (data.value) data.value = { ...data.value, git: g } | |
| 39 | + } | |
| 40 | + | |
| 41 | + return { data, loading, error, refresh, init, exists, items, presets, git, usage, usedBy, presetUsedBy, findItem, setGit } | |
| 42 | +} | |
| 43 | + | |
| 44 | +/** Project id -> name, for rendering the scopes listed in `usage`. */ | |
| 45 | +export function useScopeNames() { | |
| 46 | + const { projects } = useProjects() | |
| 47 | + return computed(() => Object.fromEntries(projects.value.map(p => [p.id, p.name])) as Record<string, string>) | |
| 48 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,48 @@ | |||
| 1 | +import type { CatalogItem, CatalogResponse, GitStatus, Preset } from '@skilly/core' | ||
| 2 | + | ||
| 3 | +/** Shared catalog snapshot (items, presets, usage, git status). */ | ||
| 4 | +export function useCatalog() { | ||
| 5 | + const { request } = useApi() | ||
| 6 | + const data = useState<CatalogResponse | null>('skilly-catalog', () => null) | ||
| 7 | + const loading = useState('skilly-catalog-loading', () => false) | ||
| 8 | + const error = useState<string | null>('skilly-catalog-error', () => null) | ||
| 9 | + | ||
| 10 | + async function refresh() { | ||
| 11 | + loading.value = true | ||
| 12 | + try { | ||
| 13 | + data.value = await request<CatalogResponse>('/api/catalog', { silent: true }) | ||
| 14 | + error.value = null | ||
| 15 | + } catch (e) { | ||
| 16 | + error.value = (e as Error).message | ||
| 17 | + } finally { | ||
| 18 | + loading.value = false | ||
| 19 | + } | ||
| 20 | + } | ||
| 21 | + | ||
| 22 | + async function init() { | ||
| 23 | + data.value = await request<CatalogResponse>('/api/catalog/init', { method: 'POST' }) | ||
| 24 | + error.value = null | ||
| 25 | + } | ||
| 26 | + | ||
| 27 | + const exists = computed(() => !!data.value?.snapshot.exists) | ||
| 28 | + const items = computed<CatalogItem[]>(() => data.value?.snapshot.items ?? []) | ||
| 29 | + const presets = computed<Preset[]>(() => data.value?.snapshot.presets ?? []) | ||
| 30 | + const git = computed<GitStatus | null>(() => data.value?.git ?? null) | ||
| 31 | + const usage = computed(() => data.value?.usage ?? {}) | ||
| 32 | + | ||
| 33 | + const usedBy = (type: string, name: string) => usage.value[itemKey(type, name)] ?? [] | ||
| 34 | + const presetUsedBy = (name: string) => usage.value[`preset/${name}`] ?? [] | ||
| 35 | + const findItem = (type: string, name: string) => items.value.find(i => i.type === type && i.name === name) | ||
| 36 | + | ||
| 37 | + function setGit(g: GitStatus) { | ||
| 38 | + if (data.value) data.value = { ...data.value, git: g } | ||
| 39 | + } | ||
| 40 | + | ||
| 41 | + return { data, loading, error, refresh, init, exists, items, presets, git, usage, usedBy, presetUsedBy, findItem, setGit } | ||
| 42 | +} | ||
| 43 | + | ||
| 44 | +/** Project id -> name, for rendering the scopes listed in `usage`. */ | ||
| 45 | +export function useScopeNames() { | ||
| 46 | + const { projects } = useProjects() | ||
| 47 | + return computed(() => Object.fromEntries(projects.value.map(p => [p.id, p.name])) as Record<string, string>) | ||
| 48 | +} | ||
added
packages/ui/app/composables/useCatalogGit.ts +34 -0 | new file mode 100644 | ||
| @@ -0,0 +1,34 @@ | ||
| 1 | +import type { GitStatus } from '@skilly/core' | |
| 2 | + | |
| 3 | +export type GitAction = 'remote' | 'commit' | 'pull' | 'push' | 'sync' | 'resolve' | |
| 4 | + | |
| 5 | +/** Catalog git actions shared by the sidebar indicator and the git dialog. */ | |
| 6 | +export function useCatalogGit() { | |
| 7 | + const { request } = useApi() | |
| 8 | + const { setGit, refresh } = useCatalog() | |
| 9 | + const toast = useToast() | |
| 10 | + const open = useState('skilly-git-open', () => false) | |
| 11 | + const busy = useState<GitAction | null>('skilly-git-busy', () => null) | |
| 12 | + const error = useState<string | null>('skilly-git-error', () => null) | |
| 13 | + | |
| 14 | + async function run(action: GitAction, body?: unknown, successTitle?: string): Promise<boolean> { | |
| 15 | + busy.value = action | |
| 16 | + error.value = null | |
| 17 | + try { | |
| 18 | + const status = await request<GitStatus>(`/api/catalog/git/${action}`, { method: 'POST', body, silent: true }) | |
| 19 | + setGit(status) | |
| 20 | + if (successTitle) toast.add({ title: successTitle, icon: 'i-lucide-check', color: 'success' }) | |
| 21 | + // Pull / sync / resolve can change the catalog content. | |
| 22 | + if (action !== 'remote' && action !== 'commit' && action !== 'push') await refresh() | |
| 23 | + return true | |
| 24 | + } catch (e) { | |
| 25 | + error.value = (e as Error).message | |
| 26 | + toast.add({ title: `Git ${action} failed`, description: (e as Error).message, color: 'error', icon: 'i-lucide-circle-alert' }) | |
| 27 | + return false | |
| 28 | + } finally { | |
| 29 | + busy.value = null | |
| 30 | + } | |
| 31 | + } | |
| 32 | + | |
| 33 | + return { open, busy, error, run } | |
| 34 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,34 @@ | |||
| 1 | +import type { GitStatus } from '@skilly/core' | ||
| 2 | + | ||
| 3 | +export type GitAction = 'remote' | 'commit' | 'pull' | 'push' | 'sync' | 'resolve' | ||
| 4 | + | ||
| 5 | +/** Catalog git actions shared by the sidebar indicator and the git dialog. */ | ||
| 6 | +export function useCatalogGit() { | ||
| 7 | + const { request } = useApi() | ||
| 8 | + const { setGit, refresh } = useCatalog() | ||
| 9 | + const toast = useToast() | ||
| 10 | + const open = useState('skilly-git-open', () => false) | ||
| 11 | + const busy = useState<GitAction | null>('skilly-git-busy', () => null) | ||
| 12 | + const error = useState<string | null>('skilly-git-error', () => null) | ||
| 13 | + | ||
| 14 | + async function run(action: GitAction, body?: unknown, successTitle?: string): Promise<boolean> { | ||
| 15 | + busy.value = action | ||
| 16 | + error.value = null | ||
| 17 | + try { | ||
| 18 | + const status = await request<GitStatus>(`/api/catalog/git/${action}`, { method: 'POST', body, silent: true }) | ||
| 19 | + setGit(status) | ||
| 20 | + if (successTitle) toast.add({ title: successTitle, icon: 'i-lucide-check', color: 'success' }) | ||
| 21 | + // Pull / sync / resolve can change the catalog content. | ||
| 22 | + if (action !== 'remote' && action !== 'commit' && action !== 'push') await refresh() | ||
| 23 | + return true | ||
| 24 | + } catch (e) { | ||
| 25 | + error.value = (e as Error).message | ||
| 26 | + toast.add({ title: `Git ${action} failed`, description: (e as Error).message, color: 'error', icon: 'i-lucide-circle-alert' }) | ||
| 27 | + return false | ||
| 28 | + } finally { | ||
| 29 | + busy.value = null | ||
| 30 | + } | ||
| 31 | + } | ||
| 32 | + | ||
| 33 | + return { open, busy, error, run } | ||
| 34 | +} | ||
modified
packages/ui/app/layouts/default.vue +7 -2 | @@ -1,8 +1,12 @@ | ||
| 1 | 1 | <script setup lang="ts"> |
| 2 | 2 | const { refresh } = useProjects() |
| 3 | +const { refresh: refreshCatalog } = useCatalog() | |
| 3 | 4 | const addOpen = useState('skilly-add-open', () => false) |
| 4 | 5 | |
| 5 | -onMounted(refresh) | |
| 6 | +onMounted(() => { | |
| 7 | + refresh() | |
| 8 | + refreshCatalog() | |
| 9 | +}) | |
| 6 | 10 | </script> |
| 7 | 11 | |
| 8 | 12 | <template> |
| @@ -22,7 +26,7 @@ onMounted(refresh) | ||
| 22 | 26 | |
| 23 | 27 | <template #footer> |
| 24 | 28 | <div class="flex w-full items-center justify-between"> |
| 25 | - <span class="text-xs text-muted">Read-only inventory</span> | |
| 29 | + <span class="text-xs text-muted">skilly</span> | |
| 26 | 30 | <UColorModeButton /> |
| 27 | 31 | </div> |
| 28 | 32 | </template> |
| @@ -31,5 +35,6 @@ onMounted(refresh) | ||
| 31 | 35 | <slot /> |
| 32 | 36 | |
| 33 | 37 | <AddProjectModal v-model:open="addOpen" /> |
| 38 | + <CatalogGitDialog /> | |
| 34 | 39 | </UDashboardGroup> |
| 35 | 40 | </template> |
| @@ -1,8 +1,12 @@ | |||
| 1 | <script setup lang="ts"> | 1 | <script setup lang="ts"> |
| 2 | const { refresh } = useProjects() | 2 | const { refresh } = useProjects() |
| 3 | +const { refresh: refreshCatalog } = useCatalog() | ||
| 3 | const addOpen = useState('skilly-add-open', () => false) | 4 | const addOpen = useState('skilly-add-open', () => false) |
| 4 | 5 | ||
| 5 | -onMounted(refresh) | 6 | +onMounted(() => { |
| 7 | + refresh() | ||
| 8 | + refreshCatalog() | ||
| 9 | +}) | ||
| 6 | </script> | 10 | </script> |
| 7 | 11 | ||
| 8 | <template> | 12 | <template> |
| @@ -22,7 +26,7 @@ onMounted(refresh) | |||
| 22 | 26 | ||
| 23 | <template #footer> | 27 | <template #footer> |
| 24 | <div class="flex w-full items-center justify-between"> | 28 | <div class="flex w-full items-center justify-between"> |
| 25 | - <span class="text-xs text-muted">Read-only inventory</span> | 29 | + <span class="text-xs text-muted">skilly</span> |
| 26 | <UColorModeButton /> | 30 | <UColorModeButton /> |
| 27 | </div> | 31 | </div> |
| 28 | </template> | 32 | </template> |
| @@ -31,5 +35,6 @@ onMounted(refresh) | |||
| 31 | <slot /> | 35 | <slot /> |
| 32 | 36 | ||
| 33 | <AddProjectModal v-model:open="addOpen" /> | 37 | <AddProjectModal v-model:open="addOpen" /> |
| 38 | + <CatalogGitDialog /> | ||
| 34 | </UDashboardGroup> | 39 | </UDashboardGroup> |
| 35 | </template> | 40 | </template> |
added
packages/ui/app/pages/catalog/[type]/[name].vue +11 -0 | new file mode 100644 | ||
| @@ -0,0 +1,11 @@ | ||
| 1 | +<script setup lang="ts"> | |
| 2 | +import type { CatalogItemType } from '@skilly/core' | |
| 3 | + | |
| 4 | +const route = useRoute() | |
| 5 | +const type = computed(() => String(route.params.type) as CatalogItemType) | |
| 6 | +const name = computed(() => String(route.params.name)) | |
| 7 | +</script> | |
| 8 | + | |
| 9 | +<template> | |
| 10 | + <CatalogItemEditor :key="`${type}/${name}`" :type="type" :name="name" /> | |
| 11 | +</template> | |
| new file mode 100644 | |||
| @@ -0,0 +1,11 @@ | |||
| 1 | +<script setup lang="ts"> | ||
| 2 | +import type { CatalogItemType } from '@skilly/core' | ||
| 3 | + | ||
| 4 | +const route = useRoute() | ||
| 5 | +const type = computed(() => String(route.params.type) as CatalogItemType) | ||
| 6 | +const name = computed(() => String(route.params.name)) | ||
| 7 | +</script> | ||
| 8 | + | ||
| 9 | +<template> | ||
| 10 | + <CatalogItemEditor :key="`${type}/${name}`" :type="type" :name="name" /> | ||
| 11 | +</template> | ||
added
packages/ui/app/pages/catalog/index.vue +147 -0 | new file mode 100644 | ||
| @@ -0,0 +1,147 @@ | ||
| 1 | +<script setup lang="ts"> | |
| 2 | +import type { CatalogItem, CatalogItemType } from '@skilly/core' | |
| 3 | + | |
| 4 | +const { data, loading, error, refresh, exists, items, usage, git } = useCatalog() | |
| 5 | +const { open: gitOpen } = useCatalogGit() | |
| 6 | +const names = useScopeNames() | |
| 7 | +const route = useRoute() | |
| 8 | + | |
| 9 | +const tab = ref<CatalogItemType>(CAT_TYPES.includes(route.query.type as CatalogItemType) ? route.query.type as CatalogItemType : 'skill') | |
| 10 | +const search = ref('') | |
| 11 | +const tagFilter = ref<string[]>([]) | |
| 12 | + | |
| 13 | +onMounted(refresh) | |
| 14 | + | |
| 15 | +const byType = computed(() => { | |
| 16 | + const m = Object.fromEntries(CAT_TYPES.map(t => [t, [] as CatalogItem[]])) as Record<CatalogItemType, CatalogItem[]> | |
| 17 | + for (const i of items.value) m[i.type]?.push(i) | |
| 18 | + return m | |
| 19 | +}) | |
| 20 | + | |
| 21 | +const allTags = computed(() => [...new Set(byType.value[tab.value].flatMap(i => i.tags))].sort()) | |
| 22 | + | |
| 23 | +const rows = computed(() => { | |
| 24 | + const q = search.value.trim().toLowerCase() | |
| 25 | + return byType.value[tab.value] | |
| 26 | + .filter(i => (!q || `${i.name} ${i.description ?? ''} ${i.tags.join(' ')}`.toLowerCase().includes(q)) | |
| 27 | + && tagFilter.value.every(t => i.tags.includes(t))) | |
| 28 | + .sort((a, b) => a.name.localeCompare(b.name)) | |
| 29 | +}) | |
| 30 | + | |
| 31 | +const tabs = computed(() => CAT_TYPES.map(t => ({ | |
| 32 | + label: CAT_META[t].label, | |
| 33 | + icon: CAT_META[t].icon, | |
| 34 | + value: t, | |
| 35 | + badge: { label: String(byType.value[t].length), color: 'neutral' as const, variant: 'subtle' as const } | |
| 36 | +}))) | |
| 37 | + | |
| 38 | +watch(tab, () => { tagFilter.value = [] }) | |
| 39 | + | |
| 40 | +const ORIGIN = { | |
| 41 | + created: { label: 'created', icon: 'i-lucide-pencil-line' }, | |
| 42 | + import: { label: 'imported', icon: 'i-lucide-download' }, | |
| 43 | + marketplace: { label: 'marketplace', icon: 'i-lucide-store' } | |
| 44 | +} as const | |
| 45 | + | |
| 46 | +const usedBy = (i: CatalogItem) => usage.value[itemKey(i.type, i.name)] ?? [] | |
| 47 | +const go = (i: CatalogItem) => navigateTo(`/catalog/${i.type}/${encodeURIComponent(i.name)}`) | |
| 48 | +const TH = 'px-3 py-2 text-left text-xs font-medium uppercase tracking-wide text-muted' | |
| 49 | +</script> | |
| 50 | + | |
| 51 | +<template> | |
| 52 | + <UDashboardPanel id="catalog"> | |
| 53 | + <template #header> | |
| 54 | + <UDashboardNavbar title="Catalog" icon="i-lucide-library"> | |
| 55 | + <template #right> | |
| 56 | + <template v-if="exists"> | |
| 57 | + <UButton v-if="git?.isRepo" label="Git" icon="i-lucide-git-branch" color="neutral" variant="outline" @click="gitOpen = true" /> | |
| 58 | + <UButton :label="`New ${CAT_META[tab].singular.toLowerCase()}`" icon="i-lucide-plus" :to="`/catalog/new?type=${tab}`" /> | |
| 59 | + </template> | |
| 60 | + </template> | |
| 61 | + </UDashboardNavbar> | |
| 62 | + </template> | |
| 63 | + | |
| 64 | + <template #body> | |
| 65 | + <div v-if="loading && !data" class="space-y-3"> | |
| 66 | + <USkeleton class="h-10 w-full" /> | |
| 67 | + <USkeleton v-for="i in 5" :key="i" class="h-12 w-full" /> | |
| 68 | + </div> | |
| 69 | + <UAlert v-else-if="error && !data" color="error" variant="subtle" icon="i-lucide-circle-alert" title="Could not load the catalog" :description="error" :actions="[{ label: 'Retry', color: 'neutral', variant: 'outline', onClick: refresh }]" /> | |
| 70 | + <CatalogSetupCard v-else-if="data && !exists" /> | |
| 71 | + | |
| 72 | + <div v-else-if="data" class="flex flex-col gap-4"> | |
| 73 | + <UAlert | |
| 74 | + v-if="data.snapshot.errors.length" | |
| 75 | + color="warning" | |
| 76 | + variant="subtle" | |
| 77 | + icon="i-lucide-triangle-alert" | |
| 78 | + :title="`${data.snapshot.errors.length} item(s) could not be read`" | |
| 79 | + > | |
| 80 | + <template #description> | |
| 81 | + <ul class="list-disc pl-4 text-xs"> | |
| 82 | + <li v-for="e in data.snapshot.errors" :key="e.path"><span class="font-mono">{{ e.path }}</span>: {{ e.message }}</li> | |
| 83 | + </ul> | |
| 84 | + </template> | |
| 85 | + </UAlert> | |
| 86 | + | |
| 87 | + <div class="flex flex-wrap items-center gap-3"> | |
| 88 | + <UInput v-model="search" icon="i-lucide-search" placeholder="Search name, description, tag..." class="w-72"> | |
| 89 | + <template v-if="search" #trailing> | |
| 90 | + <UButton icon="i-lucide-x" size="xs" color="neutral" variant="link" aria-label="Clear search" @click="search = ''" /> | |
| 91 | + </template> | |
| 92 | + </UInput> | |
| 93 | + <USelectMenu v-if="allTags.length" v-model="tagFilter" :items="allTags" multiple placeholder="Filter by tag" icon="i-lucide-tag" class="w-52" /> | |
| 94 | + <UButton v-if="tagFilter.length" label="Clear tags" size="xs" color="neutral" variant="link" @click="tagFilter = []" /> | |
| 95 | + </div> | |
| 96 | + | |
| 97 | + <UTabs v-model="tab" :items="tabs" :content="false" variant="link" class="w-full" /> | |
| 98 | + | |
| 99 | + <div v-if="!rows.length" class="flex flex-col items-center gap-2 rounded-xl border border-dashed border-default py-12 text-center"> | |
| 100 | + <UIcon :name="search || tagFilter.length ? 'i-lucide-search-x' : CAT_META[tab].icon" class="size-8 text-dimmed" /> | |
| 101 | + <p class="text-sm text-muted"> | |
| 102 | + {{ search || tagFilter.length ? 'No result matches the current filters.' : `No ${CAT_META[tab].label.toLowerCase()} in the catalog yet.` }} | |
| 103 | + </p> | |
| 104 | + <p v-if="!search && !tagFilter.length" class="text-xs text-muted">Create one, add items from a project with "Add to catalog", or import from Marketplaces.</p> | |
| 105 | + </div> | |
| 106 | + | |
| 107 | + <div v-else class="overflow-x-auto rounded-xl border border-default"> | |
| 108 | + <table class="w-full min-w-[720px] table-fixed text-sm"> | |
| 109 | + <thead class="border-b border-default bg-elevated/40"> | |
| 110 | + <tr> | |
| 111 | + <th :class="TH" class="w-[34%]">Name</th> | |
| 112 | + <th :class="TH">Tags</th> | |
| 113 | + <th :class="TH" class="w-32">Origin</th> | |
| 114 | + <th :class="TH" class="w-28">Used by</th> | |
| 115 | + </tr> | |
| 116 | + </thead> | |
| 117 | + <tbody class="divide-y divide-default"> | |
| 118 | + <tr v-for="i in rows" :key="i.name" class="cursor-pointer transition-colors hover:bg-elevated/50" tabindex="0" @click="go(i)" @keydown.enter="go(i)"> | |
| 119 | + <td class="px-3 py-2 align-middle"> | |
| 120 | + <span class="block truncate font-medium text-highlighted">{{ i.name }}</span> | |
| 121 | + <p v-if="i.description" class="line-clamp-1 text-xs text-muted" :title="i.description">{{ i.description }}</p> | |
| 122 | + </td> | |
| 123 | + <td class="px-3 py-2 align-middle"> | |
| 124 | + <div class="flex flex-wrap gap-1"> | |
| 125 | + <UBadge v-for="t in i.tags" :key="t" :label="t" color="neutral" variant="outline" size="sm" /> | |
| 126 | + <span v-if="!i.tags.length" class="text-xs text-dimmed">-</span> | |
| 127 | + </div> | |
| 128 | + </td> | |
| 129 | + <td class="px-3 py-2 align-middle"> | |
| 130 | + <UTooltip :text="i.origin?.source ? `Source: ${i.origin.source}` : 'Created in skilly'"> | |
| 131 | + <UBadge :label="ORIGIN[i.origin?.kind ?? 'created'].label" :icon="ORIGIN[i.origin?.kind ?? 'created'].icon" color="neutral" variant="subtle" size="sm" /> | |
| 132 | + </UTooltip> | |
| 133 | + </td> | |
| 134 | + <td class="px-3 py-2 align-middle"> | |
| 135 | + <UTooltip v-if="usedBy(i).length" :text="usedBy(i).map(s => scopeLabel(s, names)).join(', ')"> | |
| 136 | + <span class="text-xs tabular-nums">{{ usedBy(i).length }} scope{{ usedBy(i).length > 1 ? 's' : '' }}</span> | |
| 137 | + </UTooltip> | |
| 138 | + <span v-else class="text-xs text-dimmed">unused</span> | |
| 139 | + </td> | |
| 140 | + </tr> | |
| 141 | + </tbody> | |
| 142 | + </table> | |
| 143 | + </div> | |
| 144 | + </div> | |
| 145 | + </template> | |
| 146 | + </UDashboardPanel> | |
| 147 | +</template> | |
| new file mode 100644 | |||
| @@ -0,0 +1,147 @@ | |||
| 1 | +<script setup lang="ts"> | ||
| 2 | +import type { CatalogItem, CatalogItemType } from '@skilly/core' | ||
| 3 | + | ||
| 4 | +const { data, loading, error, refresh, exists, items, usage, git } = useCatalog() | ||
| 5 | +const { open: gitOpen } = useCatalogGit() | ||
| 6 | +const names = useScopeNames() | ||
| 7 | +const route = useRoute() | ||
| 8 | + | ||
| 9 | +const tab = ref<CatalogItemType>(CAT_TYPES.includes(route.query.type as CatalogItemType) ? route.query.type as CatalogItemType : 'skill') | ||
| 10 | +const search = ref('') | ||
| 11 | +const tagFilter = ref<string[]>([]) | ||
| 12 | + | ||
| 13 | +onMounted(refresh) | ||
| 14 | + | ||
| 15 | +const byType = computed(() => { | ||
| 16 | + const m = Object.fromEntries(CAT_TYPES.map(t => [t, [] as CatalogItem[]])) as Record<CatalogItemType, CatalogItem[]> | ||
| 17 | + for (const i of items.value) m[i.type]?.push(i) | ||
| 18 | + return m | ||
| 19 | +}) | ||
| 20 | + | ||
| 21 | +const allTags = computed(() => [...new Set(byType.value[tab.value].flatMap(i => i.tags))].sort()) | ||
| 22 | + | ||
| 23 | +const rows = computed(() => { | ||
| 24 | + const q = search.value.trim().toLowerCase() | ||
| 25 | + return byType.value[tab.value] | ||
| 26 | + .filter(i => (!q || `${i.name} ${i.description ?? ''} ${i.tags.join(' ')}`.toLowerCase().includes(q)) | ||
| 27 | + && tagFilter.value.every(t => i.tags.includes(t))) | ||
| 28 | + .sort((a, b) => a.name.localeCompare(b.name)) | ||
| 29 | +}) | ||
| 30 | + | ||
| 31 | +const tabs = computed(() => CAT_TYPES.map(t => ({ | ||
| 32 | + label: CAT_META[t].label, | ||
| 33 | + icon: CAT_META[t].icon, | ||
| 34 | + value: t, | ||
| 35 | + badge: { label: String(byType.value[t].length), color: 'neutral' as const, variant: 'subtle' as const } | ||
| 36 | +}))) | ||
| 37 | + | ||
| 38 | +watch(tab, () => { tagFilter.value = [] }) | ||
| 39 | + | ||
| 40 | +const ORIGIN = { | ||
| 41 | + created: { label: 'created', icon: 'i-lucide-pencil-line' }, | ||
| 42 | + import: { label: 'imported', icon: 'i-lucide-download' }, | ||
| 43 | + marketplace: { label: 'marketplace', icon: 'i-lucide-store' } | ||
| 44 | +} as const | ||
| 45 | + | ||
| 46 | +const usedBy = (i: CatalogItem) => usage.value[itemKey(i.type, i.name)] ?? [] | ||
| 47 | +const go = (i: CatalogItem) => navigateTo(`/catalog/${i.type}/${encodeURIComponent(i.name)}`) | ||
| 48 | +const TH = 'px-3 py-2 text-left text-xs font-medium uppercase tracking-wide text-muted' | ||
| 49 | +</script> | ||
| 50 | + | ||
| 51 | +<template> | ||
| 52 | + <UDashboardPanel id="catalog"> | ||
| 53 | + <template #header> | ||
| 54 | + <UDashboardNavbar title="Catalog" icon="i-lucide-library"> | ||
| 55 | + <template #right> | ||
| 56 | + <template v-if="exists"> | ||
| 57 | + <UButton v-if="git?.isRepo" label="Git" icon="i-lucide-git-branch" color="neutral" variant="outline" @click="gitOpen = true" /> | ||
| 58 | + <UButton :label="`New ${CAT_META[tab].singular.toLowerCase()}`" icon="i-lucide-plus" :to="`/catalog/new?type=${tab}`" /> | ||
| 59 | + </template> | ||
| 60 | + </template> | ||
| 61 | + </UDashboardNavbar> | ||
| 62 | + </template> | ||
| 63 | + | ||
| 64 | + <template #body> | ||
| 65 | + <div v-if="loading && !data" class="space-y-3"> | ||
| 66 | + <USkeleton class="h-10 w-full" /> | ||
| 67 | + <USkeleton v-for="i in 5" :key="i" class="h-12 w-full" /> | ||
| 68 | + </div> | ||
| 69 | + <UAlert v-else-if="error && !data" color="error" variant="subtle" icon="i-lucide-circle-alert" title="Could not load the catalog" :description="error" :actions="[{ label: 'Retry', color: 'neutral', variant: 'outline', onClick: refresh }]" /> | ||
| 70 | + <CatalogSetupCard v-else-if="data && !exists" /> | ||
| 71 | + | ||
| 72 | + <div v-else-if="data" class="flex flex-col gap-4"> | ||
| 73 | + <UAlert | ||
| 74 | + v-if="data.snapshot.errors.length" | ||
| 75 | + color="warning" | ||
| 76 | + variant="subtle" | ||
| 77 | + icon="i-lucide-triangle-alert" | ||
| 78 | + :title="`${data.snapshot.errors.length} item(s) could not be read`" | ||
| 79 | + > | ||
| 80 | + <template #description> | ||
| 81 | + <ul class="list-disc pl-4 text-xs"> | ||
| 82 | + <li v-for="e in data.snapshot.errors" :key="e.path"><span class="font-mono">{{ e.path }}</span>: {{ e.message }}</li> | ||
| 83 | + </ul> | ||
| 84 | + </template> | ||
| 85 | + </UAlert> | ||
| 86 | + | ||
| 87 | + <div class="flex flex-wrap items-center gap-3"> | ||
| 88 | + <UInput v-model="search" icon="i-lucide-search" placeholder="Search name, description, tag..." class="w-72"> | ||
| 89 | + <template v-if="search" #trailing> | ||
| 90 | + <UButton icon="i-lucide-x" size="xs" color="neutral" variant="link" aria-label="Clear search" @click="search = ''" /> | ||
| 91 | + </template> | ||
| 92 | + </UInput> | ||
| 93 | + <USelectMenu v-if="allTags.length" v-model="tagFilter" :items="allTags" multiple placeholder="Filter by tag" icon="i-lucide-tag" class="w-52" /> | ||
| 94 | + <UButton v-if="tagFilter.length" label="Clear tags" size="xs" color="neutral" variant="link" @click="tagFilter = []" /> | ||
| 95 | + </div> | ||
| 96 | + | ||
| 97 | + <UTabs v-model="tab" :items="tabs" :content="false" variant="link" class="w-full" /> | ||
| 98 | + | ||
| 99 | + <div v-if="!rows.length" class="flex flex-col items-center gap-2 rounded-xl border border-dashed border-default py-12 text-center"> | ||
| 100 | + <UIcon :name="search || tagFilter.length ? 'i-lucide-search-x' : CAT_META[tab].icon" class="size-8 text-dimmed" /> | ||
| 101 | + <p class="text-sm text-muted"> | ||
| 102 | + {{ search || tagFilter.length ? 'No result matches the current filters.' : `No ${CAT_META[tab].label.toLowerCase()} in the catalog yet.` }} | ||
| 103 | + </p> | ||
| 104 | + <p v-if="!search && !tagFilter.length" class="text-xs text-muted">Create one, add items from a project with "Add to catalog", or import from Marketplaces.</p> | ||
| 105 | + </div> | ||
| 106 | + | ||
| 107 | + <div v-else class="overflow-x-auto rounded-xl border border-default"> | ||
| 108 | + <table class="w-full min-w-[720px] table-fixed text-sm"> | ||
| 109 | + <thead class="border-b border-default bg-elevated/40"> | ||
| 110 | + <tr> | ||
| 111 | + <th :class="TH" class="w-[34%]">Name</th> | ||
| 112 | + <th :class="TH">Tags</th> | ||
| 113 | + <th :class="TH" class="w-32">Origin</th> | ||
| 114 | + <th :class="TH" class="w-28">Used by</th> | ||
| 115 | + </tr> | ||
| 116 | + </thead> | ||
| 117 | + <tbody class="divide-y divide-default"> | ||
| 118 | + <tr v-for="i in rows" :key="i.name" class="cursor-pointer transition-colors hover:bg-elevated/50" tabindex="0" @click="go(i)" @keydown.enter="go(i)"> | ||
| 119 | + <td class="px-3 py-2 align-middle"> | ||
| 120 | + <span class="block truncate font-medium text-highlighted">{{ i.name }}</span> | ||
| 121 | + <p v-if="i.description" class="line-clamp-1 text-xs text-muted" :title="i.description">{{ i.description }}</p> | ||
| 122 | + </td> | ||
| 123 | + <td class="px-3 py-2 align-middle"> | ||
| 124 | + <div class="flex flex-wrap gap-1"> | ||
| 125 | + <UBadge v-for="t in i.tags" :key="t" :label="t" color="neutral" variant="outline" size="sm" /> | ||
| 126 | + <span v-if="!i.tags.length" class="text-xs text-dimmed">-</span> | ||
| 127 | + </div> | ||
| 128 | + </td> | ||
| 129 | + <td class="px-3 py-2 align-middle"> | ||
| 130 | + <UTooltip :text="i.origin?.source ? `Source: ${i.origin.source}` : 'Created in skilly'"> | ||
| 131 | + <UBadge :label="ORIGIN[i.origin?.kind ?? 'created'].label" :icon="ORIGIN[i.origin?.kind ?? 'created'].icon" color="neutral" variant="subtle" size="sm" /> | ||
| 132 | + </UTooltip> | ||
| 133 | + </td> | ||
| 134 | + <td class="px-3 py-2 align-middle"> | ||
| 135 | + <UTooltip v-if="usedBy(i).length" :text="usedBy(i).map(s => scopeLabel(s, names)).join(', ')"> | ||
| 136 | + <span class="text-xs tabular-nums">{{ usedBy(i).length }} scope{{ usedBy(i).length > 1 ? 's' : '' }}</span> | ||
| 137 | + </UTooltip> | ||
| 138 | + <span v-else class="text-xs text-dimmed">unused</span> | ||
| 139 | + </td> | ||
| 140 | + </tr> | ||
| 141 | + </tbody> | ||
| 142 | + </table> | ||
| 143 | + </div> | ||
| 144 | + </div> | ||
| 145 | + </template> | ||
| 146 | + </UDashboardPanel> | ||
| 147 | +</template> | ||
added
packages/ui/app/pages/catalog/new.vue +10 -0 | new file mode 100644 | ||
| @@ -0,0 +1,10 @@ | ||
| 1 | +<script setup lang="ts"> | |
| 2 | +import type { CatalogItemType } from '@skilly/core' | |
| 3 | + | |
| 4 | +const route = useRoute() | |
| 5 | +const type = computed<CatalogItemType>(() => (CAT_TYPES.includes(route.query.type as CatalogItemType) ? route.query.type as CatalogItemType : 'skill')) | |
| 6 | +</script> | |
| 7 | + | |
| 8 | +<template> | |
| 9 | + <CatalogItemEditor :key="type" :type="type" /> | |
| 10 | +</template> | |
| new file mode 100644 | |||
| @@ -0,0 +1,10 @@ | |||
| 1 | +<script setup lang="ts"> | ||
| 2 | +import type { CatalogItemType } from '@skilly/core' | ||
| 3 | + | ||
| 4 | +const route = useRoute() | ||
| 5 | +const type = computed<CatalogItemType>(() => (CAT_TYPES.includes(route.query.type as CatalogItemType) ? route.query.type as CatalogItemType : 'skill')) | ||
| 6 | +</script> | ||
| 7 | + | ||
| 8 | +<template> | ||
| 9 | + <CatalogItemEditor :key="type" :type="type" /> | ||
| 10 | +</template> | ||
modified
packages/ui/app/pages/index.vue +75 -44 | @@ -1,14 +1,45 @@ | ||
| 1 | 1 | <script setup lang="ts"> |
| 2 | -import type { ItemType, ScopeInventory } from '@skilly/core' | |
| 2 | +import type { ItemType, ScopeInventory, ScopeState } from '@skilly/core' | |
| 3 | 3 | |
| 4 | 4 | const { projects, loaded, error: projectsError, refresh: refreshProjects } = useProjects() |
| 5 | 5 | const { home, loading: homeLoading, error: homeError, refresh: refreshHome } = useHomeInventory() |
| 6 | +const { request } = useApi() | |
| 6 | 7 | const addOpen = useState('skilly-add-open', () => false) |
| 7 | 8 | |
| 8 | -onMounted(() => { | |
| 9 | +interface Sync { label: string, color: 'success' | 'warning' | 'neutral' | 'error', icon: string } | |
| 10 | +const syncs = ref<Record<string, Sync | null>>({}) | |
| 11 | + | |
| 12 | +function toSync(s: ScopeState): Sync { | |
| 13 | + if (!s.config) return { label: 'not managed', color: 'neutral', icon: 'i-lucide-circle-dashed' } | |
| 14 | + const pending = (s.plan?.actions ?? []).filter(a => a.kind !== 'unchanged') | |
| 15 | + const conflicts = pending.filter(a => a.kind === 'conflict').length | |
| 16 | + if (conflicts) return { label: `${conflicts} conflict${conflicts > 1 ? 's' : ''}`, color: 'warning', icon: 'i-lucide-triangle-alert' } | |
| 17 | + if (pending.length) return { label: `${pending.length} change${pending.length > 1 ? 's' : ''}`, color: 'warning', icon: 'i-lucide-refresh-cw' } | |
| 18 | + return { label: 'in sync', color: 'success', icon: 'i-lucide-check' } | |
| 19 | +} | |
| 20 | + | |
| 21 | +// Sync state needs a scan of each scope: fetch lazily, three at a time, and tolerate failures (the card then shows counts only). | |
| 22 | +async function loadSyncs() { | |
| 23 | + const ids = ['home', ...projects.value.filter(p => p.exists).map(p => p.id)] | |
| 24 | + const queue = [...ids] | |
| 25 | + const worker = async () => { | |
| 26 | + for (let id = queue.shift(); id; id = queue.shift()) { | |
| 27 | + try { | |
| 28 | + syncs.value[id] = toSync(await request<ScopeState>(`/api/scopes/${encodeURIComponent(id)}/state`, { silent: true })) | |
| 29 | + } catch { | |
| 30 | + syncs.value[id] = null | |
| 31 | + } | |
| 32 | + } | |
| 33 | + } | |
| 34 | + await Promise.all([worker(), worker(), worker()]) | |
| 35 | +} | |
| 36 | + | |
| 37 | +onMounted(async () => { | |
| 9 | 38 | if (!home.value) refreshHome() |
| 10 | 39 | // Projects may have changed on disk (e.g. a folder was deleted) since the layout loaded them. |
| 11 | - if (loaded.value) refreshProjects() | |
| 40 | + if (loaded.value) await refreshProjects() | |
| 41 | + else await new Promise<void>((res) => { const stop = watch(loaded, (v) => { if (v) { stop(); res() } }) }) | |
| 42 | + loadSyncs() | |
| 12 | 43 | }) |
| 13 | 44 | |
| 14 | 45 | function countItems(inv: ScopeInventory): Record<ItemType, number> { |
| @@ -36,59 +67,59 @@ const homeCounts = computed(() => (home.value ? countItems(home.value) : null)) | ||
| 36 | 67 | Everything your coding agents can see: skills, rules, MCP servers, hooks, subagents and instruction files. |
| 37 | 68 | </p> |
| 38 | 69 | |
| 39 | - <div class="grid gap-4 md:grid-cols-2"> | |
| 70 | + <div class="grid gap-3 md:grid-cols-2"> | |
| 40 | 71 | <!-- Home --> |
| 41 | - <UCard> | |
| 42 | - <template #header> | |
| 43 | - <div class="flex items-center gap-2"> | |
| 44 | - <UIcon name="i-lucide-house" class="size-5 text-primary" /> | |
| 45 | - <h2 class="font-semibold">Home</h2> | |
| 46 | - <span class="text-xs text-muted">user-level configuration</span> | |
| 47 | - </div> | |
| 48 | - </template> | |
| 49 | - <div v-if="homeLoading && !home" class="space-y-2"> | |
| 50 | - <USkeleton class="h-4 w-2/3" /> | |
| 51 | - <USkeleton class="h-4 w-full" /> | |
| 72 | + <NuxtLink to="/home" class="block rounded-xl border border-default p-3 transition-colors hover:bg-elevated/50 focus-visible:outline-2 focus-visible:outline-primary"> | |
| 73 | + <div class="flex items-center gap-2"> | |
| 74 | + <UIcon name="i-lucide-house" class="size-4 shrink-0 text-primary" /> | |
| 75 | + <h2 class="font-semibold">Home</h2> | |
| 76 | + <span class="truncate text-xs text-muted">user-level configuration</span> | |
| 77 | + <UBadge v-if="syncs.home" :label="syncs.home.label" :color="syncs.home.color" :icon="syncs.home.icon" variant="subtle" size="sm" class="ml-auto shrink-0" /> | |
| 78 | + <USkeleton v-else-if="syncs.home === undefined" class="ml-auto h-5 w-16" /> | |
| 52 | 79 | </div> |
| 53 | - <UAlert v-else-if="homeError" color="error" variant="subtle" icon="i-lucide-circle-alert" :title="homeError" /> | |
| 54 | - <div v-else-if="home && homeCounts" class="space-y-3"> | |
| 80 | + <div v-if="homeLoading && !home" class="mt-2 space-y-2"><USkeleton class="h-4 w-full" /></div> | |
| 81 | + <UAlert v-else-if="homeError" color="error" variant="subtle" icon="i-lucide-circle-alert" :title="homeError" class="mt-2" /> | |
| 82 | + <div v-else-if="home && homeCounts" class="mt-2 space-y-2"> | |
| 55 | 83 | <p class="truncate font-mono text-xs text-muted" :title="home.root">{{ home.root }}</p> |
| 56 | - <div class="flex gap-2"> | |
| 57 | - <AgentBadge v-for="a in home.agentsDetected" :key="a" :agent="a" /> | |
| 58 | - <span v-if="!home.agentsDetected.length" class="text-sm text-muted">No agent detected</span> | |
| 84 | + <div class="flex flex-wrap items-center gap-x-3 gap-y-1.5"> | |
| 85 | + <span class="flex gap-1"> | |
| 86 | + <AgentBadge v-for="a in home.agentsDetected" :key="a" :agent="a" compact /> | |
| 87 | + <span v-if="!home.agentsDetected.length" class="text-xs text-muted">No agent</span> | |
| 88 | + </span> | |
| 89 | + <CountChips :counts="homeCounts" compact /> | |
| 59 | 90 | </div> |
| 60 | - <CountChips :counts="homeCounts" /> | |
| 61 | 91 | </div> |
| 62 | - <template #footer> | |
| 63 | - <UButton to="/home" label="Open" trailing-icon="i-lucide-arrow-right" color="neutral" variant="soft" size="sm" /> | |
| 64 | - </template> | |
| 65 | - </UCard> | |
| 92 | + </NuxtLink> | |
| 66 | 93 | |
| 67 | 94 | <!-- Projects --> |
| 68 | 95 | <template v-if="loaded"> |
| 69 | - <UCard v-for="p in projects" :key="p.id"> | |
| 70 | - <template #header> | |
| 71 | - <div class="flex items-center gap-2"> | |
| 72 | - <UIcon name="i-lucide-folder" class="size-5 text-primary" /> | |
| 73 | - <h2 class="truncate font-semibold">{{ p.name }}</h2> | |
| 74 | - <UBadge v-if="!p.exists" label="Folder missing" color="warning" variant="subtle" icon="i-lucide-triangle-alert" size="sm" /> | |
| 75 | - </div> | |
| 76 | - </template> | |
| 77 | - <div class="space-y-3"> | |
| 96 | + <NuxtLink | |
| 97 | + v-for="p in projects" | |
| 98 | + :key="p.id" | |
| 99 | + :to="`/projects/${encodeURIComponent(p.id)}`" | |
| 100 | + class="block rounded-xl border border-default p-3 transition-colors hover:bg-elevated/50 focus-visible:outline-2 focus-visible:outline-primary" | |
| 101 | + > | |
| 102 | + <div class="flex items-center gap-2"> | |
| 103 | + <UIcon name="i-lucide-folder" class="size-4 shrink-0 text-primary" /> | |
| 104 | + <h2 class="truncate font-semibold">{{ p.name }}</h2> | |
| 105 | + <UBadge v-if="!p.exists" label="Folder missing" color="warning" variant="subtle" icon="i-lucide-triangle-alert" size="sm" class="ml-auto shrink-0" /> | |
| 106 | + <UBadge v-else-if="syncs[p.id]" :label="syncs[p.id]!.label" :color="syncs[p.id]!.color" :icon="syncs[p.id]!.icon" variant="subtle" size="sm" class="ml-auto shrink-0" /> | |
| 107 | + <USkeleton v-else-if="syncs[p.id] === undefined" class="ml-auto h-5 w-16" /> | |
| 108 | + </div> | |
| 109 | + <div class="mt-2 space-y-2"> | |
| 78 | 110 | <p class="truncate font-mono text-xs text-muted" :title="p.path">{{ p.path }}</p> |
| 79 | - <div class="flex gap-2"> | |
| 80 | - <AgentBadge v-for="a in p.agentsDetected" :key="a" :agent="a" /> | |
| 81 | - <span v-if="!p.agentsDetected.length" class="text-sm text-muted">No agent detected</span> | |
| 111 | + <div class="flex flex-wrap items-center gap-x-3 gap-y-1.5"> | |
| 112 | + <span class="flex gap-1"> | |
| 113 | + <AgentBadge v-for="a in p.agentsDetected" :key="a" :agent="a" compact /> | |
| 114 | + <span v-if="!p.agentsDetected.length" class="text-xs text-muted">No agent</span> | |
| 115 | + </span> | |
| 116 | + <CountChips :counts="p.counts" compact /> | |
| 82 | 117 | </div> |
| 83 | - <CountChips :counts="p.counts" /> | |
| 84 | 118 | </div> |
| 85 | - <template #footer> | |
| 86 | - <UButton :to="`/projects/${encodeURIComponent(p.id)}`" label="Open" trailing-icon="i-lucide-arrow-right" color="neutral" variant="soft" size="sm" /> | |
| 87 | - </template> | |
| 88 | - </UCard> | |
| 119 | + </NuxtLink> | |
| 89 | 120 | </template> |
| 90 | 121 | <template v-else> |
| 91 | - <USkeleton class="h-48 w-full" /> | |
| 122 | + <USkeleton class="h-24 w-full" /> | |
| 92 | 123 | </template> |
| 93 | 124 | </div> |
| 94 | 125 | |
| @@ -109,7 +140,7 @@ const homeCounts = computed(() => (home.value ? countItems(home.value) : null)) | ||
| 109 | 140 | <UIcon name="i-lucide-folder-plus" class="size-10 text-dimmed" /> |
| 110 | 141 | <h3 class="font-semibold">Add your first project</h3> |
| 111 | 142 | <p class="max-w-md text-sm text-muted"> |
| 112 | - Pick a folder to see which skills, rules, MCP servers and instructions Claude Code and Codex use there. | |
| 143 | + Pick a folder to see which skills, rules, MCP servers and instructions Claude Code and Codex use there, then manage them from your catalog. | |
| 113 | 144 | </p> |
| 114 | 145 | <UButton icon="i-lucide-folder-plus" label="Add project" @click="addOpen = true" /> |
| 115 | 146 | </div> |
| @@ -1,14 +1,45 @@ | |||
| 1 | <script setup lang="ts"> | 1 | <script setup lang="ts"> |
| 2 | -import type { ItemType, ScopeInventory } from '@skilly/core' | 2 | +import type { ItemType, ScopeInventory, ScopeState } from '@skilly/core' |
| 3 | 3 | ||
| 4 | const { projects, loaded, error: projectsError, refresh: refreshProjects } = useProjects() | 4 | const { projects, loaded, error: projectsError, refresh: refreshProjects } = useProjects() |
| 5 | const { home, loading: homeLoading, error: homeError, refresh: refreshHome } = useHomeInventory() | 5 | const { home, loading: homeLoading, error: homeError, refresh: refreshHome } = useHomeInventory() |
| 6 | +const { request } = useApi() | ||
| 6 | const addOpen = useState('skilly-add-open', () => false) | 7 | const addOpen = useState('skilly-add-open', () => false) |
| 7 | 8 | ||
| 8 | -onMounted(() => { | 9 | +interface Sync { label: string, color: 'success' | 'warning' | 'neutral' | 'error', icon: string } |
| 10 | +const syncs = ref<Record<string, Sync | null>>({}) | ||
| 11 | + | ||
| 12 | +function toSync(s: ScopeState): Sync { | ||
| 13 | + if (!s.config) return { label: 'not managed', color: 'neutral', icon: 'i-lucide-circle-dashed' } | ||
| 14 | + const pending = (s.plan?.actions ?? []).filter(a => a.kind !== 'unchanged') | ||
| 15 | + const conflicts = pending.filter(a => a.kind === 'conflict').length | ||
| 16 | + if (conflicts) return { label: `${conflicts} conflict${conflicts > 1 ? 's' : ''}`, color: 'warning', icon: 'i-lucide-triangle-alert' } | ||
| 17 | + if (pending.length) return { label: `${pending.length} change${pending.length > 1 ? 's' : ''}`, color: 'warning', icon: 'i-lucide-refresh-cw' } | ||
| 18 | + return { label: 'in sync', color: 'success', icon: 'i-lucide-check' } | ||
| 19 | +} | ||
| 20 | + | ||
| 21 | +// Sync state needs a scan of each scope: fetch lazily, three at a time, and tolerate failures (the card then shows counts only). | ||
| 22 | +async function loadSyncs() { | ||
| 23 | + const ids = ['home', ...projects.value.filter(p => p.exists).map(p => p.id)] | ||
| 24 | + const queue = [...ids] | ||
| 25 | + const worker = async () => { | ||
| 26 | + for (let id = queue.shift(); id; id = queue.shift()) { | ||
| 27 | + try { | ||
| 28 | + syncs.value[id] = toSync(await request<ScopeState>(`/api/scopes/${encodeURIComponent(id)}/state`, { silent: true })) | ||
| 29 | + } catch { | ||
| 30 | + syncs.value[id] = null | ||
| 31 | + } | ||
| 32 | + } | ||
| 33 | + } | ||
| 34 | + await Promise.all([worker(), worker(), worker()]) | ||
| 35 | +} | ||
| 36 | + | ||
| 37 | +onMounted(async () => { | ||
| 9 | if (!home.value) refreshHome() | 38 | if (!home.value) refreshHome() |
| 10 | // Projects may have changed on disk (e.g. a folder was deleted) since the layout loaded them. | 39 | // Projects may have changed on disk (e.g. a folder was deleted) since the layout loaded them. |
| 11 | - if (loaded.value) refreshProjects() | 40 | + if (loaded.value) await refreshProjects() |
| 41 | + else await new Promise<void>((res) => { const stop = watch(loaded, (v) => { if (v) { stop(); res() } }) }) | ||
| 42 | + loadSyncs() | ||
| 12 | }) | 43 | }) |
| 13 | 44 | ||
| 14 | function countItems(inv: ScopeInventory): Record<ItemType, number> { | 45 | function countItems(inv: ScopeInventory): Record<ItemType, number> { |
| @@ -36,59 +67,59 @@ const homeCounts = computed(() => (home.value ? countItems(home.value) : null)) | |||
| 36 | Everything your coding agents can see: skills, rules, MCP servers, hooks, subagents and instruction files. | 67 | Everything your coding agents can see: skills, rules, MCP servers, hooks, subagents and instruction files. |
| 37 | </p> | 68 | </p> |
| 38 | 69 | ||
| 39 | - <div class="grid gap-4 md:grid-cols-2"> | 70 | + <div class="grid gap-3 md:grid-cols-2"> |
| 40 | <!-- Home --> | 71 | <!-- Home --> |
| 41 | - <UCard> | 72 | + <NuxtLink to="/home" class="block rounded-xl border border-default p-3 transition-colors hover:bg-elevated/50 focus-visible:outline-2 focus-visible:outline-primary"> |
| 42 | - <template #header> | 73 | + <div class="flex items-center gap-2"> |
| 43 | - <div class="flex items-center gap-2"> | 74 | + <UIcon name="i-lucide-house" class="size-4 shrink-0 text-primary" /> |
| 44 | - <UIcon name="i-lucide-house" class="size-5 text-primary" /> | 75 | + <h2 class="font-semibold">Home</h2> |
| 45 | - <h2 class="font-semibold">Home</h2> | 76 | + <span class="truncate text-xs text-muted">user-level configuration</span> |
| 46 | - <span class="text-xs text-muted">user-level configuration</span> | 77 | + <UBadge v-if="syncs.home" :label="syncs.home.label" :color="syncs.home.color" :icon="syncs.home.icon" variant="subtle" size="sm" class="ml-auto shrink-0" /> |
| 47 | - </div> | 78 | + <USkeleton v-else-if="syncs.home === undefined" class="ml-auto h-5 w-16" /> |
| 48 | - </template> | ||
| 49 | - <div v-if="homeLoading && !home" class="space-y-2"> | ||
| 50 | - <USkeleton class="h-4 w-2/3" /> | ||
| 51 | - <USkeleton class="h-4 w-full" /> | ||
| 52 | </div> | 79 | </div> |
| 53 | - <UAlert v-else-if="homeError" color="error" variant="subtle" icon="i-lucide-circle-alert" :title="homeError" /> | 80 | + <div v-if="homeLoading && !home" class="mt-2 space-y-2"><USkeleton class="h-4 w-full" /></div> |
| 54 | - <div v-else-if="home && homeCounts" class="space-y-3"> | 81 | + <UAlert v-else-if="homeError" color="error" variant="subtle" icon="i-lucide-circle-alert" :title="homeError" class="mt-2" /> |
| 82 | + <div v-else-if="home && homeCounts" class="mt-2 space-y-2"> | ||
| 55 | <p class="truncate font-mono text-xs text-muted" :title="home.root">{{ home.root }}</p> | 83 | <p class="truncate font-mono text-xs text-muted" :title="home.root">{{ home.root }}</p> |
| 56 | - <div class="flex gap-2"> | 84 | + <div class="flex flex-wrap items-center gap-x-3 gap-y-1.5"> |
| 57 | - <AgentBadge v-for="a in home.agentsDetected" :key="a" :agent="a" /> | 85 | + <span class="flex gap-1"> |
| 58 | - <span v-if="!home.agentsDetected.length" class="text-sm text-muted">No agent detected</span> | 86 | + <AgentBadge v-for="a in home.agentsDetected" :key="a" :agent="a" compact /> |
| 87 | + <span v-if="!home.agentsDetected.length" class="text-xs text-muted">No agent</span> | ||
| 88 | + </span> | ||
| 89 | + <CountChips :counts="homeCounts" compact /> | ||
| 59 | </div> | 90 | </div> |
| 60 | - <CountChips :counts="homeCounts" /> | ||
| 61 | </div> | 91 | </div> |
| 62 | - <template #footer> | 92 | + </NuxtLink> |
| 63 | - <UButton to="/home" label="Open" trailing-icon="i-lucide-arrow-right" color="neutral" variant="soft" size="sm" /> | ||
| 64 | - </template> | ||
| 65 | - </UCard> | ||
| 66 | 93 | ||
| 67 | <!-- Projects --> | 94 | <!-- Projects --> |
| 68 | <template v-if="loaded"> | 95 | <template v-if="loaded"> |
| 69 | - <UCard v-for="p in projects" :key="p.id"> | 96 | + <NuxtLink |
| 70 | - <template #header> | 97 | + v-for="p in projects" |
| 71 | - <div class="flex items-center gap-2"> | 98 | + :key="p.id" |
| 72 | - <UIcon name="i-lucide-folder" class="size-5 text-primary" /> | 99 | + :to="`/projects/${encodeURIComponent(p.id)}`" |
| 73 | - <h2 class="truncate font-semibold">{{ p.name }}</h2> | 100 | + class="block rounded-xl border border-default p-3 transition-colors hover:bg-elevated/50 focus-visible:outline-2 focus-visible:outline-primary" |
| 74 | - <UBadge v-if="!p.exists" label="Folder missing" color="warning" variant="subtle" icon="i-lucide-triangle-alert" size="sm" /> | 101 | + > |
| 75 | - </div> | 102 | + <div class="flex items-center gap-2"> |
| 76 | - </template> | 103 | + <UIcon name="i-lucide-folder" class="size-4 shrink-0 text-primary" /> |
| 77 | - <div class="space-y-3"> | 104 | + <h2 class="truncate font-semibold">{{ p.name }}</h2> |
| 105 | + <UBadge v-if="!p.exists" label="Folder missing" color="warning" variant="subtle" icon="i-lucide-triangle-alert" size="sm" class="ml-auto shrink-0" /> | ||
| 106 | + <UBadge v-else-if="syncs[p.id]" :label="syncs[p.id]!.label" :color="syncs[p.id]!.color" :icon="syncs[p.id]!.icon" variant="subtle" size="sm" class="ml-auto shrink-0" /> | ||
| 107 | + <USkeleton v-else-if="syncs[p.id] === undefined" class="ml-auto h-5 w-16" /> | ||
| 108 | + </div> | ||
| 109 | + <div class="mt-2 space-y-2"> | ||
| 78 | <p class="truncate font-mono text-xs text-muted" :title="p.path">{{ p.path }}</p> | 110 | <p class="truncate font-mono text-xs text-muted" :title="p.path">{{ p.path }}</p> |
| 79 | - <div class="flex gap-2"> | 111 | + <div class="flex flex-wrap items-center gap-x-3 gap-y-1.5"> |
| 80 | - <AgentBadge v-for="a in p.agentsDetected" :key="a" :agent="a" /> | 112 | + <span class="flex gap-1"> |
| 81 | - <span v-if="!p.agentsDetected.length" class="text-sm text-muted">No agent detected</span> | 113 | + <AgentBadge v-for="a in p.agentsDetected" :key="a" :agent="a" compact /> |
| 114 | + <span v-if="!p.agentsDetected.length" class="text-xs text-muted">No agent</span> | ||
| 115 | + </span> | ||
| 116 | + <CountChips :counts="p.counts" compact /> | ||
| 82 | </div> | 117 | </div> |
| 83 | - <CountChips :counts="p.counts" /> | ||
| 84 | </div> | 118 | </div> |
| 85 | - <template #footer> | 119 | + </NuxtLink> |
| 86 | - <UButton :to="`/projects/${encodeURIComponent(p.id)}`" label="Open" trailing-icon="i-lucide-arrow-right" color="neutral" variant="soft" size="sm" /> | ||
| 87 | - </template> | ||
| 88 | - </UCard> | ||
| 89 | </template> | 120 | </template> |
| 90 | <template v-else> | 121 | <template v-else> |
| 91 | - <USkeleton class="h-48 w-full" /> | 122 | + <USkeleton class="h-24 w-full" /> |
| 92 | </template> | 123 | </template> |
| 93 | </div> | 124 | </div> |
| 94 | 125 | ||
| @@ -109,7 +140,7 @@ const homeCounts = computed(() => (home.value ? countItems(home.value) : null)) | |||
| 109 | <UIcon name="i-lucide-folder-plus" class="size-10 text-dimmed" /> | 140 | <UIcon name="i-lucide-folder-plus" class="size-10 text-dimmed" /> |
| 110 | <h3 class="font-semibold">Add your first project</h3> | 141 | <h3 class="font-semibold">Add your first project</h3> |
| 111 | <p class="max-w-md text-sm text-muted"> | 142 | <p class="max-w-md text-sm text-muted"> |
| 112 | - Pick a folder to see which skills, rules, MCP servers and instructions Claude Code and Codex use there. | 143 | + Pick a folder to see which skills, rules, MCP servers and instructions Claude Code and Codex use there, then manage them from your catalog. |
| 113 | </p> | 144 | </p> |
| 114 | <UButton icon="i-lucide-folder-plus" label="Add project" @click="addOpen = true" /> | 145 | <UButton icon="i-lucide-folder-plus" label="Add project" @click="addOpen = true" /> |
| 115 | </div> | 146 | </div> |
added
packages/ui/app/pages/marketplaces/index.vue +386 -0 | new file mode 100644 | ||
| @@ -0,0 +1,386 @@ | ||
| 1 | +<script setup lang="ts"> | |
| 2 | +import type { ImportResult, MarketplaceSkill, MarketplaceSkillDetail, MarketplaceSource } from '@skilly/core' | |
| 3 | + | |
| 4 | +const { request } = useApi() | |
| 5 | +const catalog = useCatalog() | |
| 6 | +const toast = useToast() | |
| 7 | + | |
| 8 | +const sources = ref<MarketplaceSource[]>([]) | |
| 9 | +const sourcesLoading = ref(true) | |
| 10 | +const sourcesError = ref<string | null>(null) | |
| 11 | +const selectedId = ref<string | null>(null) | |
| 12 | +const selected = computed(() => sources.value.find(s => s.id === selectedId.value) ?? null) | |
| 13 | + | |
| 14 | +const skills = ref<MarketplaceSkill[]>([]) | |
| 15 | +const skillsLoading = ref(false) | |
| 16 | +const skillsError = ref<string | null>(null) | |
| 17 | +const refreshing = ref(false) | |
| 18 | +const search = ref('') | |
| 19 | +const picked = ref<Set<string>>(new Set()) | |
| 20 | +const LIMIT = 300 | |
| 21 | +const showAll = ref(false) | |
| 22 | + | |
| 23 | +async function loadSources() { | |
| 24 | + sourcesLoading.value = true | |
| 25 | + try { | |
| 26 | + sources.value = await request<MarketplaceSource[]>('/api/marketplaces', { silent: true }) | |
| 27 | + sourcesError.value = null | |
| 28 | + if (!selectedId.value && sources.value.length) await select(sources.value[0]!.id) | |
| 29 | + } catch (e) { | |
| 30 | + sourcesError.value = (e as Error).message | |
| 31 | + } finally { | |
| 32 | + sourcesLoading.value = false | |
| 33 | + } | |
| 34 | +} | |
| 35 | + | |
| 36 | +async function loadSkills() { | |
| 37 | + if (!selectedId.value) return | |
| 38 | + skillsLoading.value = true | |
| 39 | + skillsError.value = null | |
| 40 | + try { | |
| 41 | + skills.value = await request<MarketplaceSkill[]>(`/api/marketplaces/${encodeURIComponent(selectedId.value)}/skills`, { silent: true }) | |
| 42 | + } catch (e) { | |
| 43 | + skills.value = [] | |
| 44 | + skillsError.value = (e as Error).message | |
| 45 | + } finally { | |
| 46 | + skillsLoading.value = false | |
| 47 | + } | |
| 48 | +} | |
| 49 | + | |
| 50 | +async function select(id: string) { | |
| 51 | + selectedId.value = id | |
| 52 | + picked.value = new Set() | |
| 53 | + search.value = '' | |
| 54 | + showAll.value = false | |
| 55 | + await loadSkills() | |
| 56 | +} | |
| 57 | + | |
| 58 | +async function refreshSource() { | |
| 59 | + if (!selected.value) return | |
| 60 | + refreshing.value = true | |
| 61 | + try { | |
| 62 | + const updated = await request<MarketplaceSource>(`/api/marketplaces/${encodeURIComponent(selected.value.id)}/refresh`, { method: 'POST', silent: true }) | |
| 63 | + sources.value = sources.value.map(s => (s.id === updated.id ? updated : s)) | |
| 64 | + await loadSkills() | |
| 65 | + toast.add({ title: 'Source refreshed', description: updated.name, icon: 'i-lucide-check', color: 'success' }) | |
| 66 | + } catch (e) { | |
| 67 | + toast.add({ title: 'Refresh failed', description: (e as Error).message, color: 'error', icon: 'i-lucide-circle-alert' }) | |
| 68 | + } finally { | |
| 69 | + refreshing.value = false | |
| 70 | + } | |
| 71 | +} | |
| 72 | + | |
| 73 | +onMounted(() => { | |
| 74 | + loadSources() | |
| 75 | + if (!catalog.data.value) catalog.refresh() | |
| 76 | +}) | |
| 77 | + | |
| 78 | +/* ------------------------------------------------ List */ | |
| 79 | + | |
| 80 | +const filtered = computed(() => { | |
| 81 | + const q = search.value.trim().toLowerCase() | |
| 82 | + return q ? skills.value.filter(s => `${s.name} ${s.description ?? ''} ${s.plugin ?? ''}`.toLowerCase().includes(q)) : skills.value | |
| 83 | +}) | |
| 84 | +const shown = computed(() => (showAll.value ? filtered.value : filtered.value.slice(0, LIMIT))) | |
| 85 | +const groups = computed(() => { | |
| 86 | + if (selected.value?.kind !== 'claude-marketplace') return [{ plugin: null as string | null, rows: shown.value }] | |
| 87 | + const m = new Map<string, MarketplaceSkill[]>() | |
| 88 | + for (const s of shown.value) { | |
| 89 | + const k = s.plugin ?? '(no plugin)' | |
| 90 | + m.set(k, [...(m.get(k) ?? []), s]) | |
| 91 | + } | |
| 92 | + return [...m.entries()].map(([plugin, rows]) => ({ plugin, rows })) | |
| 93 | +}) | |
| 94 | + | |
| 95 | +function toggle(path: string, on: boolean | 'indeterminate') { | |
| 96 | + const s = new Set(picked.value) | |
| 97 | + if (on === true) s.add(path) | |
| 98 | + else s.delete(path) | |
| 99 | + picked.value = s | |
| 100 | +} | |
| 101 | +function toggleGroup(rows: MarketplaceSkill[], on: boolean | 'indeterminate') { | |
| 102 | + const s = new Set(picked.value) | |
| 103 | + for (const r of rows) (on === true ? s.add(r.path) : s.delete(r.path)) | |
| 104 | + picked.value = s | |
| 105 | +} | |
| 106 | +const groupState = (rows: MarketplaceSkill[]) => { | |
| 107 | + const n = rows.filter(r => picked.value.has(r.path)).length | |
| 108 | + return n === 0 ? false : n === rows.length ? true : 'indeterminate' as const | |
| 109 | +} | |
| 110 | + | |
| 111 | +/* ------------------------------------------------ Import */ | |
| 112 | + | |
| 113 | +const differing = computed(() => skills.value.filter(s => picked.value.has(s.path) && s.inCatalog === 'different')) | |
| 114 | +const confirmOpen = ref(false) | |
| 115 | +const importing = ref(false) | |
| 116 | + | |
| 117 | +function startImport() { | |
| 118 | + if (differing.value.length) confirmOpen.value = true | |
| 119 | + else doImport(false) | |
| 120 | +} | |
| 121 | + | |
| 122 | +async function doImport(overwrite: boolean) { | |
| 123 | + if (!selected.value) return | |
| 124 | + importing.value = true | |
| 125 | + try { | |
| 126 | + const res = await request<ImportResult>(`/api/marketplaces/${encodeURIComponent(selected.value.id)}/import`, { | |
| 127 | + method: 'POST', | |
| 128 | + body: { paths: [...picked.value], overwrite }, | |
| 129 | + silent: true | |
| 130 | + }) | |
| 131 | + confirmOpen.value = false | |
| 132 | + toast.add({ | |
| 133 | + title: `${res.imported.length} imported`, | |
| 134 | + description: res.skipped.length ? `${res.skipped.length} skipped: ${res.skipped.map(s => `${s.id} (${s.reason})`).join('; ')}` : 'Added to the catalog.', | |
| 135 | + icon: 'i-lucide-check', | |
| 136 | + color: res.skipped.length ? 'warning' : 'success' | |
| 137 | + }) | |
| 138 | + picked.value = new Set() | |
| 139 | + await Promise.all([loadSkills(), catalog.refresh()]) | |
| 140 | + } catch (e) { | |
| 141 | + toast.add({ title: 'Import failed', description: (e as Error).message, color: 'error', icon: 'i-lucide-circle-alert' }) | |
| 142 | + } finally { | |
| 143 | + importing.value = false | |
| 144 | + } | |
| 145 | +} | |
| 146 | + | |
| 147 | +/* ------------------------------------------------ Add / remove source */ | |
| 148 | + | |
| 149 | +const addOpen = ref(false) | |
| 150 | +const adding = ref(false) | |
| 151 | +const addError = ref<string | null>(null) | |
| 152 | +const addForm = reactive({ name: '', kind: 'git' as MarketplaceSource['kind'], url: '', ref: '' }) | |
| 153 | +const KINDS = [ | |
| 154 | + { label: 'Git repository (skill folders)', value: 'git' }, | |
| 155 | + { label: 'Claude plugin marketplace', value: 'claude-marketplace' } | |
| 156 | +] | |
| 157 | + | |
| 158 | +async function addSource() { | |
| 159 | + adding.value = true | |
| 160 | + addError.value = null | |
| 161 | + try { | |
| 162 | + const created = await request<MarketplaceSource>('/api/marketplaces', { | |
| 163 | + method: 'POST', | |
| 164 | + body: { name: addForm.name.trim(), kind: addForm.kind, url: addForm.url.trim(), ...(addForm.ref.trim() ? { ref: addForm.ref.trim() } : {}) }, | |
| 165 | + silent: true | |
| 166 | + }) | |
| 167 | + addOpen.value = false | |
| 168 | + Object.assign(addForm, { name: '', kind: 'git', url: '', ref: '' }) | |
| 169 | + await loadSources() | |
| 170 | + await select(created.id) | |
| 171 | + } catch (e) { | |
| 172 | + addError.value = (e as Error).message | |
| 173 | + } finally { | |
| 174 | + adding.value = false | |
| 175 | + } | |
| 176 | +} | |
| 177 | + | |
| 178 | +const removeTarget = ref<MarketplaceSource | null>(null) | |
| 179 | +async function removeSource() { | |
| 180 | + const s = removeTarget.value | |
| 181 | + if (!s) return | |
| 182 | + try { | |
| 183 | + await request(`/api/marketplaces/${encodeURIComponent(s.id)}`, { method: 'DELETE' }) | |
| 184 | + removeTarget.value = null | |
| 185 | + if (selectedId.value === s.id) { selectedId.value = null; skills.value = [] } | |
| 186 | + await loadSources() | |
| 187 | + } catch { /* toast shown */ } | |
| 188 | +} | |
| 189 | + | |
| 190 | +/* ------------------------------------------------ Preview */ | |
| 191 | + | |
| 192 | +const previewOpen = ref(false) | |
| 193 | +const preview = ref<MarketplaceSkillDetail | null>(null) | |
| 194 | +const previewLoading = ref(false) | |
| 195 | +const previewError = ref<string | null>(null) | |
| 196 | +const previewHtml = computed(() => (preview.value ? renderMarkdown(splitFrontmatter(preview.value.content).body) : '')) | |
| 197 | +const previewFm = computed(() => (preview.value ? splitFrontmatter(preview.value.content).frontmatter : null)) | |
| 198 | + | |
| 199 | +async function openPreview(s: MarketplaceSkill) { | |
| 200 | + previewOpen.value = true | |
| 201 | + preview.value = null | |
| 202 | + previewError.value = null | |
| 203 | + previewLoading.value = true | |
| 204 | + try { | |
| 205 | + preview.value = await request<MarketplaceSkillDetail>(`/api/marketplaces/${encodeURIComponent(s.sourceId)}/skill`, { query: { path: s.path }, silent: true }) | |
| 206 | + } catch (e) { | |
| 207 | + previewError.value = (e as Error).message | |
| 208 | + } finally { | |
| 209 | + previewLoading.value = false | |
| 210 | + } | |
| 211 | +} | |
| 212 | + | |
| 213 | +const sinceFetched = (s: MarketplaceSource) => (s.lastFetchedAt ? `fetched ${formatDate(s.lastFetchedAt)}` : 'never fetched') | |
| 214 | +</script> | |
| 215 | + | |
| 216 | +<template> | |
| 217 | + <UDashboardPanel id="marketplaces"> | |
| 218 | + <template #header> | |
| 219 | + <UDashboardNavbar title="Marketplaces" icon="i-lucide-store"> | |
| 220 | + <template #right> | |
| 221 | + <UButton label="Add source" icon="i-lucide-plus" color="neutral" variant="outline" @click="addOpen = true" /> | |
| 222 | + </template> | |
| 223 | + </UDashboardNavbar> | |
| 224 | + </template> | |
| 225 | + | |
| 226 | + <template #body> | |
| 227 | + <USkeleton v-if="sourcesLoading && !sources.length" class="h-64 w-full" /> | |
| 228 | + <UAlert v-else-if="sourcesError" color="error" variant="subtle" icon="i-lucide-circle-alert" title="Could not load marketplaces" :description="sourcesError" :actions="[{ label: 'Retry', color: 'neutral', variant: 'outline', onClick: loadSources }]" /> | |
| 229 | + | |
| 230 | + <div v-else class="grid items-start gap-6 lg:grid-cols-[16rem_minmax(0,1fr)]"> | |
| 231 | + <!-- Sources --> | |
| 232 | + <aside class="space-y-1"> | |
| 233 | + <div | |
| 234 | + v-for="s in sources" | |
| 235 | + :key="s.id" | |
| 236 | + class="group flex cursor-pointer items-start gap-2 rounded-lg border px-3 py-2 transition-colors" | |
| 237 | + :class="s.id === selectedId ? 'border-primary bg-primary/5' : 'border-default hover:bg-elevated/50'" | |
| 238 | + tabindex="0" | |
| 239 | + @click="select(s.id)" | |
| 240 | + @keydown.enter="select(s.id)" | |
| 241 | + > | |
| 242 | + <UIcon :name="s.kind === 'git' ? 'i-lucide-git-fork' : 'i-lucide-puzzle'" class="mt-0.5 size-4 shrink-0 text-muted" /> | |
| 243 | + <div class="min-w-0 flex-1"> | |
| 244 | + <div class="flex items-center gap-1.5"> | |
| 245 | + <span class="truncate text-sm font-medium">{{ s.name }}</span> | |
| 246 | + <UBadge v-if="s.builtIn" label="built-in" size="sm" color="neutral" variant="subtle" /> | |
| 247 | + </div> | |
| 248 | + <p class="truncate text-xs text-muted" :title="s.url">{{ s.url }}</p> | |
| 249 | + </div> | |
| 250 | + <UButton v-if="!s.builtIn" icon="i-lucide-trash-2" size="xs" color="neutral" variant="ghost" aria-label="Remove source" @click.stop="removeTarget = s" /> | |
| 251 | + </div> | |
| 252 | + </aside> | |
| 253 | + | |
| 254 | + <!-- Selected source --> | |
| 255 | + <section v-if="selected" class="min-w-0 space-y-4"> | |
| 256 | + <div class="flex flex-wrap items-center gap-3"> | |
| 257 | + <div class="min-w-0"> | |
| 258 | + <h2 class="truncate font-semibold">{{ selected.name }}</h2> | |
| 259 | + <p class="text-xs text-muted">{{ selected.kind === 'git' ? 'Git repository' : 'Claude plugin marketplace' }}<span v-if="selected.ref"> - {{ selected.ref }}</span> - {{ sinceFetched(selected) }}</p> | |
| 260 | + </div> | |
| 261 | + <UButton class="ml-auto" label="Refresh" icon="i-lucide-refresh-cw" color="neutral" variant="outline" :loading="refreshing" @click="refreshSource" /> | |
| 262 | + </div> | |
| 263 | + <UAlert v-if="refreshing" color="info" variant="subtle" icon="i-lucide-loader" title="Fetching the repository" description="Cloning or updating can take tens of seconds for large repositories." /> | |
| 264 | + | |
| 265 | + <div class="flex flex-wrap items-center gap-3"> | |
| 266 | + <UInput v-model="search" icon="i-lucide-search" placeholder="Search skills..." class="w-72" /> | |
| 267 | + <span class="text-xs text-muted">{{ filtered.length }} skill{{ filtered.length === 1 ? '' : 's' }}</span> | |
| 268 | + <UButton class="ml-auto" :label="`Import ${picked.size || ''}`.trim()" icon="i-lucide-download" :disabled="!picked.size" :loading="importing" @click="startImport" /> | |
| 269 | + </div> | |
| 270 | + | |
| 271 | + <div v-if="skillsLoading" class="space-y-2"> | |
| 272 | + <UAlert color="info" variant="subtle" icon="i-lucide-loader" title="Loading skills" description="The first load clones the repository; this can take a while." /> | |
| 273 | + <USkeleton v-for="i in 4" :key="i" class="h-12 w-full" /> | |
| 274 | + </div> | |
| 275 | + <UAlert v-else-if="skillsError" color="error" variant="subtle" icon="i-lucide-circle-alert" title="Could not load the skills" :description="skillsError" :actions="[{ label: 'Refresh source', color: 'neutral', variant: 'outline', onClick: refreshSource }]" /> | |
| 276 | + <div v-else-if="!filtered.length" class="flex flex-col items-center gap-2 rounded-xl border border-dashed border-default py-12 text-center"> | |
| 277 | + <UIcon :name="search ? 'i-lucide-search-x' : 'i-lucide-store'" class="size-8 text-dimmed" /> | |
| 278 | + <p class="text-sm text-muted">{{ search ? 'No skill matches the search.' : 'No skill found. Try Refresh.' }}</p> | |
| 279 | + </div> | |
| 280 | + | |
| 281 | + <div v-else class="space-y-3"> | |
| 282 | + <div v-for="g in groups" :key="g.plugin ?? 'all'" class="overflow-hidden rounded-xl border border-default"> | |
| 283 | + <div v-if="g.plugin" class="flex items-center gap-2 border-b border-default bg-elevated/40 px-3 py-1.5 text-xs font-medium text-muted"> | |
| 284 | + <UCheckbox :model-value="groupState(g.rows)" :aria-label="`Select plugin ${g.plugin}`" @update:model-value="(v) => toggleGroup(g.rows, v)" /> | |
| 285 | + <UIcon name="i-lucide-puzzle" class="size-3.5" />{{ g.plugin }} ({{ g.rows.length }}) | |
| 286 | + </div> | |
| 287 | + <ul class="divide-y divide-default"> | |
| 288 | + <li v-for="s in g.rows" :key="s.path" class="flex items-start gap-3 px-3 py-2 hover:bg-elevated/40"> | |
| 289 | + <UCheckbox class="mt-0.5" :model-value="picked.has(s.path)" :aria-label="`Select ${s.name}`" @update:model-value="(v) => toggle(s.path, v)" /> | |
| 290 | + <button type="button" class="min-w-0 flex-1 text-left" @click="openPreview(s)"> | |
| 291 | + <div class="flex items-center gap-2"> | |
| 292 | + <span class="truncate text-sm font-medium text-highlighted">{{ s.name }}</span> | |
| 293 | + <UBadge v-if="s.inCatalog === 'same'" label="In catalog" icon="i-lucide-check" size="sm" color="success" variant="subtle" /> | |
| 294 | + <UBadge v-else-if="s.inCatalog === 'different'" label="In catalog (different)" icon="i-lucide-git-compare" size="sm" color="warning" variant="subtle" /> | |
| 295 | + </div> | |
| 296 | + <p v-if="s.description" class="line-clamp-2 text-xs text-muted">{{ s.description }}</p> | |
| 297 | + <p class="truncate font-mono text-[11px] text-dimmed">{{ s.path }}</p> | |
| 298 | + </button> | |
| 299 | + <UButton icon="i-lucide-eye" size="xs" color="neutral" variant="ghost" aria-label="Preview" @click="openPreview(s)" /> | |
| 300 | + </li> | |
| 301 | + </ul> | |
| 302 | + </div> | |
| 303 | + <div v-if="filtered.length > shown.length" class="text-center"> | |
| 304 | + <UButton :label="`Show all ${filtered.length}`" size="xs" color="neutral" variant="link" @click="showAll = true" /> | |
| 305 | + </div> | |
| 306 | + </div> | |
| 307 | + </section> | |
| 308 | + </div> | |
| 309 | + | |
| 310 | + <!-- Preview --> | |
| 311 | + <USlideover v-model:open="previewOpen" :title="preview?.name ?? 'Preview'" :description="preview?.description" :ui="{ content: 'sm:max-w-2xl', description: 'line-clamp-3' }"> | |
| 312 | + <template #body> | |
| 313 | + <div v-if="previewLoading" class="space-y-2"><USkeleton class="h-4 w-1/3" /><USkeleton class="h-4 w-full" /><USkeleton class="h-4 w-5/6" /></div> | |
| 314 | + <UAlert v-else-if="previewError" color="error" variant="subtle" icon="i-lucide-circle-alert" title="Could not load the preview" :description="previewError" /> | |
| 315 | + <div v-else-if="preview" class="space-y-4"> | |
| 316 | + <div v-if="previewFm" class="overflow-hidden rounded-lg border border-default"> | |
| 317 | + <table class="w-full text-xs"> | |
| 318 | + <tbody class="divide-y divide-default"> | |
| 319 | + <tr v-for="(v, k) in previewFm" :key="k"> | |
| 320 | + <th class="w-1/4 bg-elevated/40 px-2 py-1.5 text-left align-top font-medium text-muted">{{ k }}</th> | |
| 321 | + <td class="px-2 py-1.5 break-words">{{ v }}</td> | |
| 322 | + </tr> | |
| 323 | + </tbody> | |
| 324 | + </table> | |
| 325 | + </div> | |
| 326 | + <!-- eslint-disable-next-line vue/no-v-html --> | |
| 327 | + <div class="md-body" v-html="previewHtml" /> | |
| 328 | + <div v-if="preview.files.length" class="space-y-1"> | |
| 329 | + <h3 class="text-sm font-semibold">Files ({{ preview.files.length }})</h3> | |
| 330 | + <ul class="max-h-48 overflow-auto rounded-lg border border-default text-xs"> | |
| 331 | + <li v-for="f in preview.files" :key="f" class="flex items-center gap-2 px-3 py-1.5 font-mono"><UIcon name="i-lucide-file" class="size-3.5 text-muted" />{{ f }}</li> | |
| 332 | + </ul> | |
| 333 | + </div> | |
| 334 | + </div> | |
| 335 | + </template> | |
| 336 | + </USlideover> | |
| 337 | + | |
| 338 | + <!-- Overwrite confirmation --> | |
| 339 | + <UModal v-model:open="confirmOpen" title="Overwrite catalog items?" description="Some selected skills already exist in the catalog with different content."> | |
| 340 | + <template #body> | |
| 341 | + <ul class="list-disc space-y-0.5 pl-4 text-sm"> | |
| 342 | + <li v-for="s in differing" :key="s.path">{{ s.name }}</li> | |
| 343 | + </ul> | |
| 344 | + <p class="mt-3 text-sm text-muted">Overwriting replaces the catalog version with the marketplace version. Local edits in the catalog are lost (git history keeps them).</p> | |
| 345 | + </template> | |
| 346 | + <template #footer> | |
| 347 | + <div class="flex w-full justify-end gap-2"> | |
| 348 | + <UButton label="Cancel" color="neutral" variant="outline" @click="confirmOpen = false" /> | |
| 349 | + <UButton label="Overwrite and import" color="warning" :loading="importing" @click="doImport(true)" /> | |
| 350 | + </div> | |
| 351 | + </template> | |
| 352 | + </UModal> | |
| 353 | + | |
| 354 | + <!-- Add source --> | |
| 355 | + <UModal v-model:open="addOpen" title="Add a source" description="A git repository containing skills, or a Claude Code plugin marketplace."> | |
| 356 | + <template #body> | |
| 357 | + <form id="add-source" class="space-y-3" @submit.prevent="addSource"> | |
| 358 | + <UAlert v-if="addError" color="error" variant="subtle" icon="i-lucide-circle-alert" :title="addError" /> | |
| 359 | + <UFormField label="Name" required><UInput v-model="addForm.name" class="w-full" placeholder="My skills" /></UFormField> | |
| 360 | + <UFormField label="Kind"><USelect v-model="addForm.kind" :items="KINDS" class="w-full" /></UFormField> | |
| 361 | + <UFormField label="URL" required><UInput v-model="addForm.url" class="w-full" placeholder="https://github.com/owner/repo.git" :ui="{ base: 'font-mono' }" /></UFormField> | |
| 362 | + <UFormField label="Ref" description="Branch or tag; default branch when empty."><UInput v-model="addForm.ref" class="w-full" placeholder="main" :ui="{ base: 'font-mono' }" /></UFormField> | |
| 363 | + </form> | |
| 364 | + </template> | |
| 365 | + <template #footer> | |
| 366 | + <div class="flex w-full justify-end gap-2"> | |
| 367 | + <UButton label="Cancel" color="neutral" variant="outline" @click="addOpen = false" /> | |
| 368 | + <UButton type="submit" form="add-source" label="Add source" :loading="adding" :disabled="!addForm.name.trim() || !addForm.url.trim()" /> | |
| 369 | + </div> | |
| 370 | + </template> | |
| 371 | + </UModal> | |
| 372 | + | |
| 373 | + <UModal :open="!!removeTarget" title="Remove source" description="Imported catalog items are kept." @update:open="(v: boolean) => { if (!v) removeTarget = null }"> | |
| 374 | + <template #body> | |
| 375 | + <p class="text-sm">Remove <strong>{{ removeTarget?.name }}</strong> from the list of sources?</p> | |
| 376 | + </template> | |
| 377 | + <template #footer> | |
| 378 | + <div class="flex w-full justify-end gap-2"> | |
| 379 | + <UButton label="Cancel" color="neutral" variant="outline" @click="removeTarget = null" /> | |
| 380 | + <UButton label="Remove" color="error" @click="removeSource" /> | |
| 381 | + </div> | |
| 382 | + </template> | |
| 383 | + </UModal> | |
| 384 | + </template> | |
| 385 | + </UDashboardPanel> | |
| 386 | +</template> | |
| new file mode 100644 | |||
| @@ -0,0 +1,386 @@ | |||
| 1 | +<script setup lang="ts"> | ||
| 2 | +import type { ImportResult, MarketplaceSkill, MarketplaceSkillDetail, MarketplaceSource } from '@skilly/core' | ||
| 3 | + | ||
| 4 | +const { request } = useApi() | ||
| 5 | +const catalog = useCatalog() | ||
| 6 | +const toast = useToast() | ||
| 7 | + | ||
| 8 | +const sources = ref<MarketplaceSource[]>([]) | ||
| 9 | +const sourcesLoading = ref(true) | ||
| 10 | +const sourcesError = ref<string | null>(null) | ||
| 11 | +const selectedId = ref<string | null>(null) | ||
| 12 | +const selected = computed(() => sources.value.find(s => s.id === selectedId.value) ?? null) | ||
| 13 | + | ||
| 14 | +const skills = ref<MarketplaceSkill[]>([]) | ||
| 15 | +const skillsLoading = ref(false) | ||
| 16 | +const skillsError = ref<string | null>(null) | ||
| 17 | +const refreshing = ref(false) | ||
| 18 | +const search = ref('') | ||
| 19 | +const picked = ref<Set<string>>(new Set()) | ||
| 20 | +const LIMIT = 300 | ||
| 21 | +const showAll = ref(false) | ||
| 22 | + | ||
| 23 | +async function loadSources() { | ||
| 24 | + sourcesLoading.value = true | ||
| 25 | + try { | ||
| 26 | + sources.value = await request<MarketplaceSource[]>('/api/marketplaces', { silent: true }) | ||
| 27 | + sourcesError.value = null | ||
| 28 | + if (!selectedId.value && sources.value.length) await select(sources.value[0]!.id) | ||
| 29 | + } catch (e) { | ||
| 30 | + sourcesError.value = (e as Error).message | ||
| 31 | + } finally { | ||
| 32 | + sourcesLoading.value = false | ||
| 33 | + } | ||
| 34 | +} | ||
| 35 | + | ||
| 36 | +async function loadSkills() { | ||
| 37 | + if (!selectedId.value) return | ||
| 38 | + skillsLoading.value = true | ||
| 39 | + skillsError.value = null | ||
| 40 | + try { | ||
| 41 | + skills.value = await request<MarketplaceSkill[]>(`/api/marketplaces/${encodeURIComponent(selectedId.value)}/skills`, { silent: true }) | ||
| 42 | + } catch (e) { | ||
| 43 | + skills.value = [] | ||
| 44 | + skillsError.value = (e as Error).message | ||
| 45 | + } finally { | ||
| 46 | + skillsLoading.value = false | ||
| 47 | + } | ||
| 48 | +} | ||
| 49 | + | ||
| 50 | +async function select(id: string) { | ||
| 51 | + selectedId.value = id | ||
| 52 | + picked.value = new Set() | ||
| 53 | + search.value = '' | ||
| 54 | + showAll.value = false | ||
| 55 | + await loadSkills() | ||
| 56 | +} | ||
| 57 | + | ||
| 58 | +async function refreshSource() { | ||
| 59 | + if (!selected.value) return | ||
| 60 | + refreshing.value = true | ||
| 61 | + try { | ||
| 62 | + const updated = await request<MarketplaceSource>(`/api/marketplaces/${encodeURIComponent(selected.value.id)}/refresh`, { method: 'POST', silent: true }) | ||
| 63 | + sources.value = sources.value.map(s => (s.id === updated.id ? updated : s)) | ||
| 64 | + await loadSkills() | ||
| 65 | + toast.add({ title: 'Source refreshed', description: updated.name, icon: 'i-lucide-check', color: 'success' }) | ||
| 66 | + } catch (e) { | ||
| 67 | + toast.add({ title: 'Refresh failed', description: (e as Error).message, color: 'error', icon: 'i-lucide-circle-alert' }) | ||
| 68 | + } finally { | ||
| 69 | + refreshing.value = false | ||
| 70 | + } | ||
| 71 | +} | ||
| 72 | + | ||
| 73 | +onMounted(() => { | ||
| 74 | + loadSources() | ||
| 75 | + if (!catalog.data.value) catalog.refresh() | ||
| 76 | +}) | ||
| 77 | + | ||
| 78 | +/* ------------------------------------------------ List */ | ||
| 79 | + | ||
| 80 | +const filtered = computed(() => { | ||
| 81 | + const q = search.value.trim().toLowerCase() | ||
| 82 | + return q ? skills.value.filter(s => `${s.name} ${s.description ?? ''} ${s.plugin ?? ''}`.toLowerCase().includes(q)) : skills.value | ||
| 83 | +}) | ||
| 84 | +const shown = computed(() => (showAll.value ? filtered.value : filtered.value.slice(0, LIMIT))) | ||
| 85 | +const groups = computed(() => { | ||
| 86 | + if (selected.value?.kind !== 'claude-marketplace') return [{ plugin: null as string | null, rows: shown.value }] | ||
| 87 | + const m = new Map<string, MarketplaceSkill[]>() | ||
| 88 | + for (const s of shown.value) { | ||
| 89 | + const k = s.plugin ?? '(no plugin)' | ||
| 90 | + m.set(k, [...(m.get(k) ?? []), s]) | ||
| 91 | + } | ||
| 92 | + return [...m.entries()].map(([plugin, rows]) => ({ plugin, rows })) | ||
| 93 | +}) | ||
| 94 | + | ||
| 95 | +function toggle(path: string, on: boolean | 'indeterminate') { | ||
| 96 | + const s = new Set(picked.value) | ||
| 97 | + if (on === true) s.add(path) | ||
| 98 | + else s.delete(path) | ||
| 99 | + picked.value = s | ||
| 100 | +} | ||
| 101 | +function toggleGroup(rows: MarketplaceSkill[], on: boolean | 'indeterminate') { | ||
| 102 | + const s = new Set(picked.value) | ||
| 103 | + for (const r of rows) (on === true ? s.add(r.path) : s.delete(r.path)) | ||
| 104 | + picked.value = s | ||
| 105 | +} | ||
| 106 | +const groupState = (rows: MarketplaceSkill[]) => { | ||
| 107 | + const n = rows.filter(r => picked.value.has(r.path)).length | ||
| 108 | + return n === 0 ? false : n === rows.length ? true : 'indeterminate' as const | ||
| 109 | +} | ||
| 110 | + | ||
| 111 | +/* ------------------------------------------------ Import */ | ||
| 112 | + | ||
| 113 | +const differing = computed(() => skills.value.filter(s => picked.value.has(s.path) && s.inCatalog === 'different')) | ||
| 114 | +const confirmOpen = ref(false) | ||
| 115 | +const importing = ref(false) | ||
| 116 | + | ||
| 117 | +function startImport() { | ||
| 118 | + if (differing.value.length) confirmOpen.value = true | ||
| 119 | + else doImport(false) | ||
| 120 | +} | ||
| 121 | + | ||
| 122 | +async function doImport(overwrite: boolean) { | ||
| 123 | + if (!selected.value) return | ||
| 124 | + importing.value = true | ||
| 125 | + try { | ||
| 126 | + const res = await request<ImportResult>(`/api/marketplaces/${encodeURIComponent(selected.value.id)}/import`, { | ||
| 127 | + method: 'POST', | ||
| 128 | + body: { paths: [...picked.value], overwrite }, | ||
| 129 | + silent: true | ||
| 130 | + }) | ||
| 131 | + confirmOpen.value = false | ||
| 132 | + toast.add({ | ||
| 133 | + title: `${res.imported.length} imported`, | ||
| 134 | + description: res.skipped.length ? `${res.skipped.length} skipped: ${res.skipped.map(s => `${s.id} (${s.reason})`).join('; ')}` : 'Added to the catalog.', | ||
| 135 | + icon: 'i-lucide-check', | ||
| 136 | + color: res.skipped.length ? 'warning' : 'success' | ||
| 137 | + }) | ||
| 138 | + picked.value = new Set() | ||
| 139 | + await Promise.all([loadSkills(), catalog.refresh()]) | ||
| 140 | + } catch (e) { | ||
| 141 | + toast.add({ title: 'Import failed', description: (e as Error).message, color: 'error', icon: 'i-lucide-circle-alert' }) | ||
| 142 | + } finally { | ||
| 143 | + importing.value = false | ||
| 144 | + } | ||
| 145 | +} | ||
| 146 | + | ||
| 147 | +/* ------------------------------------------------ Add / remove source */ | ||
| 148 | + | ||
| 149 | +const addOpen = ref(false) | ||
| 150 | +const adding = ref(false) | ||
| 151 | +const addError = ref<string | null>(null) | ||
| 152 | +const addForm = reactive({ name: '', kind: 'git' as MarketplaceSource['kind'], url: '', ref: '' }) | ||
| 153 | +const KINDS = [ | ||
| 154 | + { label: 'Git repository (skill folders)', value: 'git' }, | ||
| 155 | + { label: 'Claude plugin marketplace', value: 'claude-marketplace' } | ||
| 156 | +] | ||
| 157 | + | ||
| 158 | +async function addSource() { | ||
| 159 | + adding.value = true | ||
| 160 | + addError.value = null | ||
| 161 | + try { | ||
| 162 | + const created = await request<MarketplaceSource>('/api/marketplaces', { | ||
| 163 | + method: 'POST', | ||
| 164 | + body: { name: addForm.name.trim(), kind: addForm.kind, url: addForm.url.trim(), ...(addForm.ref.trim() ? { ref: addForm.ref.trim() } : {}) }, | ||
| 165 | + silent: true | ||
| 166 | + }) | ||
| 167 | + addOpen.value = false | ||
| 168 | + Object.assign(addForm, { name: '', kind: 'git', url: '', ref: '' }) | ||
| 169 | + await loadSources() | ||
| 170 | + await select(created.id) | ||
| 171 | + } catch (e) { | ||
| 172 | + addError.value = (e as Error).message | ||
| 173 | + } finally { | ||
| 174 | + adding.value = false | ||
| 175 | + } | ||
| 176 | +} | ||
| 177 | + | ||
| 178 | +const removeTarget = ref<MarketplaceSource | null>(null) | ||
| 179 | +async function removeSource() { | ||
| 180 | + const s = removeTarget.value | ||
| 181 | + if (!s) return | ||
| 182 | + try { | ||
| 183 | + await request(`/api/marketplaces/${encodeURIComponent(s.id)}`, { method: 'DELETE' }) | ||
| 184 | + removeTarget.value = null | ||
| 185 | + if (selectedId.value === s.id) { selectedId.value = null; skills.value = [] } | ||
| 186 | + await loadSources() | ||
| 187 | + } catch { /* toast shown */ } | ||
| 188 | +} | ||
| 189 | + | ||
| 190 | +/* ------------------------------------------------ Preview */ | ||
| 191 | + | ||
| 192 | +const previewOpen = ref(false) | ||
| 193 | +const preview = ref<MarketplaceSkillDetail | null>(null) | ||
| 194 | +const previewLoading = ref(false) | ||
| 195 | +const previewError = ref<string | null>(null) | ||
| 196 | +const previewHtml = computed(() => (preview.value ? renderMarkdown(splitFrontmatter(preview.value.content).body) : '')) | ||
| 197 | +const previewFm = computed(() => (preview.value ? splitFrontmatter(preview.value.content).frontmatter : null)) | ||
| 198 | + | ||
| 199 | +async function openPreview(s: MarketplaceSkill) { | ||
| 200 | + previewOpen.value = true | ||
| 201 | + preview.value = null | ||
| 202 | + previewError.value = null | ||
| 203 | + previewLoading.value = true | ||
| 204 | + try { | ||
| 205 | + preview.value = await request<MarketplaceSkillDetail>(`/api/marketplaces/${encodeURIComponent(s.sourceId)}/skill`, { query: { path: s.path }, silent: true }) | ||
| 206 | + } catch (e) { | ||
| 207 | + previewError.value = (e as Error).message | ||
| 208 | + } finally { | ||
| 209 | + previewLoading.value = false | ||
| 210 | + } | ||
| 211 | +} | ||
| 212 | + | ||
| 213 | +const sinceFetched = (s: MarketplaceSource) => (s.lastFetchedAt ? `fetched ${formatDate(s.lastFetchedAt)}` : 'never fetched') | ||
| 214 | +</script> | ||
| 215 | + | ||
| 216 | +<template> | ||
| 217 | + <UDashboardPanel id="marketplaces"> | ||
| 218 | + <template #header> | ||
| 219 | + <UDashboardNavbar title="Marketplaces" icon="i-lucide-store"> | ||
| 220 | + <template #right> | ||
| 221 | + <UButton label="Add source" icon="i-lucide-plus" color="neutral" variant="outline" @click="addOpen = true" /> | ||
| 222 | + </template> | ||
| 223 | + </UDashboardNavbar> | ||
| 224 | + </template> | ||
| 225 | + | ||
| 226 | + <template #body> | ||
| 227 | + <USkeleton v-if="sourcesLoading && !sources.length" class="h-64 w-full" /> | ||
| 228 | + <UAlert v-else-if="sourcesError" color="error" variant="subtle" icon="i-lucide-circle-alert" title="Could not load marketplaces" :description="sourcesError" :actions="[{ label: 'Retry', color: 'neutral', variant: 'outline', onClick: loadSources }]" /> | ||
| 229 | + | ||
| 230 | + <div v-else class="grid items-start gap-6 lg:grid-cols-[16rem_minmax(0,1fr)]"> | ||
| 231 | + <!-- Sources --> | ||
| 232 | + <aside class="space-y-1"> | ||
| 233 | + <div | ||
| 234 | + v-for="s in sources" | ||
| 235 | + :key="s.id" | ||
| 236 | + class="group flex cursor-pointer items-start gap-2 rounded-lg border px-3 py-2 transition-colors" | ||
| 237 | + :class="s.id === selectedId ? 'border-primary bg-primary/5' : 'border-default hover:bg-elevated/50'" | ||
| 238 | + tabindex="0" | ||
| 239 | + @click="select(s.id)" | ||
| 240 | + @keydown.enter="select(s.id)" | ||
| 241 | + > | ||
| 242 | + <UIcon :name="s.kind === 'git' ? 'i-lucide-git-fork' : 'i-lucide-puzzle'" class="mt-0.5 size-4 shrink-0 text-muted" /> | ||
| 243 | + <div class="min-w-0 flex-1"> | ||
| 244 | + <div class="flex items-center gap-1.5"> | ||
| 245 | + <span class="truncate text-sm font-medium">{{ s.name }}</span> | ||
| 246 | + <UBadge v-if="s.builtIn" label="built-in" size="sm" color="neutral" variant="subtle" /> | ||
| 247 | + </div> | ||
| 248 | + <p class="truncate text-xs text-muted" :title="s.url">{{ s.url }}</p> | ||
| 249 | + </div> | ||
| 250 | + <UButton v-if="!s.builtIn" icon="i-lucide-trash-2" size="xs" color="neutral" variant="ghost" aria-label="Remove source" @click.stop="removeTarget = s" /> | ||
| 251 | + </div> | ||
| 252 | + </aside> | ||
| 253 | + | ||
| 254 | + <!-- Selected source --> | ||
| 255 | + <section v-if="selected" class="min-w-0 space-y-4"> | ||
| 256 | + <div class="flex flex-wrap items-center gap-3"> | ||
| 257 | + <div class="min-w-0"> | ||
| 258 | + <h2 class="truncate font-semibold">{{ selected.name }}</h2> | ||
| 259 | + <p class="text-xs text-muted">{{ selected.kind === 'git' ? 'Git repository' : 'Claude plugin marketplace' }}<span v-if="selected.ref"> - {{ selected.ref }}</span> - {{ sinceFetched(selected) }}</p> | ||
| 260 | + </div> | ||
| 261 | + <UButton class="ml-auto" label="Refresh" icon="i-lucide-refresh-cw" color="neutral" variant="outline" :loading="refreshing" @click="refreshSource" /> | ||
| 262 | + </div> | ||
| 263 | + <UAlert v-if="refreshing" color="info" variant="subtle" icon="i-lucide-loader" title="Fetching the repository" description="Cloning or updating can take tens of seconds for large repositories." /> | ||
| 264 | + | ||
| 265 | + <div class="flex flex-wrap items-center gap-3"> | ||
| 266 | + <UInput v-model="search" icon="i-lucide-search" placeholder="Search skills..." class="w-72" /> | ||
| 267 | + <span class="text-xs text-muted">{{ filtered.length }} skill{{ filtered.length === 1 ? '' : 's' }}</span> | ||
| 268 | + <UButton class="ml-auto" :label="`Import ${picked.size || ''}`.trim()" icon="i-lucide-download" :disabled="!picked.size" :loading="importing" @click="startImport" /> | ||
| 269 | + </div> | ||
| 270 | + | ||
| 271 | + <div v-if="skillsLoading" class="space-y-2"> | ||
| 272 | + <UAlert color="info" variant="subtle" icon="i-lucide-loader" title="Loading skills" description="The first load clones the repository; this can take a while." /> | ||
| 273 | + <USkeleton v-for="i in 4" :key="i" class="h-12 w-full" /> | ||
| 274 | + </div> | ||
| 275 | + <UAlert v-else-if="skillsError" color="error" variant="subtle" icon="i-lucide-circle-alert" title="Could not load the skills" :description="skillsError" :actions="[{ label: 'Refresh source', color: 'neutral', variant: 'outline', onClick: refreshSource }]" /> | ||
| 276 | + <div v-else-if="!filtered.length" class="flex flex-col items-center gap-2 rounded-xl border border-dashed border-default py-12 text-center"> | ||
| 277 | + <UIcon :name="search ? 'i-lucide-search-x' : 'i-lucide-store'" class="size-8 text-dimmed" /> | ||
| 278 | + <p class="text-sm text-muted">{{ search ? 'No skill matches the search.' : 'No skill found. Try Refresh.' }}</p> | ||
| 279 | + </div> | ||
| 280 | + | ||
| 281 | + <div v-else class="space-y-3"> | ||
| 282 | + <div v-for="g in groups" :key="g.plugin ?? 'all'" class="overflow-hidden rounded-xl border border-default"> | ||
| 283 | + <div v-if="g.plugin" class="flex items-center gap-2 border-b border-default bg-elevated/40 px-3 py-1.5 text-xs font-medium text-muted"> | ||
| 284 | + <UCheckbox :model-value="groupState(g.rows)" :aria-label="`Select plugin ${g.plugin}`" @update:model-value="(v) => toggleGroup(g.rows, v)" /> | ||
| 285 | + <UIcon name="i-lucide-puzzle" class="size-3.5" />{{ g.plugin }} ({{ g.rows.length }}) | ||
| 286 | + </div> | ||
| 287 | + <ul class="divide-y divide-default"> | ||
| 288 | + <li v-for="s in g.rows" :key="s.path" class="flex items-start gap-3 px-3 py-2 hover:bg-elevated/40"> | ||
| 289 | + <UCheckbox class="mt-0.5" :model-value="picked.has(s.path)" :aria-label="`Select ${s.name}`" @update:model-value="(v) => toggle(s.path, v)" /> | ||
| 290 | + <button type="button" class="min-w-0 flex-1 text-left" @click="openPreview(s)"> | ||
| 291 | + <div class="flex items-center gap-2"> | ||
| 292 | + <span class="truncate text-sm font-medium text-highlighted">{{ s.name }}</span> | ||
| 293 | + <UBadge v-if="s.inCatalog === 'same'" label="In catalog" icon="i-lucide-check" size="sm" color="success" variant="subtle" /> | ||
| 294 | + <UBadge v-else-if="s.inCatalog === 'different'" label="In catalog (different)" icon="i-lucide-git-compare" size="sm" color="warning" variant="subtle" /> | ||
| 295 | + </div> | ||
| 296 | + <p v-if="s.description" class="line-clamp-2 text-xs text-muted">{{ s.description }}</p> | ||
| 297 | + <p class="truncate font-mono text-[11px] text-dimmed">{{ s.path }}</p> | ||
| 298 | + </button> | ||
| 299 | + <UButton icon="i-lucide-eye" size="xs" color="neutral" variant="ghost" aria-label="Preview" @click="openPreview(s)" /> | ||
| 300 | + </li> | ||
| 301 | + </ul> | ||
| 302 | + </div> | ||
| 303 | + <div v-if="filtered.length > shown.length" class="text-center"> | ||
| 304 | + <UButton :label="`Show all ${filtered.length}`" size="xs" color="neutral" variant="link" @click="showAll = true" /> | ||
| 305 | + </div> | ||
| 306 | + </div> | ||
| 307 | + </section> | ||
| 308 | + </div> | ||
| 309 | + | ||
| 310 | + <!-- Preview --> | ||
| 311 | + <USlideover v-model:open="previewOpen" :title="preview?.name ?? 'Preview'" :description="preview?.description" :ui="{ content: 'sm:max-w-2xl', description: 'line-clamp-3' }"> | ||
| 312 | + <template #body> | ||
| 313 | + <div v-if="previewLoading" class="space-y-2"><USkeleton class="h-4 w-1/3" /><USkeleton class="h-4 w-full" /><USkeleton class="h-4 w-5/6" /></div> | ||
| 314 | + <UAlert v-else-if="previewError" color="error" variant="subtle" icon="i-lucide-circle-alert" title="Could not load the preview" :description="previewError" /> | ||
| 315 | + <div v-else-if="preview" class="space-y-4"> | ||
| 316 | + <div v-if="previewFm" class="overflow-hidden rounded-lg border border-default"> | ||
| 317 | + <table class="w-full text-xs"> | ||
| 318 | + <tbody class="divide-y divide-default"> | ||
| 319 | + <tr v-for="(v, k) in previewFm" :key="k"> | ||
| 320 | + <th class="w-1/4 bg-elevated/40 px-2 py-1.5 text-left align-top font-medium text-muted">{{ k }}</th> | ||
| 321 | + <td class="px-2 py-1.5 break-words">{{ v }}</td> | ||
| 322 | + </tr> | ||
| 323 | + </tbody> | ||
| 324 | + </table> | ||
| 325 | + </div> | ||
| 326 | + <!-- eslint-disable-next-line vue/no-v-html --> | ||
| 327 | + <div class="md-body" v-html="previewHtml" /> | ||
| 328 | + <div v-if="preview.files.length" class="space-y-1"> | ||
| 329 | + <h3 class="text-sm font-semibold">Files ({{ preview.files.length }})</h3> | ||
| 330 | + <ul class="max-h-48 overflow-auto rounded-lg border border-default text-xs"> | ||
| 331 | + <li v-for="f in preview.files" :key="f" class="flex items-center gap-2 px-3 py-1.5 font-mono"><UIcon name="i-lucide-file" class="size-3.5 text-muted" />{{ f }}</li> | ||
| 332 | + </ul> | ||
| 333 | + </div> | ||
| 334 | + </div> | ||
| 335 | + </template> | ||
| 336 | + </USlideover> | ||
| 337 | + | ||
| 338 | + <!-- Overwrite confirmation --> | ||
| 339 | + <UModal v-model:open="confirmOpen" title="Overwrite catalog items?" description="Some selected skills already exist in the catalog with different content."> | ||
| 340 | + <template #body> | ||
| 341 | + <ul class="list-disc space-y-0.5 pl-4 text-sm"> | ||
| 342 | + <li v-for="s in differing" :key="s.path">{{ s.name }}</li> | ||
| 343 | + </ul> | ||
| 344 | + <p class="mt-3 text-sm text-muted">Overwriting replaces the catalog version with the marketplace version. Local edits in the catalog are lost (git history keeps them).</p> | ||
| 345 | + </template> | ||
| 346 | + <template #footer> | ||
| 347 | + <div class="flex w-full justify-end gap-2"> | ||
| 348 | + <UButton label="Cancel" color="neutral" variant="outline" @click="confirmOpen = false" /> | ||
| 349 | + <UButton label="Overwrite and import" color="warning" :loading="importing" @click="doImport(true)" /> | ||
| 350 | + </div> | ||
| 351 | + </template> | ||
| 352 | + </UModal> | ||
| 353 | + | ||
| 354 | + <!-- Add source --> | ||
| 355 | + <UModal v-model:open="addOpen" title="Add a source" description="A git repository containing skills, or a Claude Code plugin marketplace."> | ||
| 356 | + <template #body> | ||
| 357 | + <form id="add-source" class="space-y-3" @submit.prevent="addSource"> | ||
| 358 | + <UAlert v-if="addError" color="error" variant="subtle" icon="i-lucide-circle-alert" :title="addError" /> | ||
| 359 | + <UFormField label="Name" required><UInput v-model="addForm.name" class="w-full" placeholder="My skills" /></UFormField> | ||
| 360 | + <UFormField label="Kind"><USelect v-model="addForm.kind" :items="KINDS" class="w-full" /></UFormField> | ||
| 361 | + <UFormField label="URL" required><UInput v-model="addForm.url" class="w-full" placeholder="https://github.com/owner/repo.git" :ui="{ base: 'font-mono' }" /></UFormField> | ||
| 362 | + <UFormField label="Ref" description="Branch or tag; default branch when empty."><UInput v-model="addForm.ref" class="w-full" placeholder="main" :ui="{ base: 'font-mono' }" /></UFormField> | ||
| 363 | + </form> | ||
| 364 | + </template> | ||
| 365 | + <template #footer> | ||
| 366 | + <div class="flex w-full justify-end gap-2"> | ||
| 367 | + <UButton label="Cancel" color="neutral" variant="outline" @click="addOpen = false" /> | ||
| 368 | + <UButton type="submit" form="add-source" label="Add source" :loading="adding" :disabled="!addForm.name.trim() || !addForm.url.trim()" /> | ||
| 369 | + </div> | ||
| 370 | + </template> | ||
| 371 | + </UModal> | ||
| 372 | + | ||
| 373 | + <UModal :open="!!removeTarget" title="Remove source" description="Imported catalog items are kept." @update:open="(v: boolean) => { if (!v) removeTarget = null }"> | ||
| 374 | + <template #body> | ||
| 375 | + <p class="text-sm">Remove <strong>{{ removeTarget?.name }}</strong> from the list of sources?</p> | ||
| 376 | + </template> | ||
| 377 | + <template #footer> | ||
| 378 | + <div class="flex w-full justify-end gap-2"> | ||
| 379 | + <UButton label="Cancel" color="neutral" variant="outline" @click="removeTarget = null" /> | ||
| 380 | + <UButton label="Remove" color="error" @click="removeSource" /> | ||
| 381 | + </div> | ||
| 382 | + </template> | ||
| 383 | + </UModal> | ||
| 384 | + </template> | ||
| 385 | + </UDashboardPanel> | ||
| 386 | +</template> | ||
added
packages/ui/app/pages/presets/[name].vue +8 -0 | new file mode 100644 | ||
| @@ -0,0 +1,8 @@ | ||
| 1 | +<script setup lang="ts"> | |
| 2 | +const route = useRoute() | |
| 3 | +const name = computed(() => String(route.params.name)) | |
| 4 | +</script> | |
| 5 | + | |
| 6 | +<template> | |
| 7 | + <PresetEditor :key="name" :name="name" /> | |
| 8 | +</template> | |
| new file mode 100644 | |||
| @@ -0,0 +1,8 @@ | |||
| 1 | +<script setup lang="ts"> | ||
| 2 | +const route = useRoute() | ||
| 3 | +const name = computed(() => String(route.params.name)) | ||
| 4 | +</script> | ||
| 5 | + | ||
| 6 | +<template> | ||
| 7 | + <PresetEditor :key="name" :name="name" /> | ||
| 8 | +</template> | ||
added
packages/ui/app/pages/presets/index.vue +80 -0 | new file mode 100644 | ||
| @@ -0,0 +1,80 @@ | ||
| 1 | +<script setup lang="ts"> | |
| 2 | +import type { CatalogItemType } from '@skilly/core' | |
| 3 | + | |
| 4 | +const { data, loading, error, refresh, exists, presets, presetUsedBy } = useCatalog() | |
| 5 | +const names = useScopeNames() | |
| 6 | + | |
| 7 | +onMounted(refresh) | |
| 8 | + | |
| 9 | +const sorted = computed(() => [...presets.value].sort((a, b) => a.name.localeCompare(b.name))) | |
| 10 | +const count = (p: (typeof presets.value)[number], t: CatalogItemType) => p.items[t]?.length ?? 0 | |
| 11 | +const total = (p: (typeof presets.value)[number]) => CAT_TYPES.reduce((n, t) => n + count(p, t), 0) | |
| 12 | +const TH = 'px-3 py-2 text-left text-xs font-medium uppercase tracking-wide text-muted' | |
| 13 | +</script> | |
| 14 | + | |
| 15 | +<template> | |
| 16 | + <UDashboardPanel id="presets"> | |
| 17 | + <template #header> | |
| 18 | + <UDashboardNavbar title="Presets" icon="i-lucide-layers"> | |
| 19 | + <template #right> | |
| 20 | + <UButton v-if="exists" label="New preset" icon="i-lucide-plus" to="/presets/new" /> | |
| 21 | + </template> | |
| 22 | + </UDashboardNavbar> | |
| 23 | + </template> | |
| 24 | + <template #body> | |
| 25 | + <div v-if="loading && !data" class="space-y-3"> | |
| 26 | + <USkeleton v-for="i in 4" :key="i" class="h-12 w-full" /> | |
| 27 | + </div> | |
| 28 | + <UAlert v-else-if="error && !data" color="error" variant="subtle" icon="i-lucide-circle-alert" title="Could not load the catalog" :description="error" /> | |
| 29 | + <CatalogSetupCard v-else-if="data && !exists" /> | |
| 30 | + <div v-else class="space-y-4"> | |
| 31 | + <p class="text-sm text-muted">A preset is a named selection of catalog items. Enable it in a project to write all its items at once.</p> | |
| 32 | + <div v-if="!sorted.length" class="flex flex-col items-center gap-2 rounded-xl border border-dashed border-default py-12 text-center"> | |
| 33 | + <UIcon name="i-lucide-layers" class="size-8 text-dimmed" /> | |
| 34 | + <p class="text-sm text-muted">No preset yet.</p> | |
| 35 | + <UButton label="Create a preset" icon="i-lucide-plus" to="/presets/new" size="sm" /> | |
| 36 | + </div> | |
| 37 | + <div v-else class="overflow-x-auto rounded-xl border border-default"> | |
| 38 | + <table class="w-full min-w-[720px] table-fixed text-sm"> | |
| 39 | + <thead class="border-b border-default bg-elevated/40"> | |
| 40 | + <tr> | |
| 41 | + <th :class="TH" class="w-[30%]">Name</th> | |
| 42 | + <th :class="TH">Items</th> | |
| 43 | + <th :class="TH" class="w-40">Includes</th> | |
| 44 | + <th :class="TH" class="w-28">Used by</th> | |
| 45 | + </tr> | |
| 46 | + </thead> | |
| 47 | + <tbody class="divide-y divide-default"> | |
| 48 | + <tr v-for="p in sorted" :key="p.name" class="cursor-pointer transition-colors hover:bg-elevated/50" tabindex="0" @click="navigateTo(`/presets/${encodeURIComponent(p.name)}`)" @keydown.enter="navigateTo(`/presets/${encodeURIComponent(p.name)}`)"> | |
| 49 | + <td class="px-3 py-2 align-middle"> | |
| 50 | + <span class="block truncate font-medium text-highlighted">{{ p.name }}</span> | |
| 51 | + <p v-if="p.description" class="line-clamp-1 text-xs text-muted" :title="p.description">{{ p.description }}</p> | |
| 52 | + </td> | |
| 53 | + <td class="px-3 py-2 align-middle"> | |
| 54 | + <div class="flex flex-wrap gap-x-3 gap-y-1 text-xs"> | |
| 55 | + <span v-for="t in CAT_TYPES.filter(t => count(p, t))" :key="t" class="flex items-center gap-1"> | |
| 56 | + <UIcon :name="CAT_META[t].icon" class="size-3.5 text-muted" /><span class="tabular-nums font-medium">{{ count(p, t) }}</span><span class="text-muted">{{ CAT_META[t].label }}</span> | |
| 57 | + </span> | |
| 58 | + <span v-if="!total(p)" class="text-dimmed">empty</span> | |
| 59 | + </div> | |
| 60 | + </td> | |
| 61 | + <td class="px-3 py-2 align-middle"> | |
| 62 | + <div class="flex flex-wrap gap-1"> | |
| 63 | + <UBadge v-for="i in p.includes" :key="i" :label="i" size="sm" color="neutral" variant="outline" /> | |
| 64 | + <span v-if="!p.includes.length" class="text-xs text-dimmed">-</span> | |
| 65 | + </div> | |
| 66 | + </td> | |
| 67 | + <td class="px-3 py-2 align-middle"> | |
| 68 | + <UTooltip v-if="presetUsedBy(p.name).length" :text="presetUsedBy(p.name).map(s => scopeLabel(s, names)).join(', ')"> | |
| 69 | + <span class="text-xs tabular-nums">{{ presetUsedBy(p.name).length }} scope{{ presetUsedBy(p.name).length > 1 ? 's' : '' }}</span> | |
| 70 | + </UTooltip> | |
| 71 | + <span v-else class="text-xs text-dimmed">unused</span> | |
| 72 | + </td> | |
| 73 | + </tr> | |
| 74 | + </tbody> | |
| 75 | + </table> | |
| 76 | + </div> | |
| 77 | + </div> | |
| 78 | + </template> | |
| 79 | + </UDashboardPanel> | |
| 80 | +</template> | |
| new file mode 100644 | |||
| @@ -0,0 +1,80 @@ | |||
| 1 | +<script setup lang="ts"> | ||
| 2 | +import type { CatalogItemType } from '@skilly/core' | ||
| 3 | + | ||
| 4 | +const { data, loading, error, refresh, exists, presets, presetUsedBy } = useCatalog() | ||
| 5 | +const names = useScopeNames() | ||
| 6 | + | ||
| 7 | +onMounted(refresh) | ||
| 8 | + | ||
| 9 | +const sorted = computed(() => [...presets.value].sort((a, b) => a.name.localeCompare(b.name))) | ||
| 10 | +const count = (p: (typeof presets.value)[number], t: CatalogItemType) => p.items[t]?.length ?? 0 | ||
| 11 | +const total = (p: (typeof presets.value)[number]) => CAT_TYPES.reduce((n, t) => n + count(p, t), 0) | ||
| 12 | +const TH = 'px-3 py-2 text-left text-xs font-medium uppercase tracking-wide text-muted' | ||
| 13 | +</script> | ||
| 14 | + | ||
| 15 | +<template> | ||
| 16 | + <UDashboardPanel id="presets"> | ||
| 17 | + <template #header> | ||
| 18 | + <UDashboardNavbar title="Presets" icon="i-lucide-layers"> | ||
| 19 | + <template #right> | ||
| 20 | + <UButton v-if="exists" label="New preset" icon="i-lucide-plus" to="/presets/new" /> | ||
| 21 | + </template> | ||
| 22 | + </UDashboardNavbar> | ||
| 23 | + </template> | ||
| 24 | + <template #body> | ||
| 25 | + <div v-if="loading && !data" class="space-y-3"> | ||
| 26 | + <USkeleton v-for="i in 4" :key="i" class="h-12 w-full" /> | ||
| 27 | + </div> | ||
| 28 | + <UAlert v-else-if="error && !data" color="error" variant="subtle" icon="i-lucide-circle-alert" title="Could not load the catalog" :description="error" /> | ||
| 29 | + <CatalogSetupCard v-else-if="data && !exists" /> | ||
| 30 | + <div v-else class="space-y-4"> | ||
| 31 | + <p class="text-sm text-muted">A preset is a named selection of catalog items. Enable it in a project to write all its items at once.</p> | ||
| 32 | + <div v-if="!sorted.length" class="flex flex-col items-center gap-2 rounded-xl border border-dashed border-default py-12 text-center"> | ||
| 33 | + <UIcon name="i-lucide-layers" class="size-8 text-dimmed" /> | ||
| 34 | + <p class="text-sm text-muted">No preset yet.</p> | ||
| 35 | + <UButton label="Create a preset" icon="i-lucide-plus" to="/presets/new" size="sm" /> | ||
| 36 | + </div> | ||
| 37 | + <div v-else class="overflow-x-auto rounded-xl border border-default"> | ||
| 38 | + <table class="w-full min-w-[720px] table-fixed text-sm"> | ||
| 39 | + <thead class="border-b border-default bg-elevated/40"> | ||
| 40 | + <tr> | ||
| 41 | + <th :class="TH" class="w-[30%]">Name</th> | ||
| 42 | + <th :class="TH">Items</th> | ||
| 43 | + <th :class="TH" class="w-40">Includes</th> | ||
| 44 | + <th :class="TH" class="w-28">Used by</th> | ||
| 45 | + </tr> | ||
| 46 | + </thead> | ||
| 47 | + <tbody class="divide-y divide-default"> | ||
| 48 | + <tr v-for="p in sorted" :key="p.name" class="cursor-pointer transition-colors hover:bg-elevated/50" tabindex="0" @click="navigateTo(`/presets/${encodeURIComponent(p.name)}`)" @keydown.enter="navigateTo(`/presets/${encodeURIComponent(p.name)}`)"> | ||
| 49 | + <td class="px-3 py-2 align-middle"> | ||
| 50 | + <span class="block truncate font-medium text-highlighted">{{ p.name }}</span> | ||
| 51 | + <p v-if="p.description" class="line-clamp-1 text-xs text-muted" :title="p.description">{{ p.description }}</p> | ||
| 52 | + </td> | ||
| 53 | + <td class="px-3 py-2 align-middle"> | ||
| 54 | + <div class="flex flex-wrap gap-x-3 gap-y-1 text-xs"> | ||
| 55 | + <span v-for="t in CAT_TYPES.filter(t => count(p, t))" :key="t" class="flex items-center gap-1"> | ||
| 56 | + <UIcon :name="CAT_META[t].icon" class="size-3.5 text-muted" /><span class="tabular-nums font-medium">{{ count(p, t) }}</span><span class="text-muted">{{ CAT_META[t].label }}</span> | ||
| 57 | + </span> | ||
| 58 | + <span v-if="!total(p)" class="text-dimmed">empty</span> | ||
| 59 | + </div> | ||
| 60 | + </td> | ||
| 61 | + <td class="px-3 py-2 align-middle"> | ||
| 62 | + <div class="flex flex-wrap gap-1"> | ||
| 63 | + <UBadge v-for="i in p.includes" :key="i" :label="i" size="sm" color="neutral" variant="outline" /> | ||
| 64 | + <span v-if="!p.includes.length" class="text-xs text-dimmed">-</span> | ||
| 65 | + </div> | ||
| 66 | + </td> | ||
| 67 | + <td class="px-3 py-2 align-middle"> | ||
| 68 | + <UTooltip v-if="presetUsedBy(p.name).length" :text="presetUsedBy(p.name).map(s => scopeLabel(s, names)).join(', ')"> | ||
| 69 | + <span class="text-xs tabular-nums">{{ presetUsedBy(p.name).length }} scope{{ presetUsedBy(p.name).length > 1 ? 's' : '' }}</span> | ||
| 70 | + </UTooltip> | ||
| 71 | + <span v-else class="text-xs text-dimmed">unused</span> | ||
| 72 | + </td> | ||
| 73 | + </tr> | ||
| 74 | + </tbody> | ||
| 75 | + </table> | ||
| 76 | + </div> | ||
| 77 | + </div> | ||
| 78 | + </template> | ||
| 79 | + </UDashboardPanel> | ||
| 80 | +</template> | ||
added
packages/ui/app/pages/presets/new.vue +3 -0 | new file mode 100644 | ||
| @@ -0,0 +1,3 @@ | ||
| 1 | +<template> | |
| 2 | + <PresetEditor /> | |
| 3 | +</template> | |
| new file mode 100644 | |||
| @@ -0,0 +1,3 @@ | |||
| 1 | +<template> | ||
| 2 | + <PresetEditor /> | ||
| 3 | +</template> | ||
added
packages/ui/app/pages/settings.vue +84 -0 | new file mode 100644 | ||
| @@ -0,0 +1,84 @@ | ||
| 1 | +<script setup lang="ts"> | |
| 2 | +import type { Settings } from '@skilly/core' | |
| 3 | + | |
| 4 | +const { request } = useApi() | |
| 5 | +const { refresh: refreshCatalog, data: catalog } = useCatalog() | |
| 6 | +const toast = useToast() | |
| 7 | + | |
| 8 | +const loaded = ref<Settings | null>(null) | |
| 9 | +const form = reactive<Settings>({ catalogPath: '', autoSync: false }) | |
| 10 | +const error = ref<string | null>(null) | |
| 11 | +const saving = ref(false) | |
| 12 | +const pickerOpen = ref(false) | |
| 13 | + | |
| 14 | +const dirty = computed(() => !!loaded.value && (form.catalogPath !== loaded.value.catalogPath || form.autoSync !== loaded.value.autoSync)) | |
| 15 | + | |
| 16 | +function apply(s: Settings) { | |
| 17 | + loaded.value = s | |
| 18 | + form.catalogPath = s.catalogPath | |
| 19 | + form.autoSync = s.autoSync | |
| 20 | +} | |
| 21 | + | |
| 22 | +onMounted(async () => { | |
| 23 | + try { | |
| 24 | + apply(await request<Settings>('/api/settings', { silent: true })) | |
| 25 | + } catch (e) { | |
| 26 | + error.value = (e as Error).message | |
| 27 | + } | |
| 28 | +}) | |
| 29 | + | |
| 30 | +async function save() { | |
| 31 | + saving.value = true | |
| 32 | + try { | |
| 33 | + apply(await request<Settings>('/api/settings', { method: 'PUT', body: { catalogPath: form.catalogPath.trim(), autoSync: form.autoSync } })) | |
| 34 | + await refreshCatalog() | |
| 35 | + toast.add({ title: 'Settings saved', icon: 'i-lucide-check', color: 'success' }) | |
| 36 | + } catch { | |
| 37 | + // toast shown by useApi | |
| 38 | + } finally { | |
| 39 | + saving.value = false | |
| 40 | + } | |
| 41 | +} | |
| 42 | +</script> | |
| 43 | + | |
| 44 | +<template> | |
| 45 | + <UDashboardPanel id="settings"> | |
| 46 | + <template #header> | |
| 47 | + <UDashboardNavbar title="Settings" icon="i-lucide-settings" /> | |
| 48 | + </template> | |
| 49 | + <template #body> | |
| 50 | + <div class="mx-auto w-full max-w-2xl space-y-6"> | |
| 51 | + <UAlert v-if="error" color="error" variant="subtle" icon="i-lucide-circle-alert" title="Could not load settings" :description="error" /> | |
| 52 | + <USkeleton v-else-if="!loaded" class="h-40 w-full" /> | |
| 53 | + <template v-else> | |
| 54 | + <UCard> | |
| 55 | + <template #header> | |
| 56 | + <h2 class="font-semibold">Catalog</h2> | |
| 57 | + </template> | |
| 58 | + <div class="space-y-4"> | |
| 59 | + <UFormField label="Catalog folder" description="A folder (usually a git repository) holding your skills, rules, MCP servers, hooks, agents and presets. Point it to an existing clone to reuse it."> | |
| 60 | + <div class="flex gap-2"> | |
| 61 | + <UInput v-model="form.catalogPath" class="flex-1" :ui="{ base: 'font-mono' }" /> | |
| 62 | + <UButton label="Browse" icon="i-lucide-folder-search" color="neutral" variant="outline" @click="pickerOpen = true" /> | |
| 63 | + </div> | |
| 64 | + </UFormField> | |
| 65 | + <p v-if="catalog && form.catalogPath === loaded.catalogPath" class="text-xs" :class="catalog.snapshot.exists ? 'text-success' : 'text-warning'"> | |
| 66 | + {{ catalog.snapshot.exists ? 'Catalog found at this location.' : 'No catalog at this location yet. Create it from the Catalog page.' }} | |
| 67 | + </p> | |
| 68 | + <p v-else-if="dirty" class="text-xs text-muted">Saving re-opens the catalog at the new location.</p> | |
| 69 | + | |
| 70 | + <USwitch v-model="form.autoSync" label="Automatic sync" description="Commit, pull and push the catalog automatically after changes (needs a git remote)." /> | |
| 71 | + </div> | |
| 72 | + <template #footer> | |
| 73 | + <div class="flex justify-end gap-2"> | |
| 74 | + <UButton label="Reset" color="neutral" variant="ghost" :disabled="!dirty" @click="apply(loaded!)" /> | |
| 75 | + <UButton label="Save" icon="i-lucide-save" :loading="saving" :disabled="!dirty || !form.catalogPath.trim()" @click="save" /> | |
| 76 | + </div> | |
| 77 | + </template> | |
| 78 | + </UCard> | |
| 79 | + </template> | |
| 80 | + </div> | |
| 81 | + <FolderPickerModal v-model:open="pickerOpen" title="Choose the catalog folder" :start="form.catalogPath" @pick="(p) => form.catalogPath = p" /> | |
| 82 | + </template> | |
| 83 | + </UDashboardPanel> | |
| 84 | +</template> | |
| new file mode 100644 | |||
| @@ -0,0 +1,84 @@ | |||
| 1 | +<script setup lang="ts"> | ||
| 2 | +import type { Settings } from '@skilly/core' | ||
| 3 | + | ||
| 4 | +const { request } = useApi() | ||
| 5 | +const { refresh: refreshCatalog, data: catalog } = useCatalog() | ||
| 6 | +const toast = useToast() | ||
| 7 | + | ||
| 8 | +const loaded = ref<Settings | null>(null) | ||
| 9 | +const form = reactive<Settings>({ catalogPath: '', autoSync: false }) | ||
| 10 | +const error = ref<string | null>(null) | ||
| 11 | +const saving = ref(false) | ||
| 12 | +const pickerOpen = ref(false) | ||
| 13 | + | ||
| 14 | +const dirty = computed(() => !!loaded.value && (form.catalogPath !== loaded.value.catalogPath || form.autoSync !== loaded.value.autoSync)) | ||
| 15 | + | ||
| 16 | +function apply(s: Settings) { | ||
| 17 | + loaded.value = s | ||
| 18 | + form.catalogPath = s.catalogPath | ||
| 19 | + form.autoSync = s.autoSync | ||
| 20 | +} | ||
| 21 | + | ||
| 22 | +onMounted(async () => { | ||
| 23 | + try { | ||
| 24 | + apply(await request<Settings>('/api/settings', { silent: true })) | ||
| 25 | + } catch (e) { | ||
| 26 | + error.value = (e as Error).message | ||
| 27 | + } | ||
| 28 | +}) | ||
| 29 | + | ||
| 30 | +async function save() { | ||
| 31 | + saving.value = true | ||
| 32 | + try { | ||
| 33 | + apply(await request<Settings>('/api/settings', { method: 'PUT', body: { catalogPath: form.catalogPath.trim(), autoSync: form.autoSync } })) | ||
| 34 | + await refreshCatalog() | ||
| 35 | + toast.add({ title: 'Settings saved', icon: 'i-lucide-check', color: 'success' }) | ||
| 36 | + } catch { | ||
| 37 | + // toast shown by useApi | ||
| 38 | + } finally { | ||
| 39 | + saving.value = false | ||
| 40 | + } | ||
| 41 | +} | ||
| 42 | +</script> | ||
| 43 | + | ||
| 44 | +<template> | ||
| 45 | + <UDashboardPanel id="settings"> | ||
| 46 | + <template #header> | ||
| 47 | + <UDashboardNavbar title="Settings" icon="i-lucide-settings" /> | ||
| 48 | + </template> | ||
| 49 | + <template #body> | ||
| 50 | + <div class="mx-auto w-full max-w-2xl space-y-6"> | ||
| 51 | + <UAlert v-if="error" color="error" variant="subtle" icon="i-lucide-circle-alert" title="Could not load settings" :description="error" /> | ||
| 52 | + <USkeleton v-else-if="!loaded" class="h-40 w-full" /> | ||
| 53 | + <template v-else> | ||
| 54 | + <UCard> | ||
| 55 | + <template #header> | ||
| 56 | + <h2 class="font-semibold">Catalog</h2> | ||
| 57 | + </template> | ||
| 58 | + <div class="space-y-4"> | ||
| 59 | + <UFormField label="Catalog folder" description="A folder (usually a git repository) holding your skills, rules, MCP servers, hooks, agents and presets. Point it to an existing clone to reuse it."> | ||
| 60 | + <div class="flex gap-2"> | ||
| 61 | + <UInput v-model="form.catalogPath" class="flex-1" :ui="{ base: 'font-mono' }" /> | ||
| 62 | + <UButton label="Browse" icon="i-lucide-folder-search" color="neutral" variant="outline" @click="pickerOpen = true" /> | ||
| 63 | + </div> | ||
| 64 | + </UFormField> | ||
| 65 | + <p v-if="catalog && form.catalogPath === loaded.catalogPath" class="text-xs" :class="catalog.snapshot.exists ? 'text-success' : 'text-warning'"> | ||
| 66 | + {{ catalog.snapshot.exists ? 'Catalog found at this location.' : 'No catalog at this location yet. Create it from the Catalog page.' }} | ||
| 67 | + </p> | ||
| 68 | + <p v-else-if="dirty" class="text-xs text-muted">Saving re-opens the catalog at the new location.</p> | ||
| 69 | + | ||
| 70 | + <USwitch v-model="form.autoSync" label="Automatic sync" description="Commit, pull and push the catalog automatically after changes (needs a git remote)." /> | ||
| 71 | + </div> | ||
| 72 | + <template #footer> | ||
| 73 | + <div class="flex justify-end gap-2"> | ||
| 74 | + <UButton label="Reset" color="neutral" variant="ghost" :disabled="!dirty" @click="apply(loaded!)" /> | ||
| 75 | + <UButton label="Save" icon="i-lucide-save" :loading="saving" :disabled="!dirty || !form.catalogPath.trim()" @click="save" /> | ||
| 76 | + </div> | ||
| 77 | + </template> | ||
| 78 | + </UCard> | ||
| 79 | + </template> | ||
| 80 | + </div> | ||
| 81 | + <FolderPickerModal v-model:open="pickerOpen" title="Choose the catalog folder" :start="form.catalogPath" @pick="(p) => form.catalogPath = p" /> | ||
| 82 | + </template> | ||
| 83 | + </UDashboardPanel> | ||
| 84 | +</template> | ||
added
packages/ui/app/utils/catalog.ts +118 -0 | new file mode 100644 | ||
| @@ -0,0 +1,118 @@ | ||
| 1 | +import type { CatalogItemType, HookTemplate, ItemStatus, McpEnvValue, McpTemplate } from '@skilly/core' | |
| 2 | + | |
| 3 | +export const CAT_TYPES: CatalogItemType[] = ['skill', 'rule', 'agent', 'mcp', 'hook'] | |
| 4 | + | |
| 5 | +export const CAT_META: Record<CatalogItemType, { label: string, singular: string, icon: string, markdown: boolean }> = { | |
| 6 | + skill: { label: 'Skills', singular: 'Skill', icon: 'i-lucide-sparkles', markdown: true }, | |
| 7 | + rule: { label: 'Rules', singular: 'Rule', icon: 'i-lucide-scroll-text', markdown: true }, | |
| 8 | + agent: { label: 'Agents', singular: 'Agent', icon: 'i-lucide-bot', markdown: true }, | |
| 9 | + mcp: { label: 'MCP', singular: 'MCP server', icon: 'i-lucide-plug', markdown: false }, | |
| 10 | + hook: { label: 'Hooks', singular: 'Hook', icon: 'i-lucide-webhook', markdown: false } | |
| 11 | +} | |
| 12 | + | |
| 13 | +export const NAME_RE = /^[a-z0-9][a-z0-9._-]*$/ | |
| 14 | + | |
| 15 | +export const itemKey = (type: string, name: string) => `${type}/${name}` | |
| 16 | + | |
| 17 | +export const STATUS_META: Record<ItemStatus['state'], { label: string, color: 'success' | 'warning' | 'info' | 'neutral', icon: string, hint: string }> = { | |
| 18 | + 'in-sync': { label: 'in sync', color: 'success', icon: 'i-lucide-check', hint: 'Identical to the catalog version.' }, | |
| 19 | + 'modified': { label: 'modified', color: 'warning', icon: 'i-lucide-pencil', hint: 'Edited in this scope since the last sync.' }, | |
| 20 | + 'catalog-updated': { label: 'catalog updated', color: 'info', icon: 'i-lucide-arrow-down-to-line', hint: 'The catalog has a newer version.' }, | |
| 21 | + 'not-in-catalog': { label: 'not in catalog', color: 'neutral', icon: 'i-lucide-circle-dashed', hint: 'Local item, absent from the catalog.' }, | |
| 22 | + 'differs-from-catalog': { label: 'differs from catalog', color: 'warning', icon: 'i-lucide-git-compare', hint: 'Same name as a catalog item, different content, not managed by skilly.' } | |
| 23 | +} | |
| 24 | + | |
| 25 | +/** `*` wildcard match on names, same semantics as the config globs. */ | |
| 26 | +export function globMatch(pattern: string, name: string): boolean { | |
| 27 | + if (!pattern.includes('*')) return pattern === name | |
| 28 | + const escape = (s: string) => s.replace(/[.+?^${}()|[\]\\]/g, c => '\\' + c) | |
| 29 | + const re = new RegExp('^' + pattern.split('*').map(escape).join('.*') + '$') | |
| 30 | + return re.test(name) | |
| 31 | +} | |
| 32 | + | |
| 33 | +export const scopeLabel = (id: string, names: Record<string, string>) => (id === 'home' ? 'Home' : names[id] ?? id) | |
| 34 | +export const scopeLink = (id: string) => (id === 'home' ? '/home' : `/projects/${encodeURIComponent(id)}`) | |
| 35 | + | |
| 36 | +/* ---------------------------------------------------------------- YAML */ | |
| 37 | + | |
| 38 | +const RESERVED = /^(true|false|null|yes|no|on|off|~|[-+]?[0-9][0-9._]*(e[-+]?[0-9]+)?)$/i | |
| 39 | + | |
| 40 | +function scalar(v: string | number | boolean): string { | |
| 41 | + if (typeof v !== 'string') return String(v) | |
| 42 | + if (v !== '' && !RESERVED.test(v) && /^[A-Za-z0-9_./$@%][A-Za-z0-9_./:@%$=+-]*$/.test(v)) return v | |
| 43 | + return JSON.stringify(v) | |
| 44 | +} | |
| 45 | + | |
| 46 | +const flowList = (a: string[]) => `[${a.map(scalar).join(', ')}]` | |
| 47 | + | |
| 48 | +function envLines(key: string, map: Record<string, McpEnvValue> | undefined): string[] { | |
| 49 | + const entries = Object.entries(map ?? {}) | |
| 50 | + if (!entries.length) return [] | |
| 51 | + return [`${key}:`, ...entries.map(([k, v]) => { | |
| 52 | + const body = v.fromEnv !== undefined ? `fromEnv: ${scalar(v.fromEnv)}` : `value: ${scalar(v.value ?? '')}` | |
| 53 | + return ` ${scalar(k)}: { ${body} }` | |
| 54 | + })] | |
| 55 | +} | |
| 56 | + | |
| 57 | +export function mcpToYaml(t: McpTemplate): string { | |
| 58 | + const l: string[] = [`name: ${scalar(t.name)}`] | |
| 59 | + if (t.description) l.push(`description: ${scalar(t.description)}`) | |
| 60 | + l.push(`transport: ${t.transport}`) | |
| 61 | + if (t.transport === 'stdio') { | |
| 62 | + if (t.command) l.push(`command: ${scalar(t.command)}`) | |
| 63 | + if (t.args?.length) l.push(`args: ${flowList(t.args)}`) | |
| 64 | + l.push(...envLines('env', t.env)) | |
| 65 | + } else { | |
| 66 | + if (t.url) l.push(`url: ${scalar(t.url)}`) | |
| 67 | + l.push(...envLines('headers', t.headers)) | |
| 68 | + if (t.auth?.bearerFromEnv) l.push('auth:', ` bearerFromEnv: ${scalar(t.auth.bearerFromEnv)}`) | |
| 69 | + } | |
| 70 | + return l.join('\n') + '\n' | |
| 71 | +} | |
| 72 | + | |
| 73 | +export function hookToYaml(t: HookTemplate): string { | |
| 74 | + const l: string[] = [`name: ${scalar(t.name)}`] | |
| 75 | + if (t.description) l.push(`description: ${scalar(t.description)}`) | |
| 76 | + l.push(`event: ${scalar(t.event)}`) | |
| 77 | + if (t.matcher) l.push(`matcher: ${scalar(t.matcher)}`) | |
| 78 | + l.push('handler:', ' type: command', ` command: ${scalar(t.handler.command)}`) | |
| 79 | + if (t.handler.timeout) l.push(` timeout: ${t.handler.timeout}`) | |
| 80 | + if (t.agents?.length) l.push(`agents: ${flowList(t.agents)}`) | |
| 81 | + return l.join('\n') + '\n' | |
| 82 | +} | |
| 83 | + | |
| 84 | +/* ---------------------------------------------------------- Frontmatter */ | |
| 85 | + | |
| 86 | +const fmValue = (v: string) => (/^[^\s:#'"[\]{}&*!|>%@`,-][^:#]*$/.test(v.trim()) && v.trim() === v ? v : JSON.stringify(v)) | |
| 87 | + | |
| 88 | +export function getFm(content: string, key: string): string { | |
| 89 | + return splitFrontmatter(content).frontmatter?.[key] ?? '' | |
| 90 | +} | |
| 91 | + | |
| 92 | +/** Sets a top-level frontmatter key, creating the block when missing. Other lines are left untouched. */ | |
| 93 | +export function setFm(content: string, key: string, value: string): string { | |
| 94 | + const line = `${key}: ${fmValue(value)}` | |
| 95 | + const m = /^(---\r?\n)([\s\S]*?)(\r?\n---[ \t]*(?:\r?\n|$))/.exec(content) | |
| 96 | + if (!m) return `---\n${line}\n---\n\n${content}` | |
| 97 | + const lines = m[2]!.split(/\r?\n/) | |
| 98 | + const idx = lines.findIndex(l => l.startsWith(`${key}:`)) | |
| 99 | + if (idx === -1) lines.push(line) | |
| 100 | + else { | |
| 101 | + let end = idx + 1 | |
| 102 | + while (end < lines.length && /^\s+\S/.test(lines[end]!)) end++ | |
| 103 | + lines.splice(idx, end - idx, line) | |
| 104 | + } | |
| 105 | + return m[1] + lines.join('\n') + m[3] + content.slice(m[0].length) | |
| 106 | +} | |
| 107 | + | |
| 108 | +export function newItemContent(type: CatalogItemType, name: string): string { | |
| 109 | + const head = `---\nname: ${name}\ndescription: \n---\n\n` | |
| 110 | + if (type === 'skill') return head.replace('description: \n', 'description: ""\n') + `# ${name}\n\nDescribe when and how the agent should use this skill.\n` | |
| 111 | + if (type === 'rule') return head.replace('description: \n', 'description: ""\n') + '- \n' | |
| 112 | + return head.replace('description: \n', 'description: ""\n') + 'You are a specialized subagent. Describe your role here.\n' | |
| 113 | +} | |
| 114 | + | |
| 115 | +export const HOOK_EVENTS = [ | |
| 116 | + 'PreToolUse', 'PostToolUse', 'UserPromptSubmit', 'Stop', 'SubagentStop', | |
| 117 | + 'SessionStart', 'SessionEnd', 'Notification', 'PreCompact', 'PermissionRequest' | |
| 118 | +] | |
| new file mode 100644 | |||
| @@ -0,0 +1,118 @@ | |||
| 1 | +import type { CatalogItemType, HookTemplate, ItemStatus, McpEnvValue, McpTemplate } from '@skilly/core' | ||
| 2 | + | ||
| 3 | +export const CAT_TYPES: CatalogItemType[] = ['skill', 'rule', 'agent', 'mcp', 'hook'] | ||
| 4 | + | ||
| 5 | +export const CAT_META: Record<CatalogItemType, { label: string, singular: string, icon: string, markdown: boolean }> = { | ||
| 6 | + skill: { label: 'Skills', singular: 'Skill', icon: 'i-lucide-sparkles', markdown: true }, | ||
| 7 | + rule: { label: 'Rules', singular: 'Rule', icon: 'i-lucide-scroll-text', markdown: true }, | ||
| 8 | + agent: { label: 'Agents', singular: 'Agent', icon: 'i-lucide-bot', markdown: true }, | ||
| 9 | + mcp: { label: 'MCP', singular: 'MCP server', icon: 'i-lucide-plug', markdown: false }, | ||
| 10 | + hook: { label: 'Hooks', singular: 'Hook', icon: 'i-lucide-webhook', markdown: false } | ||
| 11 | +} | ||
| 12 | + | ||
| 13 | +export const NAME_RE = /^[a-z0-9][a-z0-9._-]*$/ | ||
| 14 | + | ||
| 15 | +export const itemKey = (type: string, name: string) => `${type}/${name}` | ||
| 16 | + | ||
| 17 | +export const STATUS_META: Record<ItemStatus['state'], { label: string, color: 'success' | 'warning' | 'info' | 'neutral', icon: string, hint: string }> = { | ||
| 18 | + 'in-sync': { label: 'in sync', color: 'success', icon: 'i-lucide-check', hint: 'Identical to the catalog version.' }, | ||
| 19 | + 'modified': { label: 'modified', color: 'warning', icon: 'i-lucide-pencil', hint: 'Edited in this scope since the last sync.' }, | ||
| 20 | + 'catalog-updated': { label: 'catalog updated', color: 'info', icon: 'i-lucide-arrow-down-to-line', hint: 'The catalog has a newer version.' }, | ||
| 21 | + 'not-in-catalog': { label: 'not in catalog', color: 'neutral', icon: 'i-lucide-circle-dashed', hint: 'Local item, absent from the catalog.' }, | ||
| 22 | + 'differs-from-catalog': { label: 'differs from catalog', color: 'warning', icon: 'i-lucide-git-compare', hint: 'Same name as a catalog item, different content, not managed by skilly.' } | ||
| 23 | +} | ||
| 24 | + | ||
| 25 | +/** `*` wildcard match on names, same semantics as the config globs. */ | ||
| 26 | +export function globMatch(pattern: string, name: string): boolean { | ||
| 27 | + if (!pattern.includes('*')) return pattern === name | ||
| 28 | + const escape = (s: string) => s.replace(/[.+?^${}()|[\]\\]/g, c => '\\' + c) | ||
| 29 | + const re = new RegExp('^' + pattern.split('*').map(escape).join('.*') + '$') | ||
| 30 | + return re.test(name) | ||
| 31 | +} | ||
| 32 | + | ||
| 33 | +export const scopeLabel = (id: string, names: Record<string, string>) => (id === 'home' ? 'Home' : names[id] ?? id) | ||
| 34 | +export const scopeLink = (id: string) => (id === 'home' ? '/home' : `/projects/${encodeURIComponent(id)}`) | ||
| 35 | + | ||
| 36 | +/* ---------------------------------------------------------------- YAML */ | ||
| 37 | + | ||
| 38 | +const RESERVED = /^(true|false|null|yes|no|on|off|~|[-+]?[0-9][0-9._]*(e[-+]?[0-9]+)?)$/i | ||
| 39 | + | ||
| 40 | +function scalar(v: string | number | boolean): string { | ||
| 41 | + if (typeof v !== 'string') return String(v) | ||
| 42 | + if (v !== '' && !RESERVED.test(v) && /^[A-Za-z0-9_./$@%][A-Za-z0-9_./:@%$=+-]*$/.test(v)) return v | ||
| 43 | + return JSON.stringify(v) | ||
| 44 | +} | ||
| 45 | + | ||
| 46 | +const flowList = (a: string[]) => `[${a.map(scalar).join(', ')}]` | ||
| 47 | + | ||
| 48 | +function envLines(key: string, map: Record<string, McpEnvValue> | undefined): string[] { | ||
| 49 | + const entries = Object.entries(map ?? {}) | ||
| 50 | + if (!entries.length) return [] | ||
| 51 | + return [`${key}:`, ...entries.map(([k, v]) => { | ||
| 52 | + const body = v.fromEnv !== undefined ? `fromEnv: ${scalar(v.fromEnv)}` : `value: ${scalar(v.value ?? '')}` | ||
| 53 | + return ` ${scalar(k)}: { ${body} }` | ||
| 54 | + })] | ||
| 55 | +} | ||
| 56 | + | ||
| 57 | +export function mcpToYaml(t: McpTemplate): string { | ||
| 58 | + const l: string[] = [`name: ${scalar(t.name)}`] | ||
| 59 | + if (t.description) l.push(`description: ${scalar(t.description)}`) | ||
| 60 | + l.push(`transport: ${t.transport}`) | ||
| 61 | + if (t.transport === 'stdio') { | ||
| 62 | + if (t.command) l.push(`command: ${scalar(t.command)}`) | ||
| 63 | + if (t.args?.length) l.push(`args: ${flowList(t.args)}`) | ||
| 64 | + l.push(...envLines('env', t.env)) | ||
| 65 | + } else { | ||
| 66 | + if (t.url) l.push(`url: ${scalar(t.url)}`) | ||
| 67 | + l.push(...envLines('headers', t.headers)) | ||
| 68 | + if (t.auth?.bearerFromEnv) l.push('auth:', ` bearerFromEnv: ${scalar(t.auth.bearerFromEnv)}`) | ||
| 69 | + } | ||
| 70 | + return l.join('\n') + '\n' | ||
| 71 | +} | ||
| 72 | + | ||
| 73 | +export function hookToYaml(t: HookTemplate): string { | ||
| 74 | + const l: string[] = [`name: ${scalar(t.name)}`] | ||
| 75 | + if (t.description) l.push(`description: ${scalar(t.description)}`) | ||
| 76 | + l.push(`event: ${scalar(t.event)}`) | ||
| 77 | + if (t.matcher) l.push(`matcher: ${scalar(t.matcher)}`) | ||
| 78 | + l.push('handler:', ' type: command', ` command: ${scalar(t.handler.command)}`) | ||
| 79 | + if (t.handler.timeout) l.push(` timeout: ${t.handler.timeout}`) | ||
| 80 | + if (t.agents?.length) l.push(`agents: ${flowList(t.agents)}`) | ||
| 81 | + return l.join('\n') + '\n' | ||
| 82 | +} | ||
| 83 | + | ||
| 84 | +/* ---------------------------------------------------------- Frontmatter */ | ||
| 85 | + | ||
| 86 | +const fmValue = (v: string) => (/^[^\s:#'"[\]{}&*!|>%@`,-][^:#]*$/.test(v.trim()) && v.trim() === v ? v : JSON.stringify(v)) | ||
| 87 | + | ||
| 88 | +export function getFm(content: string, key: string): string { | ||
| 89 | + return splitFrontmatter(content).frontmatter?.[key] ?? '' | ||
| 90 | +} | ||
| 91 | + | ||
| 92 | +/** Sets a top-level frontmatter key, creating the block when missing. Other lines are left untouched. */ | ||
| 93 | +export function setFm(content: string, key: string, value: string): string { | ||
| 94 | + const line = `${key}: ${fmValue(value)}` | ||
| 95 | + const m = /^(---\r?\n)([\s\S]*?)(\r?\n---[ \t]*(?:\r?\n|$))/.exec(content) | ||
| 96 | + if (!m) return `---\n${line}\n---\n\n${content}` | ||
| 97 | + const lines = m[2]!.split(/\r?\n/) | ||
| 98 | + const idx = lines.findIndex(l => l.startsWith(`${key}:`)) | ||
| 99 | + if (idx === -1) lines.push(line) | ||
| 100 | + else { | ||
| 101 | + let end = idx + 1 | ||
| 102 | + while (end < lines.length && /^\s+\S/.test(lines[end]!)) end++ | ||
| 103 | + lines.splice(idx, end - idx, line) | ||
| 104 | + } | ||
| 105 | + return m[1] + lines.join('\n') + m[3] + content.slice(m[0].length) | ||
| 106 | +} | ||
| 107 | + | ||
| 108 | +export function newItemContent(type: CatalogItemType, name: string): string { | ||
| 109 | + const head = `---\nname: ${name}\ndescription: \n---\n\n` | ||
| 110 | + if (type === 'skill') return head.replace('description: \n', 'description: ""\n') + `# ${name}\n\nDescribe when and how the agent should use this skill.\n` | ||
| 111 | + if (type === 'rule') return head.replace('description: \n', 'description: ""\n') + '- \n' | ||
| 112 | + return head.replace('description: \n', 'description: ""\n') + 'You are a specialized subagent. Describe your role here.\n' | ||
| 113 | +} | ||
| 114 | + | ||
| 115 | +export const HOOK_EVENTS = [ | ||
| 116 | + 'PreToolUse', 'PostToolUse', 'UserPromptSubmit', 'Stop', 'SubagentStop', | ||
| 117 | + 'SessionStart', 'SessionEnd', 'Notification', 'PreCompact', 'PermissionRequest' | ||
| 118 | +] | ||
added
packages/ui/app/utils/resolve.ts +62 -0 | new file mode 100644 | ||
| @@ -0,0 +1,62 @@ | ||
| 1 | +import type { CatalogItemType, Preset } from '@skilly/core' | |
| 2 | + | |
| 3 | +export interface ResolvedEntry { | |
| 4 | + type: CatalogItemType | |
| 5 | + name: string | |
| 6 | + /** Presets that provide the item directly, plus `add` for explicit additions. */ | |
| 7 | + via: string[] | |
| 8 | +} | |
| 9 | + | |
| 10 | +export interface Resolved { | |
| 11 | + entries: ResolvedEntry[] | |
| 12 | + /** Included / enabled presets that are not in the catalog. */ | |
| 13 | + missingPresets: string[] | |
| 14 | + /** Presets reached twice through an include cycle (ignored). */ | |
| 15 | + cycles: string[] | |
| 16 | +} | |
| 17 | + | |
| 18 | +/** | |
| 19 | + * Client-side mirror of the server resolution: union of the items of `roots` (presets, recursively including | |
| 20 | + * their `includes`) and of the explicit `add` lists. Exclusions are not applied here. | |
| 21 | + */ | |
| 22 | +export function resolveItems( | |
| 23 | + presets: Preset[], | |
| 24 | + roots: string[], | |
| 25 | + add: Partial<Record<CatalogItemType, string[]>> = {}, | |
| 26 | + inline?: Preset | |
| 27 | +): Resolved { | |
| 28 | + const byName = new Map(presets.map(p => [p.name, p])) | |
| 29 | + const map = new Map<string, ResolvedEntry>() | |
| 30 | + const missing = new Set<string>() | |
| 31 | + const cycles = new Set<string>() | |
| 32 | + | |
| 33 | + const put = (type: CatalogItemType, name: string, via: string) => { | |
| 34 | + const k = `${type}/${name}` | |
| 35 | + const e = map.get(k) ?? { type, name, via: [] } | |
| 36 | + if (!e.via.includes(via)) e.via.push(via) | |
| 37 | + map.set(k, e) | |
| 38 | + } | |
| 39 | + const visit = (p: Preset, stack: string[]) => { | |
| 40 | + for (const [type, names] of Object.entries(p.items) as [CatalogItemType, string[] | undefined][]) { | |
| 41 | + for (const n of names ?? []) put(type, n, p.name) | |
| 42 | + } | |
| 43 | + for (const inc of p.includes) { | |
| 44 | + if (stack.includes(inc)) { cycles.add(inc); continue } | |
| 45 | + const child = byName.get(inc) | |
| 46 | + if (!child) { missing.add(inc); continue } | |
| 47 | + visit(child, [...stack, inc]) | |
| 48 | + } | |
| 49 | + } | |
| 50 | + | |
| 51 | + if (inline) visit(inline, [inline.name]) | |
| 52 | + for (const r of roots) { | |
| 53 | + const p = byName.get(r) | |
| 54 | + if (!p) { missing.add(r); continue } | |
| 55 | + visit(p, [r]) | |
| 56 | + } | |
| 57 | + for (const [type, names] of Object.entries(add) as [CatalogItemType, string[] | undefined][]) { | |
| 58 | + for (const n of names ?? []) put(type, n, 'add') | |
| 59 | + } | |
| 60 | + | |
| 61 | + return { entries: [...map.values()], missingPresets: [...missing], cycles: [...cycles] } | |
| 62 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,62 @@ | |||
| 1 | +import type { CatalogItemType, Preset } from '@skilly/core' | ||
| 2 | + | ||
| 3 | +export interface ResolvedEntry { | ||
| 4 | + type: CatalogItemType | ||
| 5 | + name: string | ||
| 6 | + /** Presets that provide the item directly, plus `add` for explicit additions. */ | ||
| 7 | + via: string[] | ||
| 8 | +} | ||
| 9 | + | ||
| 10 | +export interface Resolved { | ||
| 11 | + entries: ResolvedEntry[] | ||
| 12 | + /** Included / enabled presets that are not in the catalog. */ | ||
| 13 | + missingPresets: string[] | ||
| 14 | + /** Presets reached twice through an include cycle (ignored). */ | ||
| 15 | + cycles: string[] | ||
| 16 | +} | ||
| 17 | + | ||
| 18 | +/** | ||
| 19 | + * Client-side mirror of the server resolution: union of the items of `roots` (presets, recursively including | ||
| 20 | + * their `includes`) and of the explicit `add` lists. Exclusions are not applied here. | ||
| 21 | + */ | ||
| 22 | +export function resolveItems( | ||
| 23 | + presets: Preset[], | ||
| 24 | + roots: string[], | ||
| 25 | + add: Partial<Record<CatalogItemType, string[]>> = {}, | ||
| 26 | + inline?: Preset | ||
| 27 | +): Resolved { | ||
| 28 | + const byName = new Map(presets.map(p => [p.name, p])) | ||
| 29 | + const map = new Map<string, ResolvedEntry>() | ||
| 30 | + const missing = new Set<string>() | ||
| 31 | + const cycles = new Set<string>() | ||
| 32 | + | ||
| 33 | + const put = (type: CatalogItemType, name: string, via: string) => { | ||
| 34 | + const k = `${type}/${name}` | ||
| 35 | + const e = map.get(k) ?? { type, name, via: [] } | ||
| 36 | + if (!e.via.includes(via)) e.via.push(via) | ||
| 37 | + map.set(k, e) | ||
| 38 | + } | ||
| 39 | + const visit = (p: Preset, stack: string[]) => { | ||
| 40 | + for (const [type, names] of Object.entries(p.items) as [CatalogItemType, string[] | undefined][]) { | ||
| 41 | + for (const n of names ?? []) put(type, n, p.name) | ||
| 42 | + } | ||
| 43 | + for (const inc of p.includes) { | ||
| 44 | + if (stack.includes(inc)) { cycles.add(inc); continue } | ||
| 45 | + const child = byName.get(inc) | ||
| 46 | + if (!child) { missing.add(inc); continue } | ||
| 47 | + visit(child, [...stack, inc]) | ||
| 48 | + } | ||
| 49 | + } | ||
| 50 | + | ||
| 51 | + if (inline) visit(inline, [inline.name]) | ||
| 52 | + for (const r of roots) { | ||
| 53 | + const p = byName.get(r) | ||
| 54 | + if (!p) { missing.add(r); continue } | ||
| 55 | + visit(p, [r]) | ||
| 56 | + } | ||
| 57 | + for (const [type, names] of Object.entries(add) as [CatalogItemType, string[] | undefined][]) { | ||
| 58 | + for (const n of names ?? []) put(type, n, 'add') | ||
| 59 | + } | ||
| 60 | + | ||
| 61 | + return { entries: [...map.values()], missingPresets: [...missing], cycles: [...cycles] } | ||
| 62 | +} | ||
added
packages/ui/app/utils/scopeConfig.ts +20 -0 | new file mode 100644 | ||
| @@ -0,0 +1,20 @@ | ||
| 1 | +import type { AgentId, CatalogItemType, ScopeConfig } from '@skilly/core' | |
| 2 | + | |
| 3 | +export function emptyConfig(targets: AgentId[]): ScopeConfig { | |
| 4 | + return { version: 1, targets, presets: [], add: {}, exclude: {}, onDemand: { skills: [] }, hideGlobal: { skills: [], mcp: [] } } | |
| 5 | +} | |
| 6 | + | |
| 7 | +export const cloneConfig = (c: ScopeConfig): ScopeConfig => JSON.parse(JSON.stringify(c)) | |
| 8 | + | |
| 9 | +/** Returns a copy of `c` where `field[type]` is replaced by `list` (an empty list removes the key). */ | |
| 10 | +export function withTypeList(c: ScopeConfig, field: 'add' | 'exclude', type: CatalogItemType, list: string[]): ScopeConfig { | |
| 11 | + const next = cloneConfig(c) | |
| 12 | + const map = { ...next[field] } | |
| 13 | + if (list.length) map[type] = list | |
| 14 | + else delete map[type] | |
| 15 | + next[field] = map | |
| 16 | + return next | |
| 17 | +} | |
| 18 | + | |
| 19 | +export const toggleIn = (list: string[], name: string, on: boolean) => | |
| 20 | + on ? (list.includes(name) ? list : [...list, name]) : list.filter(x => x !== name) | |
| new file mode 100644 | |||
| @@ -0,0 +1,20 @@ | |||
| 1 | +import type { AgentId, CatalogItemType, ScopeConfig } from '@skilly/core' | ||
| 2 | + | ||
| 3 | +export function emptyConfig(targets: AgentId[]): ScopeConfig { | ||
| 4 | + return { version: 1, targets, presets: [], add: {}, exclude: {}, onDemand: { skills: [] }, hideGlobal: { skills: [], mcp: [] } } | ||
| 5 | +} | ||
| 6 | + | ||
| 7 | +export const cloneConfig = (c: ScopeConfig): ScopeConfig => JSON.parse(JSON.stringify(c)) | ||
| 8 | + | ||
| 9 | +/** Returns a copy of `c` where `field[type]` is replaced by `list` (an empty list removes the key). */ | ||
| 10 | +export function withTypeList(c: ScopeConfig, field: 'add' | 'exclude', type: CatalogItemType, list: string[]): ScopeConfig { | ||
| 11 | + const next = cloneConfig(c) | ||
| 12 | + const map = { ...next[field] } | ||
| 13 | + if (list.length) map[type] = list | ||
| 14 | + else delete map[type] | ||
| 15 | + next[field] = map | ||
| 16 | + return next | ||
| 17 | +} | ||
| 18 | + | ||
| 19 | +export const toggleIn = (list: string[], name: string, on: boolean) => | ||
| 20 | + on ? (list.includes(name) ? list : [...list, name]) : list.filter(x => x !== name) | ||