feat(ui): add a dashboard, show Home as the Global project and merge project tabs into one Items list
Landing on Home was confusing ("Home" read as the home page, the page opened on the raw contents of ~ and the
Move button had no context), and the Configuration and Files tabs showed the same skills from two points of view.
- Dashboard at `/`: "To do now" (conflicts, drift, changes to apply, catalog sync, items to tidy up), a
getting-started checklist, overview cards (Global, Projects, Catalog, Sync) and recent projects. Dashboard is
added to the nav and Home is removed from it.
- Home becomes "Global", a special project at `/projects/global` (`/home` redirects; the server scope id stays
`home`). It has the project tabs, a "loaded in every project" banner and a "To tidy up" panel. Move becomes
"Move to projects…", in a row menu instead of a button on every row.
- Project and Global pages are full width with two tabs, Items and Instructions. Items is one list grouped by type
with every item the agents load: managed by skilly, local, from a plugin, and hooks. It works before the scope is
managed and has type, origin and search filters. Clicking a name opens the item drawer.
- Importing from a project or from Global keeps the items there, managed by skilly. Local items identical to the
catalog get "Manage" and "Manage all", and Apply takes the files over without rewriting them.
- Shared scope summaries (useScopeSummaries) are used by the Projects table and the dashboard.
- design/BRIEF.md records these decisions (section 2.0).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>0e3da4a parent: 0b145af modified
packages/ui/app/components/AddToCatalogModal.vue +33 -8 | @@ -1,10 +1,13 @@ | ||
| 1 | 1 | <script setup lang="ts"> |
| 2 | 2 | import type { ImportResult, InventoryItem, ItemStatus } from '@skilly/core' |
| 3 | 3 | |
| 4 | -/** "Add to catalog": copies local items into the catalog (tags, "overwrite" when a different version exists). */ | |
| 4 | +/** | |
| 5 | + * "Add to catalog": copies local items into the catalog (tags, "overwrite" when a different version exists). | |
| 6 | + * The scope page then keeps them enabled where they came from, managed by skilly (`done` carries the result). | |
| 7 | + */ | |
| 5 | 8 | const props = defineProps<{ scope: string, items: InventoryItem[], statuses: Record<string, ItemStatus> }>() |
| 6 | 9 | const open = defineModel<boolean>('open', { default: false }) |
| 7 | -const emit = defineEmits<{ done: [] }>() | |
| 10 | +const emit = defineEmits<{ done: [result: ImportResult] }>() | |
| 8 | 11 | |
| 9 | 12 | const { request } = useApi() |
| 10 | 13 | const toast = useToast() |
| @@ -14,9 +17,23 @@ const overwrite = ref(false) | ||
| 14 | 17 | const busy = ref(false) |
| 15 | 18 | const result = ref<ImportResult | null>(null) |
| 16 | 19 | const error = ref<string | null>(null) |
| 20 | +/** Ids of the items left checked: all of them when the modal opens. */ | |
| 21 | +const selected = ref<Set<string>>(new Set()) | |
| 17 | 22 | |
| 18 | -const differing = computed(() => props.items.filter(i => props.statuses[i.id]?.state === 'differs-from-catalog')) | |
| 19 | -const toSend = computed(() => props.items.filter(i => overwrite.value || props.statuses[i.id]?.state !== 'differs-from-catalog')) | |
| 23 | +const picked = computed(() => props.items.filter(i => selected.value.has(i.id))) | |
| 24 | +const differing = computed(() => picked.value.filter(i => props.statuses[i.id]?.state === 'differs-from-catalog')) | |
| 25 | +const toSend = computed(() => picked.value.filter(i => overwrite.value || props.statuses[i.id]?.state !== 'differs-from-catalog')) | |
| 26 | +const allSelected = computed<boolean | 'indeterminate'>({ | |
| 27 | + get: () => (picked.value.length === props.items.length ? true : picked.value.length ? 'indeterminate' : false), | |
| 28 | + set: (v) => { selected.value = new Set(v === true ? props.items.map(i => i.id) : []) } | |
| 29 | +}) | |
| 30 | + | |
| 31 | +function toggle(id: string, on: boolean | 'indeterminate') { | |
| 32 | + const next = new Set(selected.value) | |
| 33 | + if (on === true) next.add(id) | |
| 34 | + else next.delete(id) | |
| 35 | + selected.value = next | |
| 36 | +} | |
| 20 | 37 | |
| 21 | 38 | watch(open, (v) => { |
| 22 | 39 | if (v) { |
| @@ -24,6 +41,7 @@ watch(open, (v) => { | ||
| 24 | 41 | overwrite.value = false |
| 25 | 42 | result.value = null |
| 26 | 43 | error.value = null |
| 44 | + selected.value = new Set(props.items.map(i => i.id)) | |
| 27 | 45 | } |
| 28 | 46 | }) |
| 29 | 47 | |
| @@ -36,8 +54,13 @@ async function submit() { | ||
| 36 | 54 | body: { scope: props.scope, itemIds: toSend.value.map(i => i.id), overwrite: overwrite.value, ...(tags.value.length ? { tags: tags.value } : {}) }, |
| 37 | 55 | silent: true |
| 38 | 56 | }) |
| 39 | - toast.add({ title: `${result.value.imported.length} added to the catalog`, icon: 'i-lucide-check', color: 'success' }) | |
| 40 | - emit('done') | |
| 57 | + toast.add({ | |
| 58 | + title: `${result.value.imported.length} added to the catalog`, | |
| 59 | + description: result.value.imported.length ? 'They stay here, now managed by skilly. Apply to take them over.' : undefined, | |
| 60 | + icon: 'i-lucide-check', | |
| 61 | + color: 'success' | |
| 62 | + }) | |
| 63 | + emit('done', result.value) | |
| 41 | 64 | } catch (e) { |
| 42 | 65 | error.value = (e as Error).message |
| 43 | 66 | } finally { |
| @@ -47,7 +70,7 @@ async function submit() { | ||
| 47 | 70 | </script> |
| 48 | 71 | |
| 49 | 72 | <template> |
| 50 | - <SkModal v-model:open="open" title="Add to catalog" description="Copy these items into the catalog so they can be reused in other projects."> | |
| 73 | + <SkModal v-model:open="open" title="Add to catalog" description="Copy these items into the catalog so they can be reused in other projects. They stay enabled here, managed by skilly."> | |
| 51 | 74 | <div v-if="result" class="flex flex-col gap-3"> |
| 52 | 75 | <SkNote v-if="result.imported.length" tone="ok"> |
| 53 | 76 | <span class="font-medium">{{ result.imported.length }} added</span> |
| @@ -58,8 +81,10 @@ async function submit() { | ||
| 58 | 81 | |
| 59 | 82 | <div v-else class="flex flex-col gap-4"> |
| 60 | 83 | <SkNote v-if="error" tone="accent"><span class="font-medium">Import failed.</span> {{ error }}</SkNote> |
| 61 | - <ul class="m-0 max-h-48 list-none overflow-auto rounded-[10px] border border-sk-line p-0"> | |
| 84 | + <UCheckbox v-if="items.length > 1" v-model="allSelected" :label="`Select all (${picked.length}/${items.length})`" class="text-sk-text" /> | |
| 85 | + <ul class="m-0 max-h-64 list-none overflow-auto rounded-[10px] border border-sk-line p-0"> | |
| 62 | 86 | <li v-for="i in items" :key="i.id" class="flex items-center gap-2.5 border-t border-sk-line2 px-3 py-2 first:border-t-0"> |
| 87 | + <UCheckbox v-if="items.length > 1" :model-value="selected.has(i.id)" :aria-label="`Select ${itemDisplayName(i)}`" @update:model-value="(v) => toggle(i.id, v)" /> | |
| 63 | 88 | <span class="truncate font-medium">{{ itemDisplayName(i) }}</span> |
| 64 | 89 | <SkTypeTag :type="i.type" /> |
| 65 | 90 | <SkAgentBadge :agent="i.agent" class="text-xs" /> |
| @@ -1,10 +1,13 @@ | |||
| 1 | <script setup lang="ts"> | 1 | <script setup lang="ts"> |
| 2 | import type { ImportResult, InventoryItem, ItemStatus } from '@skilly/core' | 2 | import type { ImportResult, InventoryItem, ItemStatus } from '@skilly/core' |
| 3 | 3 | ||
| 4 | -/** "Add to catalog": copies local items into the catalog (tags, "overwrite" when a different version exists). */ | 4 | +/** |
| 5 | + * "Add to catalog": copies local items into the catalog (tags, "overwrite" when a different version exists). | ||
| 6 | + * The scope page then keeps them enabled where they came from, managed by skilly (`done` carries the result). | ||
| 7 | + */ | ||
| 5 | const props = defineProps<{ scope: string, items: InventoryItem[], statuses: Record<string, ItemStatus> }>() | 8 | const props = defineProps<{ scope: string, items: InventoryItem[], statuses: Record<string, ItemStatus> }>() |
| 6 | const open = defineModel<boolean>('open', { default: false }) | 9 | const open = defineModel<boolean>('open', { default: false }) |
| 7 | -const emit = defineEmits<{ done: [] }>() | 10 | +const emit = defineEmits<{ done: [result: ImportResult] }>() |
| 8 | 11 | ||
| 9 | const { request } = useApi() | 12 | const { request } = useApi() |
| 10 | const toast = useToast() | 13 | const toast = useToast() |
| @@ -14,9 +17,23 @@ const overwrite = ref(false) | |||
| 14 | const busy = ref(false) | 17 | const busy = ref(false) |
| 15 | const result = ref<ImportResult | null>(null) | 18 | const result = ref<ImportResult | null>(null) |
| 16 | const error = ref<string | null>(null) | 19 | const error = ref<string | null>(null) |
| 20 | +/** Ids of the items left checked: all of them when the modal opens. */ | ||
| 21 | +const selected = ref<Set<string>>(new Set()) | ||
| 17 | 22 | ||
| 18 | -const differing = computed(() => props.items.filter(i => props.statuses[i.id]?.state === 'differs-from-catalog')) | 23 | +const picked = computed(() => props.items.filter(i => selected.value.has(i.id))) |
| 19 | -const toSend = computed(() => props.items.filter(i => overwrite.value || props.statuses[i.id]?.state !== 'differs-from-catalog')) | 24 | +const differing = computed(() => picked.value.filter(i => props.statuses[i.id]?.state === 'differs-from-catalog')) |
| 25 | +const toSend = computed(() => picked.value.filter(i => overwrite.value || props.statuses[i.id]?.state !== 'differs-from-catalog')) | ||
| 26 | +const allSelected = computed<boolean | 'indeterminate'>({ | ||
| 27 | + get: () => (picked.value.length === props.items.length ? true : picked.value.length ? 'indeterminate' : false), | ||
| 28 | + set: (v) => { selected.value = new Set(v === true ? props.items.map(i => i.id) : []) } | ||
| 29 | +}) | ||
| 30 | + | ||
| 31 | +function toggle(id: string, on: boolean | 'indeterminate') { | ||
| 32 | + const next = new Set(selected.value) | ||
| 33 | + if (on === true) next.add(id) | ||
| 34 | + else next.delete(id) | ||
| 35 | + selected.value = next | ||
| 36 | +} | ||
| 20 | 37 | ||
| 21 | watch(open, (v) => { | 38 | watch(open, (v) => { |
| 22 | if (v) { | 39 | if (v) { |
| @@ -24,6 +41,7 @@ watch(open, (v) => { | |||
| 24 | overwrite.value = false | 41 | overwrite.value = false |
| 25 | result.value = null | 42 | result.value = null |
| 26 | error.value = null | 43 | error.value = null |
| 44 | + selected.value = new Set(props.items.map(i => i.id)) | ||
| 27 | } | 45 | } |
| 28 | }) | 46 | }) |
| 29 | 47 | ||
| @@ -36,8 +54,13 @@ async function submit() { | |||
| 36 | body: { scope: props.scope, itemIds: toSend.value.map(i => i.id), overwrite: overwrite.value, ...(tags.value.length ? { tags: tags.value } : {}) }, | 54 | body: { scope: props.scope, itemIds: toSend.value.map(i => i.id), overwrite: overwrite.value, ...(tags.value.length ? { tags: tags.value } : {}) }, |
| 37 | silent: true | 55 | silent: true |
| 38 | }) | 56 | }) |
| 39 | - toast.add({ title: `${result.value.imported.length} added to the catalog`, icon: 'i-lucide-check', color: 'success' }) | 57 | + toast.add({ |
| 40 | - emit('done') | 58 | + title: `${result.value.imported.length} added to the catalog`, |
| 59 | + description: result.value.imported.length ? 'They stay here, now managed by skilly. Apply to take them over.' : undefined, | ||
| 60 | + icon: 'i-lucide-check', | ||
| 61 | + color: 'success' | ||
| 62 | + }) | ||
| 63 | + emit('done', result.value) | ||
| 41 | } catch (e) { | 64 | } catch (e) { |
| 42 | error.value = (e as Error).message | 65 | error.value = (e as Error).message |
| 43 | } finally { | 66 | } finally { |
| @@ -47,7 +70,7 @@ async function submit() { | |||
| 47 | </script> | 70 | </script> |
| 48 | 71 | ||
| 49 | <template> | 72 | <template> |
| 50 | - <SkModal v-model:open="open" title="Add to catalog" description="Copy these items into the catalog so they can be reused in other projects."> | 73 | + <SkModal v-model:open="open" title="Add to catalog" description="Copy these items into the catalog so they can be reused in other projects. They stay enabled here, managed by skilly."> |
| 51 | <div v-if="result" class="flex flex-col gap-3"> | 74 | <div v-if="result" class="flex flex-col gap-3"> |
| 52 | <SkNote v-if="result.imported.length" tone="ok"> | 75 | <SkNote v-if="result.imported.length" tone="ok"> |
| 53 | <span class="font-medium">{{ result.imported.length }} added</span> | 76 | <span class="font-medium">{{ result.imported.length }} added</span> |
| @@ -58,8 +81,10 @@ async function submit() { | |||
| 58 | 81 | ||
| 59 | <div v-else class="flex flex-col gap-4"> | 82 | <div v-else class="flex flex-col gap-4"> |
| 60 | <SkNote v-if="error" tone="accent"><span class="font-medium">Import failed.</span> {{ error }}</SkNote> | 83 | <SkNote v-if="error" tone="accent"><span class="font-medium">Import failed.</span> {{ error }}</SkNote> |
| 61 | - <ul class="m-0 max-h-48 list-none overflow-auto rounded-[10px] border border-sk-line p-0"> | 84 | + <UCheckbox v-if="items.length > 1" v-model="allSelected" :label="`Select all (${picked.length}/${items.length})`" class="text-sk-text" /> |
| 85 | + <ul class="m-0 max-h-64 list-none overflow-auto rounded-[10px] border border-sk-line p-0"> | ||
| 62 | <li v-for="i in items" :key="i.id" class="flex items-center gap-2.5 border-t border-sk-line2 px-3 py-2 first:border-t-0"> | 86 | <li v-for="i in items" :key="i.id" class="flex items-center gap-2.5 border-t border-sk-line2 px-3 py-2 first:border-t-0"> |
| 87 | + <UCheckbox v-if="items.length > 1" :model-value="selected.has(i.id)" :aria-label="`Select ${itemDisplayName(i)}`" @update:model-value="(v) => toggle(i.id, v)" /> | ||
| 63 | <span class="truncate font-medium">{{ itemDisplayName(i) }}</span> | 88 | <span class="truncate font-medium">{{ itemDisplayName(i) }}</span> |
| 64 | <SkTypeTag :type="i.type" /> | 89 | <SkTypeTag :type="i.type" /> |
| 65 | <SkAgentBadge :agent="i.agent" class="text-xs" /> | 90 | <SkAgentBadge :agent="i.agent" class="text-xs" /> |
modified
packages/ui/app/components/AppHeader.vue +4 -4 | @@ -18,14 +18,14 @@ onMounted(() => { | ||
| 18 | 18 | |
| 19 | 19 | interface NavItem { label: string, to: string, count?: number } |
| 20 | 20 | const nav = computed<NavItem[]>(() => [ |
| 21 | - // Instruction files are a separate group on the Home page, not "items loaded everywhere". | |
| 22 | - { label: 'Home', to: '/home', count: home.value ? home.value.items.filter(i => i.type !== 'instructions').length : undefined }, | |
| 21 | + { label: 'Dashboard', to: '/' }, | |
| 22 | + // Global (the Home scope) is the pinned first row of Projects. | |
| 23 | 23 | { label: 'Projects', to: '/projects', count: projects.value.length }, |
| 24 | 24 | { label: 'Catalog', to: '/catalog', count: exists.value ? items.value.length : undefined }, |
| 25 | 25 | { label: 'Marketplaces', to: '/marketplaces' }, |
| 26 | 26 | { label: 'Sync', to: '/sync' } |
| 27 | 27 | ]) |
| 28 | -const isActive = (to: string) => route.path === to || route.path.startsWith(to + '/') | |
| 28 | +const isActive = (to: string) => route.path === to || (to !== '/' && route.path.startsWith(to + '/')) | |
| 29 | 29 | |
| 30 | 30 | const theme = computed({ |
| 31 | 31 | get: () => (colorMode.value === 'dark' ? 'dark' : 'light'), |
| @@ -44,7 +44,7 @@ async function onPill() { | ||
| 44 | 44 | <header |
| 45 | 45 | class="sticky top-0 z-30 flex min-w-0 items-center gap-2.5 px-[18px] py-3 backdrop-blur-[8px] bg-[color-mix(in_oklch,var(--sk-bg)_82%,transparent)]" |
| 46 | 46 | > |
| 47 | - <NuxtLink to="/projects" class="flex shrink-0 items-center gap-2 text-sk-text hover:text-sk-text" aria-label="skilly"> | |
| 47 | + <NuxtLink to="/" class="flex shrink-0 items-center gap-2 text-sk-text hover:text-sk-text" aria-label="skilly"> | |
| 48 | 48 | <SkLogo /> |
| 49 | 49 | <span class="text-base font-semibold">skilly</span> |
| 50 | 50 | </NuxtLink> |
| @@ -18,14 +18,14 @@ onMounted(() => { | |||
| 18 | 18 | ||
| 19 | interface NavItem { label: string, to: string, count?: number } | 19 | interface NavItem { label: string, to: string, count?: number } |
| 20 | const nav = computed<NavItem[]>(() => [ | 20 | const nav = computed<NavItem[]>(() => [ |
| 21 | - // Instruction files are a separate group on the Home page, not "items loaded everywhere". | 21 | + { label: 'Dashboard', to: '/' }, |
| 22 | - { label: 'Home', to: '/home', count: home.value ? home.value.items.filter(i => i.type !== 'instructions').length : undefined }, | 22 | + // Global (the Home scope) is the pinned first row of Projects. |
| 23 | { label: 'Projects', to: '/projects', count: projects.value.length }, | 23 | { label: 'Projects', to: '/projects', count: projects.value.length }, |
| 24 | { label: 'Catalog', to: '/catalog', count: exists.value ? items.value.length : undefined }, | 24 | { label: 'Catalog', to: '/catalog', count: exists.value ? items.value.length : undefined }, |
| 25 | { label: 'Marketplaces', to: '/marketplaces' }, | 25 | { label: 'Marketplaces', to: '/marketplaces' }, |
| 26 | { label: 'Sync', to: '/sync' } | 26 | { label: 'Sync', to: '/sync' } |
| 27 | ]) | 27 | ]) |
| 28 | -const isActive = (to: string) => route.path === to || route.path.startsWith(to + '/') | 28 | +const isActive = (to: string) => route.path === to || (to !== '/' && route.path.startsWith(to + '/')) |
| 29 | 29 | ||
| 30 | const theme = computed({ | 30 | const theme = computed({ |
| 31 | get: () => (colorMode.value === 'dark' ? 'dark' : 'light'), | 31 | get: () => (colorMode.value === 'dark' ? 'dark' : 'light'), |
| @@ -44,7 +44,7 @@ async function onPill() { | |||
| 44 | <header | 44 | <header |
| 45 | class="sticky top-0 z-30 flex min-w-0 items-center gap-2.5 px-[18px] py-3 backdrop-blur-[8px] bg-[color-mix(in_oklch,var(--sk-bg)_82%,transparent)]" | 45 | class="sticky top-0 z-30 flex min-w-0 items-center gap-2.5 px-[18px] py-3 backdrop-blur-[8px] bg-[color-mix(in_oklch,var(--sk-bg)_82%,transparent)]" |
| 46 | > | 46 | > |
| 47 | - <NuxtLink to="/projects" class="flex shrink-0 items-center gap-2 text-sk-text hover:text-sk-text" aria-label="skilly"> | 47 | + <NuxtLink to="/" class="flex shrink-0 items-center gap-2 text-sk-text hover:text-sk-text" aria-label="skilly"> |
| 48 | <SkLogo /> | 48 | <SkLogo /> |
| 49 | <span class="text-base font-semibold">skilly</span> | 49 | <span class="text-base font-semibold">skilly</span> |
| 50 | </NuxtLink> | 50 | </NuxtLink> |
modified
packages/ui/app/components/AppSwitcher.vue +2 -1 | @@ -15,7 +15,8 @@ function go(to: string) { | ||
| 15 | 15 | } |
| 16 | 16 | |
| 17 | 17 | const PAGES = [ |
| 18 | - { label: 'Home', to: '/home', description: 'User-level config' }, | |
| 18 | + { label: 'Dashboard', to: '/' }, | |
| 19 | + { label: 'Global', to: GLOBAL_PATH, description: 'Loaded in every project (~)' }, | |
| 19 | 20 | { label: 'Projects', to: '/projects' }, |
| 20 | 21 | { label: 'Catalog', to: '/catalog' }, |
| 21 | 22 | { label: 'Presets', to: '/catalog/presets' }, |
| @@ -15,7 +15,8 @@ function go(to: string) { | |||
| 15 | } | 15 | } |
| 16 | 16 | ||
| 17 | const PAGES = [ | 17 | const PAGES = [ |
| 18 | - { label: 'Home', to: '/home', description: 'User-level config' }, | 18 | + { label: 'Dashboard', to: '/' }, |
| 19 | + { label: 'Global', to: GLOBAL_PATH, description: 'Loaded in every project (~)' }, | ||
| 19 | { label: 'Projects', to: '/projects' }, | 20 | { label: 'Projects', to: '/projects' }, |
| 20 | { label: 'Catalog', to: '/catalog' }, | 21 | { label: 'Catalog', to: '/catalog' }, |
| 21 | { label: 'Presets', to: '/catalog/presets' }, | 22 | { label: 'Presets', to: '/catalog/presets' }, |
modified
packages/ui/app/components/RemoveFromGlobalModal.vue +9 -9 | @@ -2,11 +2,11 @@ | ||
| 2 | 2 | import type { InventoryItem, ItemStatus, RemoveFromGlobalResult } from '@skilly/core' |
| 3 | 3 | |
| 4 | 4 | /** |
| 5 | - * "Remove {name} from global" (mockup 7): three numbered steps adapted to the real API. | |
| 5 | + * "Move {name} to projects" (mockup 7, "Remove from global"): three numbered steps adapted to the real API. | |
| 6 | 6 | * 1. Catalog: the item is imported unless an identical one is already there. A different item with the same name blocks the move. |
| 7 | 7 | * 2. Enable in projects: written into the chosen projects (pre-checked with the ones that already use it). |
| 8 | - * 3. Remove from Home: files are deleted, symlinks only unlinked. | |
| 9 | - * Only skills, rules and subagents can be moved (Home MCP servers and hooks are read-only in v1). | |
| 8 | + * 3. Remove from Global: files are deleted, symlinks only unlinked. | |
| 9 | + * Only skills, rules and subagents can be moved (Global MCP servers and hooks are read-only in v1). | |
| 10 | 10 | */ |
| 11 | 11 | const props = defineProps<{ items: InventoryItem[], statuses: Record<string, ItemStatus> }>() |
| 12 | 12 | const open = defineModel<boolean>('open', { default: false }) |
| @@ -55,7 +55,7 @@ function toggle(id: string, on: boolean | 'indeterminate') { | ||
| 55 | 55 | chosen.value = on === true ? [...new Set([...chosen.value, id])] : chosen.value.filter(x => x !== id) |
| 56 | 56 | } |
| 57 | 57 | |
| 58 | -const title = computed(() => (props.items.length === 1 ? `Remove ${props.items[0]!.name} from global` : `Remove ${props.items.length} items from global`)) | |
| 58 | +const title = computed(() => (props.items.length === 1 ? `Move ${props.items[0]!.name} to projects` : `Move ${props.items.length} items to projects`)) | |
| 59 | 59 | |
| 60 | 60 | async function submit() { |
| 61 | 61 | busy.value = true |
| @@ -69,7 +69,7 @@ async function submit() { | ||
| 69 | 69 | result.value = res |
| 70 | 70 | const first = props.items.find(i => i.id === res.removed[0]) |
| 71 | 71 | toast.add({ |
| 72 | - title: res.removed.length === 1 && first ? `${first.name} removed from global` : `${res.removed.length} item${res.removed.length === 1 ? '' : 's'} removed from global`, | |
| 72 | + title: res.removed.length === 1 && first ? `${first.name} moved out of Global` : `${res.removed.length} item${res.removed.length === 1 ? '' : 's'} moved out of Global`, | |
| 73 | 73 | description: chosen.value.length ? `enabled in ${chosen.value.length} project${chosen.value.length === 1 ? '' : 's'}` : 'available in the catalog only', |
| 74 | 74 | color: res.errors.length ? 'warning' : 'success', |
| 75 | 75 | icon: 'i-lucide-check' |
| @@ -90,7 +90,7 @@ const nameOf = (id: string) => props.items.find(i => i.id === id)?.name ?? id | ||
| 90 | 90 | <!-- Result --> |
| 91 | 91 | <div v-if="result" class="flex flex-col gap-3"> |
| 92 | 92 | <SkNote v-if="result.removed.length" tone="ok"> |
| 93 | - <span class="font-medium">{{ result.removed.length }} removed from Home</span> | |
| 93 | + <span class="font-medium">{{ result.removed.length }} removed from Global</span> | |
| 94 | 94 | <span class="block font-mono text-xs text-sk-muted">{{ result.removed.map(nameOf).join(', ') }}</span> |
| 95 | 95 | </SkNote> |
| 96 | 96 | <SkNote v-if="result.imported.length" tone="info"> |
| @@ -134,11 +134,11 @@ const nameOf = (id: string) => props.items.find(i => i.id === id)?.name ?? id | ||
| 134 | 134 | </div> |
| 135 | 135 | </div> |
| 136 | 136 | |
| 137 | - <!-- 3 Home --> | |
| 137 | + <!-- 3 Global --> | |
| 138 | 138 | <div class="flex gap-3"> |
| 139 | 139 | <span class="mt-px flex size-[22px] shrink-0 items-center justify-center rounded-full bg-sk-line2 font-mono text-[11px] text-sk-muted">3</span> |
| 140 | 140 | <div class="flex min-w-0 flex-1 flex-col gap-1"> |
| 141 | - <span class="font-medium">Remove from Home</span> | |
| 141 | + <span class="font-medium">Remove from Global</span> | |
| 142 | 142 | <ul class="m-0 flex list-none flex-col gap-0.5 p-0 font-mono text-xs text-sk-muted"> |
| 143 | 143 | <li v-for="i in items" :key="i.id" class="break-all"> |
| 144 | 144 | {{ itemLocation(i, root, '~/') }} <span class="font-sans">will be {{ i.source.symlinkTarget ? 'unlinked (only the link, never its target)' : 'removed' }}</span> |
| @@ -150,7 +150,7 @@ const nameOf = (id: string) => props.items.find(i => i.id === id)?.name ?? id | ||
| 150 | 150 | |
| 151 | 151 | <template #footer> |
| 152 | 152 | <SkButton :label="result ? 'Close' : 'Cancel'" @click="open = false" /> |
| 153 | - <SkButton v-if="!result" variant="primary" label="Remove from global" :loading="busy" :disabled="!movable.length" @click="submit" /> | |
| 153 | + <SkButton v-if="!result" variant="primary" label="Move to projects" :loading="busy" :disabled="!movable.length" @click="submit" /> | |
| 154 | 154 | </template> |
| 155 | 155 | </SkModal> |
| 156 | 156 | </template> |
| @@ -2,11 +2,11 @@ | |||
| 2 | import type { InventoryItem, ItemStatus, RemoveFromGlobalResult } from '@skilly/core' | 2 | import type { InventoryItem, ItemStatus, RemoveFromGlobalResult } from '@skilly/core' |
| 3 | 3 | ||
| 4 | /** | 4 | /** |
| 5 | - * "Remove {name} from global" (mockup 7): three numbered steps adapted to the real API. | 5 | + * "Move {name} to projects" (mockup 7, "Remove from global"): three numbered steps adapted to the real API. |
| 6 | * 1. Catalog: the item is imported unless an identical one is already there. A different item with the same name blocks the move. | 6 | * 1. Catalog: the item is imported unless an identical one is already there. A different item with the same name blocks the move. |
| 7 | * 2. Enable in projects: written into the chosen projects (pre-checked with the ones that already use it). | 7 | * 2. Enable in projects: written into the chosen projects (pre-checked with the ones that already use it). |
| 8 | - * 3. Remove from Home: files are deleted, symlinks only unlinked. | 8 | + * 3. Remove from Global: files are deleted, symlinks only unlinked. |
| 9 | - * Only skills, rules and subagents can be moved (Home MCP servers and hooks are read-only in v1). | 9 | + * Only skills, rules and subagents can be moved (Global MCP servers and hooks are read-only in v1). |
| 10 | */ | 10 | */ |
| 11 | const props = defineProps<{ items: InventoryItem[], statuses: Record<string, ItemStatus> }>() | 11 | const props = defineProps<{ items: InventoryItem[], statuses: Record<string, ItemStatus> }>() |
| 12 | const open = defineModel<boolean>('open', { default: false }) | 12 | const open = defineModel<boolean>('open', { default: false }) |
| @@ -55,7 +55,7 @@ function toggle(id: string, on: boolean | 'indeterminate') { | |||
| 55 | chosen.value = on === true ? [...new Set([...chosen.value, id])] : chosen.value.filter(x => x !== id) | 55 | chosen.value = on === true ? [...new Set([...chosen.value, id])] : chosen.value.filter(x => x !== id) |
| 56 | } | 56 | } |
| 57 | 57 | ||
| 58 | -const title = computed(() => (props.items.length === 1 ? `Remove ${props.items[0]!.name} from global` : `Remove ${props.items.length} items from global`)) | 58 | +const title = computed(() => (props.items.length === 1 ? `Move ${props.items[0]!.name} to projects` : `Move ${props.items.length} items to projects`)) |
| 59 | 59 | ||
| 60 | async function submit() { | 60 | async function submit() { |
| 61 | busy.value = true | 61 | busy.value = true |
| @@ -69,7 +69,7 @@ async function submit() { | |||
| 69 | result.value = res | 69 | result.value = res |
| 70 | const first = props.items.find(i => i.id === res.removed[0]) | 70 | const first = props.items.find(i => i.id === res.removed[0]) |
| 71 | toast.add({ | 71 | toast.add({ |
| 72 | - title: res.removed.length === 1 && first ? `${first.name} removed from global` : `${res.removed.length} item${res.removed.length === 1 ? '' : 's'} removed from global`, | 72 | + title: res.removed.length === 1 && first ? `${first.name} moved out of Global` : `${res.removed.length} item${res.removed.length === 1 ? '' : 's'} moved out of Global`, |
| 73 | description: chosen.value.length ? `enabled in ${chosen.value.length} project${chosen.value.length === 1 ? '' : 's'}` : 'available in the catalog only', | 73 | description: chosen.value.length ? `enabled in ${chosen.value.length} project${chosen.value.length === 1 ? '' : 's'}` : 'available in the catalog only', |
| 74 | color: res.errors.length ? 'warning' : 'success', | 74 | color: res.errors.length ? 'warning' : 'success', |
| 75 | icon: 'i-lucide-check' | 75 | icon: 'i-lucide-check' |
| @@ -90,7 +90,7 @@ const nameOf = (id: string) => props.items.find(i => i.id === id)?.name ?? id | |||
| 90 | <!-- Result --> | 90 | <!-- Result --> |
| 91 | <div v-if="result" class="flex flex-col gap-3"> | 91 | <div v-if="result" class="flex flex-col gap-3"> |
| 92 | <SkNote v-if="result.removed.length" tone="ok"> | 92 | <SkNote v-if="result.removed.length" tone="ok"> |
| 93 | - <span class="font-medium">{{ result.removed.length }} removed from Home</span> | 93 | + <span class="font-medium">{{ result.removed.length }} removed from Global</span> |
| 94 | <span class="block font-mono text-xs text-sk-muted">{{ result.removed.map(nameOf).join(', ') }}</span> | 94 | <span class="block font-mono text-xs text-sk-muted">{{ result.removed.map(nameOf).join(', ') }}</span> |
| 95 | </SkNote> | 95 | </SkNote> |
| 96 | <SkNote v-if="result.imported.length" tone="info"> | 96 | <SkNote v-if="result.imported.length" tone="info"> |
| @@ -134,11 +134,11 @@ const nameOf = (id: string) => props.items.find(i => i.id === id)?.name ?? id | |||
| 134 | </div> | 134 | </div> |
| 135 | </div> | 135 | </div> |
| 136 | 136 | ||
| 137 | - <!-- 3 Home --> | 137 | + <!-- 3 Global --> |
| 138 | <div class="flex gap-3"> | 138 | <div class="flex gap-3"> |
| 139 | <span class="mt-px flex size-[22px] shrink-0 items-center justify-center rounded-full bg-sk-line2 font-mono text-[11px] text-sk-muted">3</span> | 139 | <span class="mt-px flex size-[22px] shrink-0 items-center justify-center rounded-full bg-sk-line2 font-mono text-[11px] text-sk-muted">3</span> |
| 140 | <div class="flex min-w-0 flex-1 flex-col gap-1"> | 140 | <div class="flex min-w-0 flex-1 flex-col gap-1"> |
| 141 | - <span class="font-medium">Remove from Home</span> | 141 | + <span class="font-medium">Remove from Global</span> |
| 142 | <ul class="m-0 flex list-none flex-col gap-0.5 p-0 font-mono text-xs text-sk-muted"> | 142 | <ul class="m-0 flex list-none flex-col gap-0.5 p-0 font-mono text-xs text-sk-muted"> |
| 143 | <li v-for="i in items" :key="i.id" class="break-all"> | 143 | <li v-for="i in items" :key="i.id" class="break-all"> |
| 144 | {{ itemLocation(i, root, '~/') }} <span class="font-sans">will be {{ i.source.symlinkTarget ? 'unlinked (only the link, never its target)' : 'removed' }}</span> | 144 | {{ itemLocation(i, root, '~/') }} <span class="font-sans">will be {{ i.source.symlinkTarget ? 'unlinked (only the link, never its target)' : 'removed' }}</span> |
| @@ -150,7 +150,7 @@ const nameOf = (id: string) => props.items.find(i => i.id === id)?.name ?? id | |||
| 150 | 150 | ||
| 151 | <template #footer> | 151 | <template #footer> |
| 152 | <SkButton :label="result ? 'Close' : 'Cancel'" @click="open = false" /> | 152 | <SkButton :label="result ? 'Close' : 'Cancel'" @click="open = false" /> |
| 153 | - <SkButton v-if="!result" variant="primary" label="Remove from global" :loading="busy" :disabled="!movable.length" @click="submit" /> | 153 | + <SkButton v-if="!result" variant="primary" label="Move to projects" :loading="busy" :disabled="!movable.length" @click="submit" /> |
| 154 | </template> | 154 | </template> |
| 155 | </SkModal> | 155 | </SkModal> |
| 156 | </template> | 156 | </template> |
added
packages/ui/app/components/dashboard/DashboardOverview.vue +29 -0 | new file mode 100644 | ||
| @@ -0,0 +1,29 @@ | ||
| 1 | +<script setup lang="ts"> | |
| 2 | +import type { OverviewCard } from '../../utils/dashboard' | |
| 3 | + | |
| 4 | +/** Dashboard entry points: one card per area (Global, Projects, Catalog, Sync) with its count and state. */ | |
| 5 | +defineProps<{ cards: OverviewCard[] }>() | |
| 6 | +</script> | |
| 7 | + | |
| 8 | +<template> | |
| 9 | + <SkSection title="Overview"> | |
| 10 | + <div class="grid gap-3.5 sm:grid-cols-2 lg:grid-cols-4"> | |
| 11 | + <SkPanel | |
| 12 | + v-for="c in cards" | |
| 13 | + :key="c.id" | |
| 14 | + :as="'NuxtLink'" | |
| 15 | + :to="c.to" | |
| 16 | + interactive | |
| 17 | + padded | |
| 18 | + class="flex flex-col gap-3 text-sk-text hover:text-sk-text" | |
| 19 | + > | |
| 20 | + <SkSectionLabel>{{ c.title }}</SkSectionLabel> | |
| 21 | + <div class="flex flex-col gap-0.5"> | |
| 22 | + <span class="truncate font-mono text-[28px] leading-none">{{ c.value }}</span> | |
| 23 | + <span class="text-xs text-sk-muted">{{ c.caption }}</span> | |
| 24 | + </div> | |
| 25 | + <SkStatus v-if="c.status" :tone="c.status.tone" :label="c.status.label" small class="mt-auto text-sk-muted" /> | |
| 26 | + </SkPanel> | |
| 27 | + </div> | |
| 28 | + </SkSection> | |
| 29 | +</template> | |
| new file mode 100644 | |||
| @@ -0,0 +1,29 @@ | |||
| 1 | +<script setup lang="ts"> | ||
| 2 | +import type { OverviewCard } from '../../utils/dashboard' | ||
| 3 | + | ||
| 4 | +/** Dashboard entry points: one card per area (Global, Projects, Catalog, Sync) with its count and state. */ | ||
| 5 | +defineProps<{ cards: OverviewCard[] }>() | ||
| 6 | +</script> | ||
| 7 | + | ||
| 8 | +<template> | ||
| 9 | + <SkSection title="Overview"> | ||
| 10 | + <div class="grid gap-3.5 sm:grid-cols-2 lg:grid-cols-4"> | ||
| 11 | + <SkPanel | ||
| 12 | + v-for="c in cards" | ||
| 13 | + :key="c.id" | ||
| 14 | + :as="'NuxtLink'" | ||
| 15 | + :to="c.to" | ||
| 16 | + interactive | ||
| 17 | + padded | ||
| 18 | + class="flex flex-col gap-3 text-sk-text hover:text-sk-text" | ||
| 19 | + > | ||
| 20 | + <SkSectionLabel>{{ c.title }}</SkSectionLabel> | ||
| 21 | + <div class="flex flex-col gap-0.5"> | ||
| 22 | + <span class="truncate font-mono text-[28px] leading-none">{{ c.value }}</span> | ||
| 23 | + <span class="text-xs text-sk-muted">{{ c.caption }}</span> | ||
| 24 | + </div> | ||
| 25 | + <SkStatus v-if="c.status" :tone="c.status.tone" :label="c.status.label" small class="mt-auto text-sk-muted" /> | ||
| 26 | + </SkPanel> | ||
| 27 | + </div> | ||
| 28 | + </SkSection> | ||
| 29 | +</template> | ||
added
packages/ui/app/components/dashboard/DashboardRecent.vue +54 -0 | new file mode 100644 | ||
| @@ -0,0 +1,54 @@ | ||
| 1 | +<script setup lang="ts"> | |
| 2 | +import type { ProjectSummary } from '@skilly/core' | |
| 3 | +import type { ScopeSummary } from '../../composables/useScopeSummaries' | |
| 4 | + | |
| 5 | +/** Dashboard "Recent projects": the last projects opened (or the last added), with their sync status. */ | |
| 6 | +const props = defineProps<{ projects: ProjectSummary[], summaries: Record<string, ScopeSummary | null> }>() | |
| 7 | +const addOpen = useAddProjectModal() | |
| 8 | + | |
| 9 | +const MAX = 5 | |
| 10 | +const recent = computed(() => { | |
| 11 | + const order = recentProjectIds() | |
| 12 | + const rank = (p: ProjectSummary) => { const i = order.indexOf(p.id); return i < 0 ? Number.MAX_SAFE_INTEGER : i } | |
| 13 | + return [...props.projects] | |
| 14 | + .sort((a, b) => rank(a) - rank(b) || b.addedAt.localeCompare(a.addedAt)) | |
| 15 | + .slice(0, MAX) | |
| 16 | +}) | |
| 17 | + | |
| 18 | +const COLS = 'minmax(0,1fr) minmax(0,1.2fr)' | |
| 19 | +const statusOf = (p: ProjectSummary) => (p.exists ? props.summaries[p.id]?.status : { tone: 'accent' as const, label: 'Folder missing' }) | |
| 20 | +</script> | |
| 21 | + | |
| 22 | +<template> | |
| 23 | + <SkSection title="Recent projects"> | |
| 24 | + <template #actions> | |
| 25 | + <NuxtLink v-if="projects.length > MAX" to="/projects" class="text-sk-muted hover:text-sk-text">All {{ projects.length }} projects →</NuxtLink> | |
| 26 | + </template> | |
| 27 | + <SkPanel> | |
| 28 | + <SkRow | |
| 29 | + v-for="p in recent" | |
| 30 | + :key="p.id" | |
| 31 | + interactive | |
| 32 | + :cols="COLS" | |
| 33 | + class="py-3" | |
| 34 | + tabindex="0" | |
| 35 | + role="link" | |
| 36 | + :aria-label="`Open ${p.name}`" | |
| 37 | + @click="navigateTo(scopeLink(p.id))" | |
| 38 | + @keydown.enter="navigateTo(scopeLink(p.id))" | |
| 39 | + > | |
| 40 | + <div class="min-w-0"> | |
| 41 | + <div class="truncate font-medium">{{ p.name }}</div> | |
| 42 | + <div class="truncate font-mono text-[11px] text-sk-faint" :title="p.path">{{ p.path }}</div> | |
| 43 | + </div> | |
| 44 | + <SkStatus v-if="statusOf(p)" :tone="statusOf(p)!.tone" :label="statusOf(p)!.label" /> | |
| 45 | + <SkStatus v-else-if="summaries[p.id] === null" tone="faint" label="Unavailable" /> | |
| 46 | + <USkeleton v-else class="h-4 w-20" /> | |
| 47 | + </SkRow> | |
| 48 | + <SkEmpty v-if="!projects.length" title="No project yet"> | |
| 49 | + A project is a folder you work in. skilly shows what Claude Code and Codex load there and writes the items you enable. | |
| 50 | + <template #actions><SkButton variant="primary" label="Add project" @click="addOpen = true" /></template> | |
| 51 | + </SkEmpty> | |
| 52 | + </SkPanel> | |
| 53 | + </SkSection> | |
| 54 | +</template> | |
| new file mode 100644 | |||
| @@ -0,0 +1,54 @@ | |||
| 1 | +<script setup lang="ts"> | ||
| 2 | +import type { ProjectSummary } from '@skilly/core' | ||
| 3 | +import type { ScopeSummary } from '../../composables/useScopeSummaries' | ||
| 4 | + | ||
| 5 | +/** Dashboard "Recent projects": the last projects opened (or the last added), with their sync status. */ | ||
| 6 | +const props = defineProps<{ projects: ProjectSummary[], summaries: Record<string, ScopeSummary | null> }>() | ||
| 7 | +const addOpen = useAddProjectModal() | ||
| 8 | + | ||
| 9 | +const MAX = 5 | ||
| 10 | +const recent = computed(() => { | ||
| 11 | + const order = recentProjectIds() | ||
| 12 | + const rank = (p: ProjectSummary) => { const i = order.indexOf(p.id); return i < 0 ? Number.MAX_SAFE_INTEGER : i } | ||
| 13 | + return [...props.projects] | ||
| 14 | + .sort((a, b) => rank(a) - rank(b) || b.addedAt.localeCompare(a.addedAt)) | ||
| 15 | + .slice(0, MAX) | ||
| 16 | +}) | ||
| 17 | + | ||
| 18 | +const COLS = 'minmax(0,1fr) minmax(0,1.2fr)' | ||
| 19 | +const statusOf = (p: ProjectSummary) => (p.exists ? props.summaries[p.id]?.status : { tone: 'accent' as const, label: 'Folder missing' }) | ||
| 20 | +</script> | ||
| 21 | + | ||
| 22 | +<template> | ||
| 23 | + <SkSection title="Recent projects"> | ||
| 24 | + <template #actions> | ||
| 25 | + <NuxtLink v-if="projects.length > MAX" to="/projects" class="text-sk-muted hover:text-sk-text">All {{ projects.length }} projects →</NuxtLink> | ||
| 26 | + </template> | ||
| 27 | + <SkPanel> | ||
| 28 | + <SkRow | ||
| 29 | + v-for="p in recent" | ||
| 30 | + :key="p.id" | ||
| 31 | + interactive | ||
| 32 | + :cols="COLS" | ||
| 33 | + class="py-3" | ||
| 34 | + tabindex="0" | ||
| 35 | + role="link" | ||
| 36 | + :aria-label="`Open ${p.name}`" | ||
| 37 | + @click="navigateTo(scopeLink(p.id))" | ||
| 38 | + @keydown.enter="navigateTo(scopeLink(p.id))" | ||
| 39 | + > | ||
| 40 | + <div class="min-w-0"> | ||
| 41 | + <div class="truncate font-medium">{{ p.name }}</div> | ||
| 42 | + <div class="truncate font-mono text-[11px] text-sk-faint" :title="p.path">{{ p.path }}</div> | ||
| 43 | + </div> | ||
| 44 | + <SkStatus v-if="statusOf(p)" :tone="statusOf(p)!.tone" :label="statusOf(p)!.label" /> | ||
| 45 | + <SkStatus v-else-if="summaries[p.id] === null" tone="faint" label="Unavailable" /> | ||
| 46 | + <USkeleton v-else class="h-4 w-20" /> | ||
| 47 | + </SkRow> | ||
| 48 | + <SkEmpty v-if="!projects.length" title="No project yet"> | ||
| 49 | + A project is a folder you work in. skilly shows what Claude Code and Codex load there and writes the items you enable. | ||
| 50 | + <template #actions><SkButton variant="primary" label="Add project" @click="addOpen = true" /></template> | ||
| 51 | + </SkEmpty> | ||
| 52 | + </SkPanel> | ||
| 53 | + </SkSection> | ||
| 54 | +</template> | ||
added
packages/ui/app/components/dashboard/DashboardStart.vue +44 -0 | new file mode 100644 | ||
| @@ -0,0 +1,44 @@ | ||
| 1 | +<script setup lang="ts"> | |
| 2 | +import type { DashboardAction, StartStep } from '../../utils/dashboard' | |
| 3 | + | |
| 4 | +/** Dashboard "Getting started" checklist. Shown until every step is done or the user hides it. */ | |
| 5 | +const props = defineProps<{ steps: StartStep[] }>() | |
| 6 | +defineEmits<{ run: [action: DashboardAction], hide: [] }>() | |
| 7 | + | |
| 8 | +const doneCount = computed(() => props.steps.filter(s => s.done).length) | |
| 9 | +const nextId = computed(() => props.steps.find(s => !s.done)?.id) | |
| 10 | +</script> | |
| 11 | + | |
| 12 | +<template> | |
| 13 | + <SkPanel hero padded class="flex flex-col gap-4"> | |
| 14 | + <SkStepHeader title="Getting started" :hint="`${doneCount} of ${steps.length} done`"> | |
| 15 | + <template #actions> | |
| 16 | + <SkButton variant="ghost" size="sm" label="Hide" @click="$emit('hide')" /> | |
| 17 | + </template> | |
| 18 | + </SkStepHeader> | |
| 19 | + <ol class="m-0 flex list-none flex-col p-0"> | |
| 20 | + <li | |
| 21 | + v-for="(s, n) in steps" | |
| 22 | + :key="s.id" | |
| 23 | + class="flex items-center gap-3 border-t border-sk-line2 py-3 first:border-t-0 first:pt-0 last:pb-0" | |
| 24 | + > | |
| 25 | + <UIcon v-if="s.done" name="i-lucide-circle-check" class="size-[22px] shrink-0 text-sk-ok" aria-label="Done" /> | |
| 26 | + <span v-else class="flex size-[22px] shrink-0 items-center justify-center rounded-full bg-sk-line2 font-mono text-[11px] text-sk-muted">{{ n + 1 }}</span> | |
| 27 | + <div class="min-w-0 flex-1"> | |
| 28 | + <div class="font-medium" :class="s.done && 'text-sk-muted line-through decoration-sk-faint'">{{ s.title }}</div> | |
| 29 | + <div v-if="!s.done" class="text-pretty text-xs text-sk-muted">{{ s.hint }}</div> | |
| 30 | + </div> | |
| 31 | + <template v-if="!s.done"> | |
| 32 | + <SkButton | |
| 33 | + v-if="s.action.run" | |
| 34 | + size="sm" | |
| 35 | + :variant="s.id === nextId ? 'primary' : 'secondary'" | |
| 36 | + :label="s.action.label" | |
| 37 | + @click="$emit('run', s.action)" | |
| 38 | + /> | |
| 39 | + <SkButton v-else size="sm" :variant="s.id === nextId ? 'primary' : 'secondary'" :label="s.action.label" :to="s.action.to" /> | |
| 40 | + </template> | |
| 41 | + </li> | |
| 42 | + </ol> | |
| 43 | + </SkPanel> | |
| 44 | +</template> | |
| new file mode 100644 | |||
| @@ -0,0 +1,44 @@ | |||
| 1 | +<script setup lang="ts"> | ||
| 2 | +import type { DashboardAction, StartStep } from '../../utils/dashboard' | ||
| 3 | + | ||
| 4 | +/** Dashboard "Getting started" checklist. Shown until every step is done or the user hides it. */ | ||
| 5 | +const props = defineProps<{ steps: StartStep[] }>() | ||
| 6 | +defineEmits<{ run: [action: DashboardAction], hide: [] }>() | ||
| 7 | + | ||
| 8 | +const doneCount = computed(() => props.steps.filter(s => s.done).length) | ||
| 9 | +const nextId = computed(() => props.steps.find(s => !s.done)?.id) | ||
| 10 | +</script> | ||
| 11 | + | ||
| 12 | +<template> | ||
| 13 | + <SkPanel hero padded class="flex flex-col gap-4"> | ||
| 14 | + <SkStepHeader title="Getting started" :hint="`${doneCount} of ${steps.length} done`"> | ||
| 15 | + <template #actions> | ||
| 16 | + <SkButton variant="ghost" size="sm" label="Hide" @click="$emit('hide')" /> | ||
| 17 | + </template> | ||
| 18 | + </SkStepHeader> | ||
| 19 | + <ol class="m-0 flex list-none flex-col p-0"> | ||
| 20 | + <li | ||
| 21 | + v-for="(s, n) in steps" | ||
| 22 | + :key="s.id" | ||
| 23 | + class="flex items-center gap-3 border-t border-sk-line2 py-3 first:border-t-0 first:pt-0 last:pb-0" | ||
| 24 | + > | ||
| 25 | + <UIcon v-if="s.done" name="i-lucide-circle-check" class="size-[22px] shrink-0 text-sk-ok" aria-label="Done" /> | ||
| 26 | + <span v-else class="flex size-[22px] shrink-0 items-center justify-center rounded-full bg-sk-line2 font-mono text-[11px] text-sk-muted">{{ n + 1 }}</span> | ||
| 27 | + <div class="min-w-0 flex-1"> | ||
| 28 | + <div class="font-medium" :class="s.done && 'text-sk-muted line-through decoration-sk-faint'">{{ s.title }}</div> | ||
| 29 | + <div v-if="!s.done" class="text-pretty text-xs text-sk-muted">{{ s.hint }}</div> | ||
| 30 | + </div> | ||
| 31 | + <template v-if="!s.done"> | ||
| 32 | + <SkButton | ||
| 33 | + v-if="s.action.run" | ||
| 34 | + size="sm" | ||
| 35 | + :variant="s.id === nextId ? 'primary' : 'secondary'" | ||
| 36 | + :label="s.action.label" | ||
| 37 | + @click="$emit('run', s.action)" | ||
| 38 | + /> | ||
| 39 | + <SkButton v-else size="sm" :variant="s.id === nextId ? 'primary' : 'secondary'" :label="s.action.label" :to="s.action.to" /> | ||
| 40 | + </template> | ||
| 41 | + </li> | ||
| 42 | + </ol> | ||
| 43 | + </SkPanel> | ||
| 44 | +</template> | ||
added
packages/ui/app/components/dashboard/DashboardTodo.vue +39 -0 | new file mode 100644 | ||
| @@ -0,0 +1,39 @@ | ||
| 1 | +<script setup lang="ts"> | |
| 2 | +import type { DashboardAction, DashboardTodo } from '../../utils/dashboard' | |
| 3 | + | |
| 4 | +/** Dashboard "To do now": one row per scope or catalog issue, with the action that fixes it. */ | |
| 5 | +defineProps<{ todos: DashboardTodo[], scanning: boolean, syncing: boolean }>() | |
| 6 | +defineEmits<{ run: [action: DashboardAction] }>() | |
| 7 | + | |
| 8 | +const COLS = '16px minmax(0,160px) minmax(0,1fr) auto' | |
| 9 | +</script> | |
| 10 | + | |
| 11 | +<template> | |
| 12 | + <SkSection title="To do now" hint="Changes to apply, conflicts and housekeeping across Global, your projects and the catalog."> | |
| 13 | + <SkPanel v-if="todos.length"> | |
| 14 | + <SkRow v-for="t in todos" :key="t.id" :cols="COLS" class="py-3"> | |
| 15 | + <SkStatusDot :tone="t.tone" /> | |
| 16 | + <span class="truncate font-medium" :title="t.title">{{ t.title }}</span> | |
| 17 | + <span class="truncate text-sk-muted" :title="t.detail">{{ t.detail }}</span> | |
| 18 | + <SkButton | |
| 19 | + v-if="t.action.run" | |
| 20 | + size="sm" | |
| 21 | + :label="t.action.label" | |
| 22 | + :loading="t.action.run === 'sync' && syncing" | |
| 23 | + @click="$emit('run', t.action)" | |
| 24 | + /> | |
| 25 | + <SkButton v-else size="sm" :label="t.action.label" :to="t.action.to" /> | |
| 26 | + </SkRow> | |
| 27 | + <div v-if="scanning" class="flex items-center gap-2 border-t border-sk-line2 px-4 py-2.5 text-xs text-sk-faint"> | |
| 28 | + <UIcon name="i-lucide-loader-circle" class="size-3.5 animate-spin" /> Checking the other projects… | |
| 29 | + </div> | |
| 30 | + </SkPanel> | |
| 31 | + <SkPanel v-else-if="scanning" padded class="flex flex-col gap-2"> | |
| 32 | + <USkeleton class="h-5 w-2/3" /> | |
| 33 | + <USkeleton class="h-5 w-1/2" /> | |
| 34 | + </SkPanel> | |
| 35 | + <SkNote v-else tone="ok"> | |
| 36 | + <span class="font-medium">Nothing to do.</span> Every managed project and Global match the catalog, and the catalog is in sync. | |
| 37 | + </SkNote> | |
| 38 | + </SkSection> | |
| 39 | +</template> | |
| new file mode 100644 | |||
| @@ -0,0 +1,39 @@ | |||
| 1 | +<script setup lang="ts"> | ||
| 2 | +import type { DashboardAction, DashboardTodo } from '../../utils/dashboard' | ||
| 3 | + | ||
| 4 | +/** Dashboard "To do now": one row per scope or catalog issue, with the action that fixes it. */ | ||
| 5 | +defineProps<{ todos: DashboardTodo[], scanning: boolean, syncing: boolean }>() | ||
| 6 | +defineEmits<{ run: [action: DashboardAction] }>() | ||
| 7 | + | ||
| 8 | +const COLS = '16px minmax(0,160px) minmax(0,1fr) auto' | ||
| 9 | +</script> | ||
| 10 | + | ||
| 11 | +<template> | ||
| 12 | + <SkSection title="To do now" hint="Changes to apply, conflicts and housekeeping across Global, your projects and the catalog."> | ||
| 13 | + <SkPanel v-if="todos.length"> | ||
| 14 | + <SkRow v-for="t in todos" :key="t.id" :cols="COLS" class="py-3"> | ||
| 15 | + <SkStatusDot :tone="t.tone" /> | ||
| 16 | + <span class="truncate font-medium" :title="t.title">{{ t.title }}</span> | ||
| 17 | + <span class="truncate text-sk-muted" :title="t.detail">{{ t.detail }}</span> | ||
| 18 | + <SkButton | ||
| 19 | + v-if="t.action.run" | ||
| 20 | + size="sm" | ||
| 21 | + :label="t.action.label" | ||
| 22 | + :loading="t.action.run === 'sync' && syncing" | ||
| 23 | + @click="$emit('run', t.action)" | ||
| 24 | + /> | ||
| 25 | + <SkButton v-else size="sm" :label="t.action.label" :to="t.action.to" /> | ||
| 26 | + </SkRow> | ||
| 27 | + <div v-if="scanning" class="flex items-center gap-2 border-t border-sk-line2 px-4 py-2.5 text-xs text-sk-faint"> | ||
| 28 | + <UIcon name="i-lucide-loader-circle" class="size-3.5 animate-spin" /> Checking the other projects… | ||
| 29 | + </div> | ||
| 30 | + </SkPanel> | ||
| 31 | + <SkPanel v-else-if="scanning" padded class="flex flex-col gap-2"> | ||
| 32 | + <USkeleton class="h-5 w-2/3" /> | ||
| 33 | + <USkeleton class="h-5 w-1/2" /> | ||
| 34 | + </SkPanel> | ||
| 35 | + <SkNote v-else tone="ok"> | ||
| 36 | + <span class="font-medium">Nothing to do.</span> Every managed project and Global match the catalog, and the catalog is in sync. | ||
| 37 | + </SkNote> | ||
| 38 | + </SkSection> | ||
| 39 | +</template> | ||
deleted
packages/ui/app/components/home/HomeInventory.vue +0 -95 | deleted file mode 100644 | ||
| @@ -1,95 +0,0 @@ | ||
| 1 | -<script setup lang="ts"> | |
| 2 | -import type { InventoryItem } from '@skilly/core' | |
| 3 | -import type { ProjectSpecific } from '../../utils/inventoryView' | |
| 4 | - | |
| 5 | -/** | |
| 6 | - * Home "Inventory" tab (mockup 6): "1 Loaded everywhere" hero tile and per-directory counts on the left, the | |
| 7 | - * inventory tray on the right, then the "project-specific" suggestion panel below. | |
| 8 | - */ | |
| 9 | -const scope = useScopeContext() | |
| 10 | -const catalog = useCatalog() | |
| 11 | -const { projects } = useProjects() | |
| 12 | - | |
| 13 | -const inventory = computed(() => scope.inventory.value!) | |
| 14 | -const items = computed(() => inventory.value.items) | |
| 15 | -const everywhere = computed(() => items.value.filter(i => i.type !== 'instructions')) | |
| 16 | - | |
| 17 | -const directories = computed(() => { | |
| 18 | - const counts = new Map<string, number>() | |
| 19 | - for (const i of everywhere.value) { | |
| 20 | - const d = homeDirOf(i, inventory.value.root) | |
| 21 | - counts.set(d, (counts.get(d) ?? 0) + 1) | |
| 22 | - } | |
| 23 | - return [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])) | |
| 24 | -}) | |
| 25 | - | |
| 26 | -/** Items that look project-specific (brief 4.5), by inventory item id. */ | |
| 27 | -const flagged = computed(() => { | |
| 28 | - const m = new Map<string, ProjectSpecific>() | |
| 29 | - for (const i of items.value) { | |
| 30 | - const p = projectSpecific(i, scope.statuses.value[i.id], catalog.usage.value, projects.value) | |
| 31 | - if (p && canMoveOutOfHome(i, scope.statuses.value[i.id])) m.set(i.id, p) | |
| 32 | - } | |
| 33 | - return m | |
| 34 | -}) | |
| 35 | - | |
| 36 | -const reviewOnly = ref(false) | |
| 37 | -const onlyIds = computed(() => (reviewOnly.value ? new Set(flagged.value.keys()) : null)) | |
| 38 | -watch(() => flagged.value.size, (n) => { if (!n) reviewOnly.value = false }) | |
| 39 | - | |
| 40 | -const errorsOpen = ref(false) | |
| 41 | -function importOne(i: InventoryItem) { scope.askImport([i]) } | |
| 42 | -</script> | |
| 43 | - | |
| 44 | -<template> | |
| 45 | - <div class="flex flex-col gap-4"> | |
| 46 | - <SkPanel hero> | |
| 47 | - <div class="grid lg:grid-cols-[280px_minmax(0,1fr)]"> | |
| 48 | - <div class="flex flex-col gap-4 p-[22px] lg:border-r lg:border-sk-line2"> | |
| 49 | - <SkStepHeader step="1" title="Loaded everywhere" /> | |
| 50 | - <SkDitherTile variant="hero" :height="240" :value="everywhere.length" :caption="everywhere.length === 1 ? 'item in every project' : 'items in every project'" /> | |
| 51 | - <ul class="m-0 flex list-none flex-col gap-1.5 p-0 font-mono text-xs"> | |
| 52 | - <li v-for="[dir, n] in directories" :key="dir" class="flex justify-between text-sk-muted"> | |
| 53 | - <span>{{ dir }}</span><span class="text-sk-faint">{{ n }}</span> | |
| 54 | - </li> | |
| 55 | - <li v-if="!directories.length" class="text-sk-faint">Nothing is loaded from Home.</li> | |
| 56 | - </ul> | |
| 57 | - </div> | |
| 58 | - | |
| 59 | - <div class="min-w-0 p-[22px]"> | |
| 60 | - <ScopeInventoryList | |
| 61 | - :items="items" | |
| 62 | - :statuses="scope.statuses.value" | |
| 63 | - :root="inventory.root" | |
| 64 | - mode="home" | |
| 65 | - :flagged="flagged" | |
| 66 | - :only-ids="onlyIds" | |
| 67 | - @select="scope.openItem" | |
| 68 | - @move="(i: InventoryItem) => scope.askMove([i])" | |
| 69 | - @import="importOne" | |
| 70 | - > | |
| 71 | - <template #top> | |
| 72 | - <SkNote v-if="inventory.errors.length" tone="accent" class="mb-1"> | |
| 73 | - <button type="button" class="cursor-pointer border-0 bg-transparent p-0 text-left font-semibold text-inherit" @click="errorsOpen = !errorsOpen"> | |
| 74 | - {{ inventory.errors.length }} item{{ inventory.errors.length > 1 ? 's' : '' }} could not be read | |
| 75 | - <span class="font-normal opacity-80">· {{ errorsOpen ? 'hide' : 'show' }} details</span> | |
| 76 | - </button> | |
| 77 | - <ScopeScanErrors v-if="errorsOpen" :errors="inventory.errors" :root="inventory.root" /> | |
| 78 | - </SkNote> | |
| 79 | - </template> | |
| 80 | - </ScopeInventoryList> | |
| 81 | - </div> | |
| 82 | - </div> | |
| 83 | - </SkPanel> | |
| 84 | - | |
| 85 | - <HomeSuggestions | |
| 86 | - v-if="flagged.size" | |
| 87 | - :items="items.filter(i => flagged.has(i.id))" | |
| 88 | - :flagged="flagged" | |
| 89 | - :statuses="scope.statuses.value" | |
| 90 | - :review="reviewOnly" | |
| 91 | - @toggle-review="reviewOnly = !reviewOnly" | |
| 92 | - @move="(i: InventoryItem) => scope.askMove([i])" | |
| 93 | - /> | |
| 94 | - </div> | |
| 95 | -</template> | |
| deleted file mode 100644 | |||
| @@ -1,95 +0,0 @@ | |||
| 1 | -<script setup lang="ts"> | ||
| 2 | -import type { InventoryItem } from '@skilly/core' | ||
| 3 | -import type { ProjectSpecific } from '../../utils/inventoryView' | ||
| 4 | - | ||
| 5 | -/** | ||
| 6 | - * Home "Inventory" tab (mockup 6): "1 Loaded everywhere" hero tile and per-directory counts on the left, the | ||
| 7 | - * inventory tray on the right, then the "project-specific" suggestion panel below. | ||
| 8 | - */ | ||
| 9 | -const scope = useScopeContext() | ||
| 10 | -const catalog = useCatalog() | ||
| 11 | -const { projects } = useProjects() | ||
| 12 | - | ||
| 13 | -const inventory = computed(() => scope.inventory.value!) | ||
| 14 | -const items = computed(() => inventory.value.items) | ||
| 15 | -const everywhere = computed(() => items.value.filter(i => i.type !== 'instructions')) | ||
| 16 | - | ||
| 17 | -const directories = computed(() => { | ||
| 18 | - const counts = new Map<string, number>() | ||
| 19 | - for (const i of everywhere.value) { | ||
| 20 | - const d = homeDirOf(i, inventory.value.root) | ||
| 21 | - counts.set(d, (counts.get(d) ?? 0) + 1) | ||
| 22 | - } | ||
| 23 | - return [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])) | ||
| 24 | -}) | ||
| 25 | - | ||
| 26 | -/** Items that look project-specific (brief 4.5), by inventory item id. */ | ||
| 27 | -const flagged = computed(() => { | ||
| 28 | - const m = new Map<string, ProjectSpecific>() | ||
| 29 | - for (const i of items.value) { | ||
| 30 | - const p = projectSpecific(i, scope.statuses.value[i.id], catalog.usage.value, projects.value) | ||
| 31 | - if (p && canMoveOutOfHome(i, scope.statuses.value[i.id])) m.set(i.id, p) | ||
| 32 | - } | ||
| 33 | - return m | ||
| 34 | -}) | ||
| 35 | - | ||
| 36 | -const reviewOnly = ref(false) | ||
| 37 | -const onlyIds = computed(() => (reviewOnly.value ? new Set(flagged.value.keys()) : null)) | ||
| 38 | -watch(() => flagged.value.size, (n) => { if (!n) reviewOnly.value = false }) | ||
| 39 | - | ||
| 40 | -const errorsOpen = ref(false) | ||
| 41 | -function importOne(i: InventoryItem) { scope.askImport([i]) } | ||
| 42 | -</script> | ||
| 43 | - | ||
| 44 | -<template> | ||
| 45 | - <div class="flex flex-col gap-4"> | ||
| 46 | - <SkPanel hero> | ||
| 47 | - <div class="grid lg:grid-cols-[280px_minmax(0,1fr)]"> | ||
| 48 | - <div class="flex flex-col gap-4 p-[22px] lg:border-r lg:border-sk-line2"> | ||
| 49 | - <SkStepHeader step="1" title="Loaded everywhere" /> | ||
| 50 | - <SkDitherTile variant="hero" :height="240" :value="everywhere.length" :caption="everywhere.length === 1 ? 'item in every project' : 'items in every project'" /> | ||
| 51 | - <ul class="m-0 flex list-none flex-col gap-1.5 p-0 font-mono text-xs"> | ||
| 52 | - <li v-for="[dir, n] in directories" :key="dir" class="flex justify-between text-sk-muted"> | ||
| 53 | - <span>{{ dir }}</span><span class="text-sk-faint">{{ n }}</span> | ||
| 54 | - </li> | ||
| 55 | - <li v-if="!directories.length" class="text-sk-faint">Nothing is loaded from Home.</li> | ||
| 56 | - </ul> | ||
| 57 | - </div> | ||
| 58 | - | ||
| 59 | - <div class="min-w-0 p-[22px]"> | ||
| 60 | - <ScopeInventoryList | ||
| 61 | - :items="items" | ||
| 62 | - :statuses="scope.statuses.value" | ||
| 63 | - :root="inventory.root" | ||
| 64 | - mode="home" | ||
| 65 | - :flagged="flagged" | ||
| 66 | - :only-ids="onlyIds" | ||
| 67 | - @select="scope.openItem" | ||
| 68 | - @move="(i: InventoryItem) => scope.askMove([i])" | ||
| 69 | - @import="importOne" | ||
| 70 | - > | ||
| 71 | - <template #top> | ||
| 72 | - <SkNote v-if="inventory.errors.length" tone="accent" class="mb-1"> | ||
| 73 | - <button type="button" class="cursor-pointer border-0 bg-transparent p-0 text-left font-semibold text-inherit" @click="errorsOpen = !errorsOpen"> | ||
| 74 | - {{ inventory.errors.length }} item{{ inventory.errors.length > 1 ? 's' : '' }} could not be read | ||
| 75 | - <span class="font-normal opacity-80">· {{ errorsOpen ? 'hide' : 'show' }} details</span> | ||
| 76 | - </button> | ||
| 77 | - <ScopeScanErrors v-if="errorsOpen" :errors="inventory.errors" :root="inventory.root" /> | ||
| 78 | - </SkNote> | ||
| 79 | - </template> | ||
| 80 | - </ScopeInventoryList> | ||
| 81 | - </div> | ||
| 82 | - </div> | ||
| 83 | - </SkPanel> | ||
| 84 | - | ||
| 85 | - <HomeSuggestions | ||
| 86 | - v-if="flagged.size" | ||
| 87 | - :items="items.filter(i => flagged.has(i.id))" | ||
| 88 | - :flagged="flagged" | ||
| 89 | - :statuses="scope.statuses.value" | ||
| 90 | - :review="reviewOnly" | ||
| 91 | - @toggle-review="reviewOnly = !reviewOnly" | ||
| 92 | - @move="(i: InventoryItem) => scope.askMove([i])" | ||
| 93 | - /> | ||
| 94 | - </div> | ||
| 95 | -</template> | ||
modified
packages/ui/app/components/home/HomeSuggestions.vue +3 -2 | @@ -3,7 +3,8 @@ import type { InventoryItem, ItemStatus } from '@skilly/core' | ||
| 3 | 3 | import type { ProjectSpecific } from '../../utils/inventoryView' |
| 4 | 4 | |
| 5 | 5 | /** |
| 6 | - * "2 · N global items look project-specific" (mockup 6): gradient tiles that open the Move modal. | |
| 6 | + * "To tidy up" (mockup 6): global items only used in some projects, as gradient tiles that open the | |
| 7 | + * "Move to projects" modal. | |
| 7 | 8 | * Computed client side from catalog usage (brief 4.5); nothing is ever moved automatically. |
| 8 | 9 | */ |
| 9 | 10 | defineProps<{ |
| @@ -20,7 +21,7 @@ const catalogLabel = (st?: ItemStatus) => (st?.state === 'in-sync' ? 'in catalog | ||
| 20 | 21 | |
| 21 | 22 | <template> |
| 22 | 23 | <SkPanel hero padded class="flex flex-col gap-4"> |
| 23 | - <SkStepHeader step="2" :title="`${items.length} global item${items.length === 1 ? '' : 's'} look${items.length === 1 ? 's' : ''} project-specific`" hint="Move them to the catalog and enable them only where they are needed."> | |
| 24 | + <SkStepHeader :title="`To tidy up: ${items.length} item${items.length === 1 ? ' is' : 's are'} only used in some projects`" hint="They are loaded in every session anyway. Move them to the projects that use them: the other projects stop paying for them in context."> | |
| 24 | 25 | <template #actions> |
| 25 | 26 | <SkButton variant="secondary" pill :label="review ? 'Show all' : 'Review'" @click="$emit('toggleReview')" /> |
| 26 | 27 | </template> |
| @@ -3,7 +3,8 @@ import type { InventoryItem, ItemStatus } from '@skilly/core' | |||
| 3 | import type { ProjectSpecific } from '../../utils/inventoryView' | 3 | import type { ProjectSpecific } from '../../utils/inventoryView' |
| 4 | 4 | ||
| 5 | /** | 5 | /** |
| 6 | - * "2 · N global items look project-specific" (mockup 6): gradient tiles that open the Move modal. | 6 | + * "To tidy up" (mockup 6): global items only used in some projects, as gradient tiles that open the |
| 7 | + * "Move to projects" modal. | ||
| 7 | * Computed client side from catalog usage (brief 4.5); nothing is ever moved automatically. | 8 | * Computed client side from catalog usage (brief 4.5); nothing is ever moved automatically. |
| 8 | */ | 9 | */ |
| 9 | defineProps<{ | 10 | defineProps<{ |
| @@ -20,7 +21,7 @@ const catalogLabel = (st?: ItemStatus) => (st?.state === 'in-sync' ? 'in catalog | |||
| 20 | 21 | ||
| 21 | <template> | 22 | <template> |
| 22 | <SkPanel hero padded class="flex flex-col gap-4"> | 23 | <SkPanel hero padded class="flex flex-col gap-4"> |
| 23 | - <SkStepHeader step="2" :title="`${items.length} global item${items.length === 1 ? '' : 's'} look${items.length === 1 ? 's' : ''} project-specific`" hint="Move them to the catalog and enable them only where they are needed."> | 24 | + <SkStepHeader :title="`To tidy up: ${items.length} item${items.length === 1 ? ' is' : 's are'} only used in some projects`" hint="They are loaded in every session anyway. Move them to the projects that use them: the other projects stop paying for them in context."> |
| 24 | <template #actions> | 25 | <template #actions> |
| 25 | <SkButton variant="secondary" pill :label="review ? 'Show all' : 'Review'" @click="$emit('toggleReview')" /> | 26 | <SkButton variant="secondary" pill :label="review ? 'Show all' : 'Review'" @click="$emit('toggleReview')" /> |
| 26 | </template> | 27 | </template> |
modified
packages/ui/app/components/projects/ProjectsTable.vue +11 -49 | @@ -1,56 +1,15 @@ | ||
| 1 | 1 | <script setup lang="ts"> |
| 2 | -import type { ProjectSummary, ScopeState } from '@skilly/core' | |
| 2 | +import type { ProjectSummary } from '@skilly/core' | |
| 3 | 3 | import type { Tone } from '../../utils/status' |
| 4 | 4 | |
| 5 | 5 | /** |
| 6 | - * Projects table (mockup 8): a pinned Home row, then one row per project with agents, presets, item count and | |
| 7 | - * sync status. Status needs a scan of each scope: it is fetched lazily, three at a time, and tolerates failures. | |
| 6 | + * Projects table (mockup 8): a pinned Global row (the user-level scope, `~`), then one row per project with agents, | |
| 7 | + * presets, item count and sync status. Statuses come from `useScopeSummaries` (lazy scan of each scope). | |
| 8 | 8 | */ |
| 9 | 9 | const { projects, loaded, error: projectsError, refresh: refreshProjects } = useProjects() |
| 10 | -const { request } = useApi() | |
| 11 | 10 | const catalog = useCatalog() |
| 12 | 11 | const addOpen = useAddProjectModal() |
| 13 | - | |
| 14 | -interface Summary { | |
| 15 | - managed: boolean | |
| 16 | - agents: string | |
| 17 | - presets: string[] | |
| 18 | - items: number | |
| 19 | - status: { tone: Tone, label: string } | |
| 20 | - root?: string | |
| 21 | -} | |
| 22 | -const summaries = useState<Record<string, Summary | null>>('skilly-scope-summaries', () => ({})) | |
| 23 | - | |
| 24 | -function summarize(s: ScopeState, fallbackAgents: string): Summary { | |
| 25 | - const cfg = s.config | |
| 26 | - const inv = s.inventory | |
| 27 | - const nonInstr = inv.items.filter(i => i.type !== 'instructions') | |
| 28 | - let items = nonInstr.length | |
| 29 | - if (cfg) { | |
| 30 | - const entries = resolveItems(catalog.presets.value, cfg.presets, cfg.add).entries | |
| 31 | - items = entries.filter(e => !matchIn(cfg.exclude[e.type], e.name)).length | |
| 32 | - } | |
| 33 | - const targets = cfg?.targets ?? inv.agentsDetected | |
| 34 | - const agents = targets.length ? targets.map(a => AGENT_LABELS[a]).join(', ') : fallbackAgents | |
| 35 | - const status = !cfg ? { tone: 'faint' as Tone, label: 'Not managed' } : summarizePlan(s.plan) | |
| 36 | - return { managed: !!cfg, agents, presets: cfg?.presets ?? [], items, status, root: inv.root } | |
| 37 | -} | |
| 38 | - | |
| 39 | -async function loadSummaries() { | |
| 40 | - const ids = ['home', ...projects.value.filter(p => p.exists).map(p => p.id)] | |
| 41 | - const queue = [...ids] | |
| 42 | - const worker = async () => { | |
| 43 | - for (let id = queue.shift(); id; id = queue.shift()) { | |
| 44 | - try { | |
| 45 | - const s = await request<ScopeState>(`/api/scopes/${encodeURIComponent(id)}/state`, { silent: true }) | |
| 46 | - summaries.value = { ...summaries.value, [id]: summarize(s, '—') } | |
| 47 | - } catch { | |
| 48 | - summaries.value = { ...summaries.value, [id]: null } | |
| 49 | - } | |
| 50 | - } | |
| 51 | - } | |
| 52 | - await Promise.all([worker(), worker(), worker()]) | |
| 53 | -} | |
| 12 | +const { summaries, load: loadSummaries } = useScopeSummaries() | |
| 54 | 13 | |
| 55 | 14 | onMounted(async () => { |
| 56 | 15 | if (!catalog.data.value) catalog.refresh() |
| @@ -63,7 +22,7 @@ onMounted(async () => { | ||
| 63 | 22 | const COLS = 'minmax(0,2.2fr) minmax(0,1.3fr) minmax(0,1.4fr) 64px minmax(0,1.5fr)' |
| 64 | 23 | |
| 65 | 24 | function go(id: string) { |
| 66 | - navigateTo(id === 'home' ? '/home' : `/projects/${encodeURIComponent(id)}`) | |
| 25 | + navigateTo(scopeLink(id)) | |
| 67 | 26 | } |
| 68 | 27 | |
| 69 | 28 | const rowStatus = (p: ProjectSummary): { tone: Tone, label: string } | null => { |
| @@ -81,12 +40,15 @@ const countOf = (p: ProjectSummary) => !p.exists ? '—' : summaries.value[p.id] | ||
| 81 | 40 | <span>Project</span><span>Agents</span><span>Presets</span><span>Items</span><span>Status</span> |
| 82 | 41 | </SkRow> |
| 83 | 42 | |
| 84 | - <!-- Home, pinned --> | |
| 85 | - <SkRow interactive :cols="COLS" class="py-3" tabindex="0" role="link" aria-label="Open Home" @click="go('home')" @keydown.enter="go('home')"> | |
| 43 | + <!-- Global (Home scope), pinned --> | |
| 44 | + <SkRow interactive :cols="COLS" class="py-3" tabindex="0" role="link" aria-label="Open Global" @click="go('home')" @keydown.enter="go('home')"> | |
| 86 | 45 | <div class="flex min-w-0 items-center gap-2.5"> |
| 87 | 46 | <SkAvatar :size="28" /> |
| 88 | 47 | <div class="min-w-0"> |
| 89 | - <div class="truncate font-medium">Home · user-level config</div> | |
| 48 | + <div class="flex min-w-0 items-baseline gap-2"> | |
| 49 | + <span class="truncate font-medium">Global</span> | |
| 50 | + <span class="shrink-0 text-[11.5px] text-sk-muted">loaded in every project</span> | |
| 51 | + </div> | |
| 90 | 52 | <div class="truncate font-mono text-[11px] text-sk-faint">{{ summaries.home?.root ?? '~' }}</div> |
| 91 | 53 | </div> |
| 92 | 54 | </div> |
| @@ -1,56 +1,15 @@ | |||
| 1 | <script setup lang="ts"> | 1 | <script setup lang="ts"> |
| 2 | -import type { ProjectSummary, ScopeState } from '@skilly/core' | 2 | +import type { ProjectSummary } from '@skilly/core' |
| 3 | import type { Tone } from '../../utils/status' | 3 | import type { Tone } from '../../utils/status' |
| 4 | 4 | ||
| 5 | /** | 5 | /** |
| 6 | - * Projects table (mockup 8): a pinned Home row, then one row per project with agents, presets, item count and | 6 | + * Projects table (mockup 8): a pinned Global row (the user-level scope, `~`), then one row per project with agents, |
| 7 | - * sync status. Status needs a scan of each scope: it is fetched lazily, three at a time, and tolerates failures. | 7 | + * presets, item count and sync status. Statuses come from `useScopeSummaries` (lazy scan of each scope). |
| 8 | */ | 8 | */ |
| 9 | const { projects, loaded, error: projectsError, refresh: refreshProjects } = useProjects() | 9 | const { projects, loaded, error: projectsError, refresh: refreshProjects } = useProjects() |
| 10 | -const { request } = useApi() | ||
| 11 | const catalog = useCatalog() | 10 | const catalog = useCatalog() |
| 12 | const addOpen = useAddProjectModal() | 11 | const addOpen = useAddProjectModal() |
| 13 | - | 12 | +const { summaries, load: loadSummaries } = useScopeSummaries() |
| 14 | -interface Summary { | ||
| 15 | - managed: boolean | ||
| 16 | - agents: string | ||
| 17 | - presets: string[] | ||
| 18 | - items: number | ||
| 19 | - status: { tone: Tone, label: string } | ||
| 20 | - root?: string | ||
| 21 | -} | ||
| 22 | -const summaries = useState<Record<string, Summary | null>>('skilly-scope-summaries', () => ({})) | ||
| 23 | - | ||
| 24 | -function summarize(s: ScopeState, fallbackAgents: string): Summary { | ||
| 25 | - const cfg = s.config | ||
| 26 | - const inv = s.inventory | ||
| 27 | - const nonInstr = inv.items.filter(i => i.type !== 'instructions') | ||
| 28 | - let items = nonInstr.length | ||
| 29 | - if (cfg) { | ||
| 30 | - const entries = resolveItems(catalog.presets.value, cfg.presets, cfg.add).entries | ||
| 31 | - items = entries.filter(e => !matchIn(cfg.exclude[e.type], e.name)).length | ||
| 32 | - } | ||
| 33 | - const targets = cfg?.targets ?? inv.agentsDetected | ||
| 34 | - const agents = targets.length ? targets.map(a => AGENT_LABELS[a]).join(', ') : fallbackAgents | ||
| 35 | - const status = !cfg ? { tone: 'faint' as Tone, label: 'Not managed' } : summarizePlan(s.plan) | ||
| 36 | - return { managed: !!cfg, agents, presets: cfg?.presets ?? [], items, status, root: inv.root } | ||
| 37 | -} | ||
| 38 | - | ||
| 39 | -async function loadSummaries() { | ||
| 40 | - const ids = ['home', ...projects.value.filter(p => p.exists).map(p => p.id)] | ||
| 41 | - const queue = [...ids] | ||
| 42 | - const worker = async () => { | ||
| 43 | - for (let id = queue.shift(); id; id = queue.shift()) { | ||
| 44 | - try { | ||
| 45 | - const s = await request<ScopeState>(`/api/scopes/${encodeURIComponent(id)}/state`, { silent: true }) | ||
| 46 | - summaries.value = { ...summaries.value, [id]: summarize(s, '—') } | ||
| 47 | - } catch { | ||
| 48 | - summaries.value = { ...summaries.value, [id]: null } | ||
| 49 | - } | ||
| 50 | - } | ||
| 51 | - } | ||
| 52 | - await Promise.all([worker(), worker(), worker()]) | ||
| 53 | -} | ||
| 54 | 13 | ||
| 55 | onMounted(async () => { | 14 | onMounted(async () => { |
| 56 | if (!catalog.data.value) catalog.refresh() | 15 | if (!catalog.data.value) catalog.refresh() |
| @@ -63,7 +22,7 @@ onMounted(async () => { | |||
| 63 | const COLS = 'minmax(0,2.2fr) minmax(0,1.3fr) minmax(0,1.4fr) 64px minmax(0,1.5fr)' | 22 | const COLS = 'minmax(0,2.2fr) minmax(0,1.3fr) minmax(0,1.4fr) 64px minmax(0,1.5fr)' |
| 64 | 23 | ||
| 65 | function go(id: string) { | 24 | function go(id: string) { |
| 66 | - navigateTo(id === 'home' ? '/home' : `/projects/${encodeURIComponent(id)}`) | 25 | + navigateTo(scopeLink(id)) |
| 67 | } | 26 | } |
| 68 | 27 | ||
| 69 | const rowStatus = (p: ProjectSummary): { tone: Tone, label: string } | null => { | 28 | const rowStatus = (p: ProjectSummary): { tone: Tone, label: string } | null => { |
| @@ -81,12 +40,15 @@ const countOf = (p: ProjectSummary) => !p.exists ? '—' : summaries.value[p.id] | |||
| 81 | <span>Project</span><span>Agents</span><span>Presets</span><span>Items</span><span>Status</span> | 40 | <span>Project</span><span>Agents</span><span>Presets</span><span>Items</span><span>Status</span> |
| 82 | </SkRow> | 41 | </SkRow> |
| 83 | 42 | ||
| 84 | - <!-- Home, pinned --> | 43 | + <!-- Global (Home scope), pinned --> |
| 85 | - <SkRow interactive :cols="COLS" class="py-3" tabindex="0" role="link" aria-label="Open Home" @click="go('home')" @keydown.enter="go('home')"> | 44 | + <SkRow interactive :cols="COLS" class="py-3" tabindex="0" role="link" aria-label="Open Global" @click="go('home')" @keydown.enter="go('home')"> |
| 86 | <div class="flex min-w-0 items-center gap-2.5"> | 45 | <div class="flex min-w-0 items-center gap-2.5"> |
| 87 | <SkAvatar :size="28" /> | 46 | <SkAvatar :size="28" /> |
| 88 | <div class="min-w-0"> | 47 | <div class="min-w-0"> |
| 89 | - <div class="truncate font-medium">Home · user-level config</div> | 48 | + <div class="flex min-w-0 items-baseline gap-2"> |
| 49 | + <span class="truncate font-medium">Global</span> | ||
| 50 | + <span class="shrink-0 text-[11.5px] text-sk-muted">loaded in every project</span> | ||
| 51 | + </div> | ||
| 90 | <div class="truncate font-mono text-[11px] text-sk-faint">{{ summaries.home?.root ?? '~' }}</div> | 52 | <div class="truncate font-mono text-[11px] text-sk-faint">{{ summaries.home?.root ?? '~' }}</div> |
| 91 | </div> | 53 | </div> |
| 92 | </div> | 54 | </div> |
deleted
packages/ui/app/components/scope/ScopeConfigTab.vue +0 -20 | deleted file mode 100644 | ||
| @@ -1,20 +0,0 @@ | ||
| 1 | -<script setup lang="ts"> | |
| 2 | -/** Configuration tab: presets, items, inherited-from-Home (projects). */ | |
| 3 | -const scope = useScopeContext() | |
| 4 | - | |
| 5 | -const missing = computed(() => scope.plan.value?.missing ?? []) | |
| 6 | -const noTarget = computed(() => scope.config.value && !scope.config.value.targets.length) | |
| 7 | -</script> | |
| 8 | - | |
| 9 | -<template> | |
| 10 | - <div class="flex flex-col gap-[22px]"> | |
| 11 | - <SkNote v-if="noTarget" tone="accent">No target agent is selected: nothing will be written. Choose at least one agent under "Writes for" above.</SkNote> | |
| 12 | - <SkNote v-if="missing.length" tone="accent"> | |
| 13 | - {{ missing.length }} referenced item{{ missing.length > 1 ? 's are' : ' is' }} not in the catalog and will be skipped: | |
| 14 | - <span class="font-mono text-xs">{{ missing.map(m => `${m.type}/${m.name}`).join(', ') }}</span> | |
| 15 | - </SkNote> | |
| 16 | - <ScopePresetsSection /> | |
| 17 | - <ScopeItemsSection /> | |
| 18 | - <ScopeInheritedSection v-if="scope.kind === 'project'" /> | |
| 19 | - </div> | |
| 20 | -</template> | |
| deleted file mode 100644 | |||
| @@ -1,20 +0,0 @@ | |||
| 1 | -<script setup lang="ts"> | ||
| 2 | -/** Configuration tab: presets, items, inherited-from-Home (projects). */ | ||
| 3 | -const scope = useScopeContext() | ||
| 4 | - | ||
| 5 | -const missing = computed(() => scope.plan.value?.missing ?? []) | ||
| 6 | -const noTarget = computed(() => scope.config.value && !scope.config.value.targets.length) | ||
| 7 | -</script> | ||
| 8 | - | ||
| 9 | -<template> | ||
| 10 | - <div class="flex flex-col gap-[22px]"> | ||
| 11 | - <SkNote v-if="noTarget" tone="accent">No target agent is selected: nothing will be written. Choose at least one agent under "Writes for" above.</SkNote> | ||
| 12 | - <SkNote v-if="missing.length" tone="accent"> | ||
| 13 | - {{ missing.length }} referenced item{{ missing.length > 1 ? 's are' : ' is' }} not in the catalog and will be skipped: | ||
| 14 | - <span class="font-mono text-xs">{{ missing.map(m => `${m.type}/${m.name}`).join(', ') }}</span> | ||
| 15 | - </SkNote> | ||
| 16 | - <ScopePresetsSection /> | ||
| 17 | - <ScopeItemsSection /> | ||
| 18 | - <ScopeInheritedSection v-if="scope.kind === 'project'" /> | ||
| 19 | - </div> | ||
| 20 | -</template> | ||
modified
packages/ui/app/components/scope/ScopeConflictChoice.vue +1 -1 | @@ -9,7 +9,7 @@ const props = defineProps<{ action: PlanAction, home?: boolean }>() | ||
| 9 | 9 | const model = defineModel<ConflictResolution>({ required: true }) |
| 10 | 10 | |
| 11 | 11 | const options = computed(() => [ |
| 12 | - { value: 'keep-local' as const, label: props.home ? 'Keep Home version' : 'Keep project version' }, | |
| 12 | + { value: 'keep-local' as const, label: props.home ? 'Keep Global version' : 'Keep project version' }, | |
| 13 | 13 | { value: 'take-catalog' as const, label: 'Take catalog version', disabled: props.action.conflict === 'missing-in-catalog', title: props.action.conflict === 'missing-in-catalog' ? 'The item is no longer in the catalog' : undefined }, |
| 14 | 14 | { value: 'push-to-catalog' as const, label: 'Push to catalog' } |
| 15 | 15 | ]) |
| @@ -9,7 +9,7 @@ const props = defineProps<{ action: PlanAction, home?: boolean }>() | |||
| 9 | const model = defineModel<ConflictResolution>({ required: true }) | 9 | const model = defineModel<ConflictResolution>({ required: true }) |
| 10 | 10 | ||
| 11 | const options = computed(() => [ | 11 | const options = computed(() => [ |
| 12 | - { value: 'keep-local' as const, label: props.home ? 'Keep Home version' : 'Keep project version' }, | 12 | + { value: 'keep-local' as const, label: props.home ? 'Keep Global version' : 'Keep project version' }, |
| 13 | { value: 'take-catalog' as const, label: 'Take catalog version', disabled: props.action.conflict === 'missing-in-catalog', title: props.action.conflict === 'missing-in-catalog' ? 'The item is no longer in the catalog' : undefined }, | 13 | { value: 'take-catalog' as const, label: 'Take catalog version', disabled: props.action.conflict === 'missing-in-catalog', title: props.action.conflict === 'missing-in-catalog' ? 'The item is no longer in the catalog' : undefined }, |
| 14 | { value: 'push-to-catalog' as const, label: 'Push to catalog' } | 14 | { value: 'push-to-catalog' as const, label: 'Push to catalog' } |
| 15 | ]) | 15 | ]) |
modified
packages/ui/app/components/scope/ScopeConflictCompare.vue +1 -1 | @@ -65,7 +65,7 @@ watch(() => props.action.id, load, { immediate: true }) | ||
| 65 | 65 | <USkeleton v-if="loading" class="h-20 w-full rounded-[10px]" /> |
| 66 | 66 | <template v-else> |
| 67 | 67 | <p v-if="sentence && stats" class="m-0 text-pretty"> |
| 68 | - This {{ localLabel === 'Home' ? 'Home' : 'project' }}'s <code class="font-mono text-xs font-medium">{{ action.name }}</code> | |
| 68 | + This {{ localLabel === 'Global' ? 'Global' : 'project' }}'s <code class="font-mono text-xs font-medium">{{ action.name }}</code> | |
| 69 | 69 | {{ stats.added || stats.removed ? `differs from the catalog: ${describeDiff(stats)}.` : 'has the same text as the catalog (only the rendering or metadata differs).' }} |
| 70 | 70 | </p> |
| 71 | 71 | <SkNote v-if="note">{{ note }}</SkNote> |
| @@ -65,7 +65,7 @@ watch(() => props.action.id, load, { immediate: true }) | |||
| 65 | <USkeleton v-if="loading" class="h-20 w-full rounded-[10px]" /> | 65 | <USkeleton v-if="loading" class="h-20 w-full rounded-[10px]" /> |
| 66 | <template v-else> | 66 | <template v-else> |
| 67 | <p v-if="sentence && stats" class="m-0 text-pretty"> | 67 | <p v-if="sentence && stats" class="m-0 text-pretty"> |
| 68 | - This {{ localLabel === 'Home' ? 'Home' : 'project' }}'s <code class="font-mono text-xs font-medium">{{ action.name }}</code> | 68 | + This {{ localLabel === 'Global' ? 'Global' : 'project' }}'s <code class="font-mono text-xs font-medium">{{ action.name }}</code> |
| 69 | {{ stats.added || stats.removed ? `differs from the catalog: ${describeDiff(stats)}.` : 'has the same text as the catalog (only the rendering or metadata differs).' }} | 69 | {{ stats.added || stats.removed ? `differs from the catalog: ${describeDiff(stats)}.` : 'has the same text as the catalog (only the rendering or metadata differs).' }} |
| 70 | </p> | 70 | </p> |
| 71 | <SkNote v-if="note">{{ note }}</SkNote> | 71 | <SkNote v-if="note">{{ note }}</SkNote> |
deleted
packages/ui/app/components/scope/ScopeFilesTab.vue +0 -21 | deleted file mode 100644 | ||
| @@ -1,21 +0,0 @@ | ||
| 1 | -<script setup lang="ts"> | |
| 2 | -import type { InventoryItem } from '@skilly/core' | |
| 3 | - | |
| 4 | -/** Files tab: the raw inventory of the project by type, with the item drawer (mockup tray style). */ | |
| 5 | -const scope = useScopeContext() | |
| 6 | -</script> | |
| 7 | - | |
| 8 | -<template> | |
| 9 | - <SkSection title="Files on disk" hint="Everything the agents can see in this project, as scanned. Click a row for details."> | |
| 10 | - <SkPanel padded> | |
| 11 | - <ScopeInventoryList | |
| 12 | - :items="scope.inventory.value?.items ?? []" | |
| 13 | - :statuses="scope.statuses.value" | |
| 14 | - :root="scope.rootPath.value" | |
| 15 | - mode="project" | |
| 16 | - @select="scope.openItem" | |
| 17 | - @import="(i: InventoryItem) => scope.askImport([i])" | |
| 18 | - /> | |
| 19 | - </SkPanel> | |
| 20 | - </SkSection> | |
| 21 | -</template> | |
| deleted file mode 100644 | |||
| @@ -1,21 +0,0 @@ | |||
| 1 | -<script setup lang="ts"> | ||
| 2 | -import type { InventoryItem } from '@skilly/core' | ||
| 3 | - | ||
| 4 | -/** Files tab: the raw inventory of the project by type, with the item drawer (mockup tray style). */ | ||
| 5 | -const scope = useScopeContext() | ||
| 6 | -</script> | ||
| 7 | - | ||
| 8 | -<template> | ||
| 9 | - <SkSection title="Files on disk" hint="Everything the agents can see in this project, as scanned. Click a row for details."> | ||
| 10 | - <SkPanel padded> | ||
| 11 | - <ScopeInventoryList | ||
| 12 | - :items="scope.inventory.value?.items ?? []" | ||
| 13 | - :statuses="scope.statuses.value" | ||
| 14 | - :root="scope.rootPath.value" | ||
| 15 | - mode="project" | ||
| 16 | - @select="scope.openItem" | ||
| 17 | - @import="(i: InventoryItem) => scope.askImport([i])" | ||
| 18 | - /> | ||
| 19 | - </SkPanel> | ||
| 20 | - </SkSection> | ||
| 21 | -</template> | ||
modified
packages/ui/app/components/scope/ScopeInheritedSection.vue +4 -4 | @@ -68,15 +68,15 @@ function setHidden(i: Inherited, on: boolean) { | ||
| 68 | 68 | </script> |
| 69 | 69 | |
| 70 | 70 | <template> |
| 71 | - <SkSection title="Inherited from Home"> | |
| 71 | + <SkSection title="Inherited from Global"> | |
| 72 | 72 | <template #actions> |
| 73 | 73 | <span class="text-pretty text-sk-muted">Hiding is a fallback. Agents support it only partially.</span> |
| 74 | - <NuxtLink to="/home" class="text-sk-accent-text hover:underline">Move it out of Home instead</NuxtLink> | |
| 74 | + <NuxtLink :to="GLOBAL_PATH" class="text-sk-accent-text hover:underline">Move it out of Global instead</NuxtLink> | |
| 75 | 75 | </template> |
| 76 | 76 | |
| 77 | 77 | <SkPanel> |
| 78 | 78 | <div v-if="all.length > 8" class="border-b border-sk-line2 px-4 py-2.5"> |
| 79 | - <UInput v-model="search" placeholder="Search Home items" size="sm" class="w-full max-w-xs" /> | |
| 79 | + <UInput v-model="search" placeholder="Search Global items" size="sm" class="w-full max-w-xs" /> | |
| 80 | 80 | </div> |
| 81 | 81 | |
| 82 | 82 | <SkRow v-for="i in shownHideable" :key="i.key" cols="minmax(0,1fr) auto minmax(0,220px) 28px" class="text-[13px]"> |
| @@ -109,7 +109,7 @@ function setHidden(i: Inherited, on: boolean) { | ||
| 109 | 109 | <SkLinkAction @click="showFixed = true">Show {{ fixed.length }} rules, hooks and subagents that can't be hidden</SkLinkAction> |
| 110 | 110 | </div> |
| 111 | 111 | |
| 112 | - <SkEmpty v-if="!filtered.length && !loading">{{ search ? 'No Home item matches your search.' : 'Nothing is installed in Home: there is nothing to inherit.' }}</SkEmpty> | |
| 112 | + <SkEmpty v-if="!filtered.length && !loading">{{ search ? 'No Global item matches your search.' : 'Nothing is installed in Global (~): there is nothing to inherit.' }}</SkEmpty> | |
| 113 | 113 | <div v-if="loading && !homeInv" class="p-4"><USkeleton class="h-8 w-full" /></div> |
| 114 | 114 | </SkPanel> |
| 115 | 115 | </SkSection> |
| @@ -68,15 +68,15 @@ function setHidden(i: Inherited, on: boolean) { | |||
| 68 | </script> | 68 | </script> |
| 69 | 69 | ||
| 70 | <template> | 70 | <template> |
| 71 | - <SkSection title="Inherited from Home"> | 71 | + <SkSection title="Inherited from Global"> |
| 72 | <template #actions> | 72 | <template #actions> |
| 73 | <span class="text-pretty text-sk-muted">Hiding is a fallback. Agents support it only partially.</span> | 73 | <span class="text-pretty text-sk-muted">Hiding is a fallback. Agents support it only partially.</span> |
| 74 | - <NuxtLink to="/home" class="text-sk-accent-text hover:underline">Move it out of Home instead</NuxtLink> | 74 | + <NuxtLink :to="GLOBAL_PATH" class="text-sk-accent-text hover:underline">Move it out of Global instead</NuxtLink> |
| 75 | </template> | 75 | </template> |
| 76 | 76 | ||
| 77 | <SkPanel> | 77 | <SkPanel> |
| 78 | <div v-if="all.length > 8" class="border-b border-sk-line2 px-4 py-2.5"> | 78 | <div v-if="all.length > 8" class="border-b border-sk-line2 px-4 py-2.5"> |
| 79 | - <UInput v-model="search" placeholder="Search Home items" size="sm" class="w-full max-w-xs" /> | 79 | + <UInput v-model="search" placeholder="Search Global items" size="sm" class="w-full max-w-xs" /> |
| 80 | </div> | 80 | </div> |
| 81 | 81 | ||
| 82 | <SkRow v-for="i in shownHideable" :key="i.key" cols="minmax(0,1fr) auto minmax(0,220px) 28px" class="text-[13px]"> | 82 | <SkRow v-for="i in shownHideable" :key="i.key" cols="minmax(0,1fr) auto minmax(0,220px) 28px" class="text-[13px]"> |
| @@ -109,7 +109,7 @@ function setHidden(i: Inherited, on: boolean) { | |||
| 109 | <SkLinkAction @click="showFixed = true">Show {{ fixed.length }} rules, hooks and subagents that can't be hidden</SkLinkAction> | 109 | <SkLinkAction @click="showFixed = true">Show {{ fixed.length }} rules, hooks and subagents that can't be hidden</SkLinkAction> |
| 110 | </div> | 110 | </div> |
| 111 | 111 | ||
| 112 | - <SkEmpty v-if="!filtered.length && !loading">{{ search ? 'No Home item matches your search.' : 'Nothing is installed in Home: there is nothing to inherit.' }}</SkEmpty> | 112 | + <SkEmpty v-if="!filtered.length && !loading">{{ search ? 'No Global item matches your search.' : 'Nothing is installed in Global (~): there is nothing to inherit.' }}</SkEmpty> |
| 113 | <div v-if="loading && !homeInv" class="p-4"><USkeleton class="h-8 w-full" /></div> | 113 | <div v-if="loading && !homeInv" class="p-4"><USkeleton class="h-8 w-full" /></div> |
| 114 | </SkPanel> | 114 | </SkPanel> |
| 115 | </SkSection> | 115 | </SkSection> |
modified
packages/ui/app/components/scope/ScopeInstructionsTab.vue +4 -3 | @@ -2,10 +2,11 @@ | ||
| 2 | 2 | import type { InstructionsDetails, InventoryItem } from '@skilly/core' |
| 3 | 3 | |
| 4 | 4 | /** |
| 5 | - * Instructions tab (mockup 12): CLAUDE.md / AGENTS.md of the project, read-only, with loaded / not loaded, | |
| 5 | + * Instructions tab (mockup 12): CLAUDE.md / AGENTS.md of the project (or of Global: ~/.claude, ~/.codex), read-only, with loaded / not loaded, | |
| 6 | 6 | * imports, size, and the Codex 32 KiB limit gauge (brief 3, "Project instructions"). |
| 7 | 7 | */ |
| 8 | 8 | const scope = useScopeContext() |
| 9 | +const home = scope.kind === 'home' | |
| 9 | 10 | |
| 10 | 11 | const CODEX_LIMIT = 32 * 1024 |
| 11 | 12 | const detail = (i: InventoryItem) => i.details as InstructionsDetails |
| @@ -47,7 +48,7 @@ const loadedText = (f: InstructionFile) => (f.loadedBy.length ? `Loaded by ${f.l | ||
| 47 | 48 | <div class="flex flex-col gap-3.5"> |
| 48 | 49 | <div v-if="files.length" class="flex flex-wrap items-center justify-between gap-x-3 gap-y-2"> |
| 49 | 50 | <SkSeg v-model="selected" :options="options" mono aria-label="Instruction file" /> |
| 50 | - <span class="text-sk-muted">Read-only · specific to this project, never shared</span> | |
| 51 | + <span class="text-sk-muted">{{ home ? 'Read-only · loaded in every project' : 'Read-only · specific to this project, never shared' }}</span> | |
| 51 | 52 | </div> |
| 52 | 53 | |
| 53 | 54 | <template v-if="current"> |
| @@ -72,7 +73,7 @@ const loadedText = (f: InstructionFile) => (f.loadedBy.length ? `Loaded by ${f.l | ||
| 72 | 73 | </template> |
| 73 | 74 | |
| 74 | 75 | <SkPanel v-else> |
| 75 | - <SkEmpty>No CLAUDE.md or AGENTS.md in this project. Instruction files are written by you: skilly only reads them.</SkEmpty> | |
| 76 | + <SkEmpty>No CLAUDE.md or AGENTS.md {{ home ? 'in ~/.claude or ~/.codex' : 'in this project' }}. Instruction files are written by you: skilly only reads them.</SkEmpty> | |
| 76 | 77 | </SkPanel> |
| 77 | 78 | </div> |
| 78 | 79 | </template> |
| @@ -2,10 +2,11 @@ | |||
| 2 | import type { InstructionsDetails, InventoryItem } from '@skilly/core' | 2 | import type { InstructionsDetails, InventoryItem } from '@skilly/core' |
| 3 | 3 | ||
| 4 | /** | 4 | /** |
| 5 | - * Instructions tab (mockup 12): CLAUDE.md / AGENTS.md of the project, read-only, with loaded / not loaded, | 5 | + * Instructions tab (mockup 12): CLAUDE.md / AGENTS.md of the project (or of Global: ~/.claude, ~/.codex), read-only, with loaded / not loaded, |
| 6 | * imports, size, and the Codex 32 KiB limit gauge (brief 3, "Project instructions"). | 6 | * imports, size, and the Codex 32 KiB limit gauge (brief 3, "Project instructions"). |
| 7 | */ | 7 | */ |
| 8 | const scope = useScopeContext() | 8 | const scope = useScopeContext() |
| 9 | +const home = scope.kind === 'home' | ||
| 9 | 10 | ||
| 10 | const CODEX_LIMIT = 32 * 1024 | 11 | const CODEX_LIMIT = 32 * 1024 |
| 11 | const detail = (i: InventoryItem) => i.details as InstructionsDetails | 12 | const detail = (i: InventoryItem) => i.details as InstructionsDetails |
| @@ -47,7 +48,7 @@ const loadedText = (f: InstructionFile) => (f.loadedBy.length ? `Loaded by ${f.l | |||
| 47 | <div class="flex flex-col gap-3.5"> | 48 | <div class="flex flex-col gap-3.5"> |
| 48 | <div v-if="files.length" class="flex flex-wrap items-center justify-between gap-x-3 gap-y-2"> | 49 | <div v-if="files.length" class="flex flex-wrap items-center justify-between gap-x-3 gap-y-2"> |
| 49 | <SkSeg v-model="selected" :options="options" mono aria-label="Instruction file" /> | 50 | <SkSeg v-model="selected" :options="options" mono aria-label="Instruction file" /> |
| 50 | - <span class="text-sk-muted">Read-only · specific to this project, never shared</span> | 51 | + <span class="text-sk-muted">{{ home ? 'Read-only · loaded in every project' : 'Read-only · specific to this project, never shared' }}</span> |
| 51 | </div> | 52 | </div> |
| 52 | 53 | ||
| 53 | <template v-if="current"> | 54 | <template v-if="current"> |
| @@ -72,7 +73,7 @@ const loadedText = (f: InstructionFile) => (f.loadedBy.length ? `Loaded by ${f.l | |||
| 72 | </template> | 73 | </template> |
| 73 | 74 | ||
| 74 | <SkPanel v-else> | 75 | <SkPanel v-else> |
| 75 | - <SkEmpty>No CLAUDE.md or AGENTS.md in this project. Instruction files are written by you: skilly only reads them.</SkEmpty> | 76 | + <SkEmpty>No CLAUDE.md or AGENTS.md {{ home ? 'in ~/.claude or ~/.codex' : 'in this project' }}. Instruction files are written by you: skilly only reads them.</SkEmpty> |
| 76 | </SkPanel> | 77 | </SkPanel> |
| 77 | </div> | 78 | </div> |
| 78 | </template> | 79 | </template> |
deleted
packages/ui/app/components/scope/ScopeInventoryList.vue +0 -149 | deleted file mode 100644 | ||
| @@ -1,149 +0,0 @@ | ||
| 1 | -<script setup lang="ts"> | |
| 2 | -import type { AgentId, InstructionsDetails, InventoryItem, ItemStatus, ItemType, McpDetails } from '@skilly/core' | |
| 3 | -import type { ProjectSpecific } from '../../utils/inventoryView' | |
| 4 | -import type { Tone } from '../../utils/status' | |
| 5 | - | |
| 6 | -/** | |
| 7 | - * Raw inventory grouped by type in a tray of row cards (Home inventory, mockup 6; project Files tab). | |
| 8 | - * Filters: type segment with counts, agent segment, search. One row per agent and file, as the paths differ. | |
| 9 | - * Home rows get "Move" (skills, rules, subagents) or "Add to catalog" (MCP servers and hooks are read-only in v1). | |
| 10 | - */ | |
| 11 | -const props = defineProps<{ | |
| 12 | - items: InventoryItem[] | |
| 13 | - statuses: Record<string, ItemStatus> | |
| 14 | - root: string | |
| 15 | - mode: 'home' | 'project' | |
| 16 | - /** Items flagged "Project-specific" (Home), by inventory item id. */ | |
| 17 | - flagged?: Map<string, ProjectSpecific> | |
| 18 | - /** Restricts the list to these item ids ("Review" in the suggestion panel). */ | |
| 19 | - onlyIds?: Set<string> | null | |
| 20 | -}>() | |
| 21 | -const emit = defineEmits<{ select: [item: InventoryItem], move: [item: InventoryItem], import: [item: InventoryItem] }>() | |
| 22 | - | |
| 23 | -const home = computed(() => props.mode === 'home') | |
| 24 | -const typeFilter = ref<'all' | ItemType>('all') | |
| 25 | -const agentFilter = ref<'all' | AgentId>('all') | |
| 26 | -const search = ref('') | |
| 27 | - | |
| 28 | -const GROUP_ORDER: ItemType[] = ['skill', 'rule', 'mcp', 'hook', 'agent', 'instructions'] | |
| 29 | - | |
| 30 | -function haystack(i: InventoryItem): string { | |
| 31 | - const d = i.details | |
| 32 | - return [ | |
| 33 | - itemDisplayName(i), i.description, i.source.path, i.source.plugin, | |
| 34 | - d.type === 'mcp' ? `${d.command ?? ''} ${d.url ?? ''} ${d.transport}` : '', | |
| 35 | - d.type === 'hook' ? `${d.event} ${d.matcher ?? ''} ${d.command ?? ''}` : '', | |
| 36 | - d.type === 'agent' ? d.model : '' | |
| 37 | - ].join(' ').toLowerCase() | |
| 38 | -} | |
| 39 | - | |
| 40 | -const base = computed(() => { | |
| 41 | - const q = search.value.trim().toLowerCase() | |
| 42 | - return props.items.filter(i => | |
| 43 | - (agentFilter.value === 'all' || i.agent === agentFilter.value) | |
| 44 | - && (!q || haystack(i).includes(q)) | |
| 45 | - && (!props.onlyIds || props.onlyIds.has(i.id))) | |
| 46 | -}) | |
| 47 | - | |
| 48 | -const counts = computed(() => { | |
| 49 | - const c = Object.fromEntries(GROUP_ORDER.map(t => [t, 0])) as Record<ItemType, number> | |
| 50 | - for (const i of base.value) c[i.type]++ | |
| 51 | - return c | |
| 52 | -}) | |
| 53 | -const totalNoInstr = computed(() => GROUP_ORDER.filter(t => t !== 'instructions').reduce((n, t) => n + counts.value[t], 0)) | |
| 54 | - | |
| 55 | -const typeOptions = computed(() => [ | |
| 56 | - { value: 'all' as const, label: 'All', count: totalNoInstr.value }, | |
| 57 | - ...GROUP_ORDER.filter(t => t !== 'instructions').map(t => ({ value: t, label: TYPE_PLURAL[t], count: counts.value[t] })), | |
| 58 | - ...(counts.value.instructions || typeFilter.value === 'instructions' ? [{ value: 'instructions' as const, label: 'Instructions', count: counts.value.instructions }] : []) | |
| 59 | -]) | |
| 60 | -const agentOptions = [ | |
| 61 | - { value: 'all' as const, label: 'All agents' }, | |
| 62 | - { value: 'claude' as const, label: AGENT_LABELS.claude }, | |
| 63 | - { value: 'codex' as const, label: AGENT_LABELS.codex } | |
| 64 | -] | |
| 65 | - | |
| 66 | -const groups = computed(() => { | |
| 67 | - const by = Object.fromEntries(GROUP_ORDER.map(t => [t, [] as InventoryItem[]])) as Record<ItemType, InventoryItem[]> | |
| 68 | - for (const i of base.value) by[i.type].push(i) | |
| 69 | - // Sort by name, then agent; hooks and instruction files keep their scan order (it is meaningful). | |
| 70 | - for (const t of ['skill', 'rule', 'mcp', 'agent'] as const) { | |
| 71 | - by[t].sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }) || a.agent.localeCompare(b.agent)) | |
| 72 | - } | |
| 73 | - return GROUP_ORDER | |
| 74 | - .filter(t => (typeFilter.value === 'all' ? true : typeFilter.value === t) && by[t].length) | |
| 75 | - .map(t => ({ type: t, items: by[t] })) | |
| 76 | -}) | |
| 77 | -const empty = computed(() => !groups.value.length) | |
| 78 | - | |
| 79 | -const stateOf = (i: InventoryItem): { tone: Tone, label: string } => { | |
| 80 | - if (i.type === 'instructions') { | |
| 81 | - const d = i.details as InstructionsDetails | |
| 82 | - return { tone: d.loaded ? 'ok' : 'faint', label: d.loaded ? 'Loaded' : 'Not loaded' } | |
| 83 | - } | |
| 84 | - const st = props.statuses[i.id] | |
| 85 | - if (home.value) return homeStateMeta(st?.state) | |
| 86 | - const meta = ITEM_STATE_META[st?.state ?? 'not-in-catalog'] | |
| 87 | - return { tone: meta.tone, label: meta.label } | |
| 88 | -} | |
| 89 | - | |
| 90 | -const mcpDisabled = (i: InventoryItem) => i.details.type === 'mcp' && !(i.details as McpDetails).enabled | |
| 91 | -const readOnly = (t: ItemType) => home.value && (t === 'mcp' || t === 'hook') | |
| 92 | -</script> | |
| 93 | - | |
| 94 | -<template> | |
| 95 | - <div class="flex min-w-0 flex-col gap-3"> | |
| 96 | - <div class="flex flex-wrap items-center gap-x-3 gap-y-2"> | |
| 97 | - <SkSeg v-model="typeFilter" :options="typeOptions" size="md" aria-label="Filter by type" /> | |
| 98 | - <UInput v-model="search" placeholder="Search items" size="sm" class="min-w-40 flex-1" aria-label="Search items" /> | |
| 99 | - <SkSeg v-model="agentFilter" :options="agentOptions" size="md" aria-label="Filter by agent" /> | |
| 100 | - </div> | |
| 101 | - | |
| 102 | - <SkTray> | |
| 103 | - <slot name="top" /> | |
| 104 | - <template v-for="g in groups" :key="g.type"> | |
| 105 | - <SkSectionLabel inset :count="g.items.length"> | |
| 106 | - {{ g.type === 'instructions' ? 'Instruction files' : TYPE_PLURAL[g.type] }} | |
| 107 | - <span v-if="readOnly(g.type)" class="normal-case tracking-normal" title="Entries of your user configuration files: shown read-only in v1. You can still add them to the catalog.">· read-only in v1</span> | |
| 108 | - </SkSectionLabel> | |
| 109 | - <SkRowCard | |
| 110 | - v-for="i in g.items" | |
| 111 | - :key="i.id" | |
| 112 | - interactive | |
| 113 | - cols="minmax(0,1fr) 100px 148px 118px" | |
| 114 | - tabindex="0" | |
| 115 | - role="button" | |
| 116 | - :aria-label="`Open ${itemDisplayName(i)} (${AGENT_LABELS[i.agent]})`" | |
| 117 | - @click="emit('select', i)" | |
| 118 | - @keydown.enter="emit('select', i)" | |
| 119 | - > | |
| 120 | - <div class="min-w-0"> | |
| 121 | - <div class="flex flex-wrap items-baseline gap-x-2 gap-y-0.5"> | |
| 122 | - <span class="truncate font-medium">{{ itemDisplayName(i) }}</span> | |
| 123 | - <span v-if="flagged?.has(i.id)" class="text-[11.5px] font-medium text-sk-accent-text" :title="flagged.get(i.id)!.hint">Project-specific</span> | |
| 124 | - <SkTag v-if="i.source.plugin" mono :title="`Provided by the plugin ${i.source.plugin}`">plugin</SkTag> | |
| 125 | - <SkTag v-if="i.source.symlinkTarget" mono :title="`Symlink to ${i.source.symlinkTarget}`">symlink</SkTag> | |
| 126 | - <SkTag v-if="mcpDisabled(i)" mono>disabled</SkTag> | |
| 127 | - <span v-if="i.warnings.length" class="font-mono text-[11px] text-sk-accent-text" :title="i.warnings.join('\n')">warning</span> | |
| 128 | - </div> | |
| 129 | - <div class="truncate font-mono text-[11px] text-sk-faint" :title="itemLocation(i, root, home ? '~/' : '')">{{ itemLocation(i, root, home ? '~/' : '') }}</div> | |
| 130 | - </div> | |
| 131 | - <SkAgentBadge :agent="i.agent" /> | |
| 132 | - <SkStatus :tone="stateOf(i).tone" :label="stateOf(i).label" small class="text-sk-muted" /> | |
| 133 | - <div class="flex justify-end" @click.stop> | |
| 134 | - <template v-if="home"> | |
| 135 | - <SkButton v-if="canMoveOutOfHome(i, statuses[i.id])" variant="primary" size="sm" label="Move" :aria-label="`Move ${i.name} out of Home`" @click="emit('move', i)" /> | |
| 136 | - <SkButton v-else-if="canAddToCatalog(i, statuses[i.id])" size="sm" label="Add to catalog" @click="emit('import', i)" /> | |
| 137 | - <span v-else-if="statuses[i.id]?.managed" class="font-mono text-[11px] text-sk-faint">managed</span> | |
| 138 | - <span v-else-if="i.source.plugin" class="font-mono text-[11px] text-sk-faint">plugin</span> | |
| 139 | - </template> | |
| 140 | - <SkButton v-else-if="canAddToCatalog(i, statuses[i.id])" size="sm" label="Add to catalog" @click="emit('import', i)" /> | |
| 141 | - </div> | |
| 142 | - </SkRowCard> | |
| 143 | - </template> | |
| 144 | - <SkEmpty v-if="empty" class="rounded-[11px] bg-sk-panel"> | |
| 145 | - {{ items.length ? 'Nothing matches these filters.' : 'No item found here.' }} | |
| 146 | - </SkEmpty> | |
| 147 | - </SkTray> | |
| 148 | - </div> | |
| 149 | -</template> | |
| deleted file mode 100644 | |||
| @@ -1,149 +0,0 @@ | |||
| 1 | -<script setup lang="ts"> | ||
| 2 | -import type { AgentId, InstructionsDetails, InventoryItem, ItemStatus, ItemType, McpDetails } from '@skilly/core' | ||
| 3 | -import type { ProjectSpecific } from '../../utils/inventoryView' | ||
| 4 | -import type { Tone } from '../../utils/status' | ||
| 5 | - | ||
| 6 | -/** | ||
| 7 | - * Raw inventory grouped by type in a tray of row cards (Home inventory, mockup 6; project Files tab). | ||
| 8 | - * Filters: type segment with counts, agent segment, search. One row per agent and file, as the paths differ. | ||
| 9 | - * Home rows get "Move" (skills, rules, subagents) or "Add to catalog" (MCP servers and hooks are read-only in v1). | ||
| 10 | - */ | ||
| 11 | -const props = defineProps<{ | ||
| 12 | - items: InventoryItem[] | ||
| 13 | - statuses: Record<string, ItemStatus> | ||
| 14 | - root: string | ||
| 15 | - mode: 'home' | 'project' | ||
| 16 | - /** Items flagged "Project-specific" (Home), by inventory item id. */ | ||
| 17 | - flagged?: Map<string, ProjectSpecific> | ||
| 18 | - /** Restricts the list to these item ids ("Review" in the suggestion panel). */ | ||
| 19 | - onlyIds?: Set<string> | null | ||
| 20 | -}>() | ||
| 21 | -const emit = defineEmits<{ select: [item: InventoryItem], move: [item: InventoryItem], import: [item: InventoryItem] }>() | ||
| 22 | - | ||
| 23 | -const home = computed(() => props.mode === 'home') | ||
| 24 | -const typeFilter = ref<'all' | ItemType>('all') | ||
| 25 | -const agentFilter = ref<'all' | AgentId>('all') | ||
| 26 | -const search = ref('') | ||
| 27 | - | ||
| 28 | -const GROUP_ORDER: ItemType[] = ['skill', 'rule', 'mcp', 'hook', 'agent', 'instructions'] | ||
| 29 | - | ||
| 30 | -function haystack(i: InventoryItem): string { | ||
| 31 | - const d = i.details | ||
| 32 | - return [ | ||
| 33 | - itemDisplayName(i), i.description, i.source.path, i.source.plugin, | ||
| 34 | - d.type === 'mcp' ? `${d.command ?? ''} ${d.url ?? ''} ${d.transport}` : '', | ||
| 35 | - d.type === 'hook' ? `${d.event} ${d.matcher ?? ''} ${d.command ?? ''}` : '', | ||
| 36 | - d.type === 'agent' ? d.model : '' | ||
| 37 | - ].join(' ').toLowerCase() | ||
| 38 | -} | ||
| 39 | - | ||
| 40 | -const base = computed(() => { | ||
| 41 | - const q = search.value.trim().toLowerCase() | ||
| 42 | - return props.items.filter(i => | ||
| 43 | - (agentFilter.value === 'all' || i.agent === agentFilter.value) | ||
| 44 | - && (!q || haystack(i).includes(q)) | ||
| 45 | - && (!props.onlyIds || props.onlyIds.has(i.id))) | ||
| 46 | -}) | ||
| 47 | - | ||
| 48 | -const counts = computed(() => { | ||
| 49 | - const c = Object.fromEntries(GROUP_ORDER.map(t => [t, 0])) as Record<ItemType, number> | ||
| 50 | - for (const i of base.value) c[i.type]++ | ||
| 51 | - return c | ||
| 52 | -}) | ||
| 53 | -const totalNoInstr = computed(() => GROUP_ORDER.filter(t => t !== 'instructions').reduce((n, t) => n + counts.value[t], 0)) | ||
| 54 | - | ||
| 55 | -const typeOptions = computed(() => [ | ||
| 56 | - { value: 'all' as const, label: 'All', count: totalNoInstr.value }, | ||
| 57 | - ...GROUP_ORDER.filter(t => t !== 'instructions').map(t => ({ value: t, label: TYPE_PLURAL[t], count: counts.value[t] })), | ||
| 58 | - ...(counts.value.instructions || typeFilter.value === 'instructions' ? [{ value: 'instructions' as const, label: 'Instructions', count: counts.value.instructions }] : []) | ||
| 59 | -]) | ||
| 60 | -const agentOptions = [ | ||
| 61 | - { value: 'all' as const, label: 'All agents' }, | ||
| 62 | - { value: 'claude' as const, label: AGENT_LABELS.claude }, | ||
| 63 | - { value: 'codex' as const, label: AGENT_LABELS.codex } | ||
| 64 | -] | ||
| 65 | - | ||
| 66 | -const groups = computed(() => { | ||
| 67 | - const by = Object.fromEntries(GROUP_ORDER.map(t => [t, [] as InventoryItem[]])) as Record<ItemType, InventoryItem[]> | ||
| 68 | - for (const i of base.value) by[i.type].push(i) | ||
| 69 | - // Sort by name, then agent; hooks and instruction files keep their scan order (it is meaningful). | ||
| 70 | - for (const t of ['skill', 'rule', 'mcp', 'agent'] as const) { | ||
| 71 | - by[t].sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }) || a.agent.localeCompare(b.agent)) | ||
| 72 | - } | ||
| 73 | - return GROUP_ORDER | ||
| 74 | - .filter(t => (typeFilter.value === 'all' ? true : typeFilter.value === t) && by[t].length) | ||
| 75 | - .map(t => ({ type: t, items: by[t] })) | ||
| 76 | -}) | ||
| 77 | -const empty = computed(() => !groups.value.length) | ||
| 78 | - | ||
| 79 | -const stateOf = (i: InventoryItem): { tone: Tone, label: string } => { | ||
| 80 | - if (i.type === 'instructions') { | ||
| 81 | - const d = i.details as InstructionsDetails | ||
| 82 | - return { tone: d.loaded ? 'ok' : 'faint', label: d.loaded ? 'Loaded' : 'Not loaded' } | ||
| 83 | - } | ||
| 84 | - const st = props.statuses[i.id] | ||
| 85 | - if (home.value) return homeStateMeta(st?.state) | ||
| 86 | - const meta = ITEM_STATE_META[st?.state ?? 'not-in-catalog'] | ||
| 87 | - return { tone: meta.tone, label: meta.label } | ||
| 88 | -} | ||
| 89 | - | ||
| 90 | -const mcpDisabled = (i: InventoryItem) => i.details.type === 'mcp' && !(i.details as McpDetails).enabled | ||
| 91 | -const readOnly = (t: ItemType) => home.value && (t === 'mcp' || t === 'hook') | ||
| 92 | -</script> | ||
| 93 | - | ||
| 94 | -<template> | ||
| 95 | - <div class="flex min-w-0 flex-col gap-3"> | ||
| 96 | - <div class="flex flex-wrap items-center gap-x-3 gap-y-2"> | ||
| 97 | - <SkSeg v-model="typeFilter" :options="typeOptions" size="md" aria-label="Filter by type" /> | ||
| 98 | - <UInput v-model="search" placeholder="Search items" size="sm" class="min-w-40 flex-1" aria-label="Search items" /> | ||
| 99 | - <SkSeg v-model="agentFilter" :options="agentOptions" size="md" aria-label="Filter by agent" /> | ||
| 100 | - </div> | ||
| 101 | - | ||
| 102 | - <SkTray> | ||
| 103 | - <slot name="top" /> | ||
| 104 | - <template v-for="g in groups" :key="g.type"> | ||
| 105 | - <SkSectionLabel inset :count="g.items.length"> | ||
| 106 | - {{ g.type === 'instructions' ? 'Instruction files' : TYPE_PLURAL[g.type] }} | ||
| 107 | - <span v-if="readOnly(g.type)" class="normal-case tracking-normal" title="Entries of your user configuration files: shown read-only in v1. You can still add them to the catalog.">· read-only in v1</span> | ||
| 108 | - </SkSectionLabel> | ||
| 109 | - <SkRowCard | ||
| 110 | - v-for="i in g.items" | ||
| 111 | - :key="i.id" | ||
| 112 | - interactive | ||
| 113 | - cols="minmax(0,1fr) 100px 148px 118px" | ||
| 114 | - tabindex="0" | ||
| 115 | - role="button" | ||
| 116 | - :aria-label="`Open ${itemDisplayName(i)} (${AGENT_LABELS[i.agent]})`" | ||
| 117 | - @click="emit('select', i)" | ||
| 118 | - @keydown.enter="emit('select', i)" | ||
| 119 | - > | ||
| 120 | - <div class="min-w-0"> | ||
| 121 | - <div class="flex flex-wrap items-baseline gap-x-2 gap-y-0.5"> | ||
| 122 | - <span class="truncate font-medium">{{ itemDisplayName(i) }}</span> | ||
| 123 | - <span v-if="flagged?.has(i.id)" class="text-[11.5px] font-medium text-sk-accent-text" :title="flagged.get(i.id)!.hint">Project-specific</span> | ||
| 124 | - <SkTag v-if="i.source.plugin" mono :title="`Provided by the plugin ${i.source.plugin}`">plugin</SkTag> | ||
| 125 | - <SkTag v-if="i.source.symlinkTarget" mono :title="`Symlink to ${i.source.symlinkTarget}`">symlink</SkTag> | ||
| 126 | - <SkTag v-if="mcpDisabled(i)" mono>disabled</SkTag> | ||
| 127 | - <span v-if="i.warnings.length" class="font-mono text-[11px] text-sk-accent-text" :title="i.warnings.join('\n')">warning</span> | ||
| 128 | - </div> | ||
| 129 | - <div class="truncate font-mono text-[11px] text-sk-faint" :title="itemLocation(i, root, home ? '~/' : '')">{{ itemLocation(i, root, home ? '~/' : '') }}</div> | ||
| 130 | - </div> | ||
| 131 | - <SkAgentBadge :agent="i.agent" /> | ||
| 132 | - <SkStatus :tone="stateOf(i).tone" :label="stateOf(i).label" small class="text-sk-muted" /> | ||
| 133 | - <div class="flex justify-end" @click.stop> | ||
| 134 | - <template v-if="home"> | ||
| 135 | - <SkButton v-if="canMoveOutOfHome(i, statuses[i.id])" variant="primary" size="sm" label="Move" :aria-label="`Move ${i.name} out of Home`" @click="emit('move', i)" /> | ||
| 136 | - <SkButton v-else-if="canAddToCatalog(i, statuses[i.id])" size="sm" label="Add to catalog" @click="emit('import', i)" /> | ||
| 137 | - <span v-else-if="statuses[i.id]?.managed" class="font-mono text-[11px] text-sk-faint">managed</span> | ||
| 138 | - <span v-else-if="i.source.plugin" class="font-mono text-[11px] text-sk-faint">plugin</span> | ||
| 139 | - </template> | ||
| 140 | - <SkButton v-else-if="canAddToCatalog(i, statuses[i.id])" size="sm" label="Add to catalog" @click="emit('import', i)" /> | ||
| 141 | - </div> | ||
| 142 | - </SkRowCard> | ||
| 143 | - </template> | ||
| 144 | - <SkEmpty v-if="empty" class="rounded-[11px] bg-sk-panel"> | ||
| 145 | - {{ items.length ? 'Nothing matches these filters.' : 'No item found here.' }} | ||
| 146 | - </SkEmpty> | ||
| 147 | - </SkTray> | ||
| 148 | - </div> | ||
| 149 | -</template> | ||
modified
packages/ui/app/components/scope/ScopeItemDrawer.vue +2 -2 | @@ -3,7 +3,7 @@ import type { SkillDetails } from '@skilly/core' | ||
| 3 | 3 | |
| 4 | 4 | /** |
| 5 | 5 | * Item drawer (brief 3, "Item drawer"): metadata grid, warnings, then the file content (rendered markdown) or the |
| 6 | - * list of files of a skill; instruction files use the read-only viewer. Actions: open in catalog, add to catalog, move out of Home. | |
| 6 | + * list of files of a skill; instruction files use the read-only viewer. Actions: open in catalog, add to catalog, move to projects (Global). | |
| 7 | 7 | */ |
| 8 | 8 | const scope = useScopeContext() |
| 9 | 9 | const open = defineModel<boolean>('open', { default: false }) |
| @@ -147,7 +147,7 @@ function move() { | ||
| 147 | 147 | <div class="flex w-full flex-wrap items-center justify-end gap-2"> |
| 148 | 148 | <SkButton v-if="status?.catalogRef" label="Open in catalog" @click="openInCatalog" /> |
| 149 | 149 | <SkButton v-if="canCatalog" label="Add to catalog" @click="addToCatalog" /> |
| 150 | - <SkButton v-if="canMove" variant="primary" label="Remove from global" @click="move" /> | |
| 150 | + <SkButton v-if="canMove" variant="primary" label="Move to projects…" @click="move" /> | |
| 151 | 151 | </div> |
| 152 | 152 | </template> |
| 153 | 153 | </SkDrawer> |
| @@ -3,7 +3,7 @@ import type { SkillDetails } from '@skilly/core' | |||
| 3 | 3 | ||
| 4 | /** | 4 | /** |
| 5 | * Item drawer (brief 3, "Item drawer"): metadata grid, warnings, then the file content (rendered markdown) or the | 5 | * Item drawer (brief 3, "Item drawer"): metadata grid, warnings, then the file content (rendered markdown) or the |
| 6 | - * list of files of a skill; instruction files use the read-only viewer. Actions: open in catalog, add to catalog, move out of Home. | 6 | + * list of files of a skill; instruction files use the read-only viewer. Actions: open in catalog, add to catalog, move to projects (Global). |
| 7 | */ | 7 | */ |
| 8 | const scope = useScopeContext() | 8 | const scope = useScopeContext() |
| 9 | const open = defineModel<boolean>('open', { default: false }) | 9 | const open = defineModel<boolean>('open', { default: false }) |
| @@ -147,7 +147,7 @@ function move() { | |||
| 147 | <div class="flex w-full flex-wrap items-center justify-end gap-2"> | 147 | <div class="flex w-full flex-wrap items-center justify-end gap-2"> |
| 148 | <SkButton v-if="status?.catalogRef" label="Open in catalog" @click="openInCatalog" /> | 148 | <SkButton v-if="status?.catalogRef" label="Open in catalog" @click="openInCatalog" /> |
| 149 | <SkButton v-if="canCatalog" label="Add to catalog" @click="addToCatalog" /> | 149 | <SkButton v-if="canCatalog" label="Add to catalog" @click="addToCatalog" /> |
| 150 | - <SkButton v-if="canMove" variant="primary" label="Remove from global" @click="move" /> | 150 | + <SkButton v-if="canMove" variant="primary" label="Move to projects…" @click="move" /> |
| 151 | </div> | 151 | </div> |
| 152 | </template> | 152 | </template> |
| 153 | </SkDrawer> | 153 | </SkDrawer> |
modified
packages/ui/app/components/scope/ScopeItemRow.vue +35 -8 | @@ -2,12 +2,25 @@ | ||
| 2 | 2 | import type { RowAction, ScopeRow } from '../../utils/scopeRows' |
| 3 | 3 | |
| 4 | 4 | /** |
| 5 | - * One row of "Items in this project" (mockup 9 and 10): switch, name + type + origin, status cell with its inline | |
| 6 | - * action, Automatic / On demand for skills, and the inline resolve box. Editing only changes the config (autosaved); | |
| 7 | - * files change on Apply. | |
| 5 | + * One row of the Items list (mockup 9 and 10): switch (managed items), name + type + origin (and the path on disk | |
| 6 | + * for items skilly doesn't manage), status cell with its inline action, Automatic / On demand for skills, a "…" menu | |
| 7 | + * (Global: "Move to projects…") and the inline resolve box. Clicking the name opens the item drawer. | |
| 8 | + * Editing only changes the config (autosaved); files change on Apply. | |
| 8 | 9 | */ |
| 9 | -const props = defineProps<{ row: ScopeRow, root: string, home?: boolean }>() | |
| 10 | -const emit = defineEmits<{ toggle: [on: boolean], mode: [onDemand: boolean], review: [], import: [] }>() | |
| 10 | +const props = defineProps<{ | |
| 11 | + row: ScopeRow | |
| 12 | + root: string | |
| 13 | + home?: boolean | |
| 14 | + /** Path on disk, shown under the origin for items skilly doesn't manage. */ | |
| 15 | + location?: string | |
| 16 | + /** "Project-specific" hint (Global). */ | |
| 17 | + flag?: string | |
| 18 | + canMove?: boolean | |
| 19 | +}>() | |
| 20 | +const emit = defineEmits<{ toggle: [on: boolean], mode: [onDemand: boolean], review: [], import: [], manage: [], open: [], move: [] }>() | |
| 21 | + | |
| 22 | +const clickable = computed(() => props.row.itemIds.length > 0 || props.row.inCatalog) | |
| 23 | +const menu = computed(() => [[{ label: 'Move to projects…', icon: 'i-lucide-folder-input', onSelect: () => emit('move') }]]) | |
| 11 | 24 | |
| 12 | 25 | const open = ref(false) |
| 13 | 26 | const faded = computed(() => props.row.excluded || props.row.kind === 'removal') |
| @@ -30,6 +43,7 @@ const MODE_OPTIONS = computed(() => [ | ||
| 30 | 43 | function onAction(kind: RowAction) { |
| 31 | 44 | if (kind === 'resolve') open.value = !open.value |
| 32 | 45 | else if (kind === 'review') emit('review') |
| 46 | + else if (kind === 'manage') emit('manage') | |
| 33 | 47 | else emit('import') |
| 34 | 48 | } |
| 35 | 49 | </script> |
| @@ -51,13 +65,23 @@ function onAction(kind: RowAction) { | ||
| 51 | 65 | |
| 52 | 66 | <div class="min-w-0" :class="faded && 'opacity-55'"> |
| 53 | 67 | <div class="flex items-baseline gap-2"> |
| 54 | - <NuxtLink v-if="row.inCatalog" :to="`/catalog/${row.type}/${encodeURIComponent(row.name)}`" class="truncate font-medium text-sk-text hover:underline">{{ row.name }}</NuxtLink> | |
| 55 | - <span v-else class="truncate font-medium">{{ row.name }}</span> | |
| 68 | + <button | |
| 69 | + v-if="clickable" | |
| 70 | + type="button" | |
| 71 | + class="cursor-pointer truncate border-0 bg-transparent p-0 text-left font-medium text-sk-text hover:underline" | |
| 72 | + :title="`Details of ${row.label}`" | |
| 73 | + @click="emit('open')" | |
| 74 | + > | |
| 75 | + {{ row.label }} | |
| 76 | + </button> | |
| 77 | + <span v-else class="truncate font-medium">{{ row.label }}</span> | |
| 56 | 78 | <SkTypeTag :type="row.type" /> |
| 79 | + <span v-if="flag" class="text-[11.5px] font-medium whitespace-nowrap text-sk-accent-text" :title="flag">Project-specific</span> | |
| 57 | 80 | </div> |
| 58 | - <div class="flex flex-wrap items-center gap-x-2 text-xs text-sk-muted"> | |
| 81 | + <div class="flex min-w-0 flex-wrap items-center gap-x-2 text-xs text-sk-muted"> | |
| 59 | 82 | <span>{{ row.origin }}</span> |
| 60 | 83 | <span v-if="row.kind === 'entry' && !row.inCatalog" class="font-mono text-[11px] text-sk-accent-text">not in catalog</span> |
| 84 | + <span v-if="location" class="min-w-0 truncate font-mono text-[11px] text-sk-faint" :title="location">{{ location }}</span> | |
| 61 | 85 | </div> |
| 62 | 86 | </div> |
| 63 | 87 | |
| @@ -70,6 +94,9 @@ function onAction(kind: RowAction) { | ||
| 70 | 94 | <SkSeg v-model="mode" :options="MODE_OPTIONS" size="xs" aria-label="Skill mode" /> |
| 71 | 95 | <p v-if="row.onDemand.pattern" class="m-0 mt-0.5 text-right font-mono text-[11px] text-sk-faint">matches {{ row.onDemand.pattern }}</p> |
| 72 | 96 | </template> |
| 97 | + <UDropdownMenu v-else-if="canMove" :items="menu" :content="{ align: 'end' }"> | |
| 98 | + <SkButton variant="ghost" size="sm" icon="i-lucide-ellipsis" :aria-label="`More actions for ${row.label}`" /> | |
| 99 | + </UDropdownMenu> | |
| 73 | 100 | </div> |
| 74 | 101 | </div> |
| 75 | 102 | |
| @@ -2,12 +2,25 @@ | |||
| 2 | import type { RowAction, ScopeRow } from '../../utils/scopeRows' | 2 | import type { RowAction, ScopeRow } from '../../utils/scopeRows' |
| 3 | 3 | ||
| 4 | /** | 4 | /** |
| 5 | - * One row of "Items in this project" (mockup 9 and 10): switch, name + type + origin, status cell with its inline | 5 | + * One row of the Items list (mockup 9 and 10): switch (managed items), name + type + origin (and the path on disk |
| 6 | - * action, Automatic / On demand for skills, and the inline resolve box. Editing only changes the config (autosaved); | 6 | + * for items skilly doesn't manage), status cell with its inline action, Automatic / On demand for skills, a "…" menu |
| 7 | - * files change on Apply. | 7 | + * (Global: "Move to projects…") and the inline resolve box. Clicking the name opens the item drawer. |
| 8 | + * Editing only changes the config (autosaved); files change on Apply. | ||
| 8 | */ | 9 | */ |
| 9 | -const props = defineProps<{ row: ScopeRow, root: string, home?: boolean }>() | 10 | +const props = defineProps<{ |
| 10 | -const emit = defineEmits<{ toggle: [on: boolean], mode: [onDemand: boolean], review: [], import: [] }>() | 11 | + row: ScopeRow |
| 12 | + root: string | ||
| 13 | + home?: boolean | ||
| 14 | + /** Path on disk, shown under the origin for items skilly doesn't manage. */ | ||
| 15 | + location?: string | ||
| 16 | + /** "Project-specific" hint (Global). */ | ||
| 17 | + flag?: string | ||
| 18 | + canMove?: boolean | ||
| 19 | +}>() | ||
| 20 | +const emit = defineEmits<{ toggle: [on: boolean], mode: [onDemand: boolean], review: [], import: [], manage: [], open: [], move: [] }>() | ||
| 21 | + | ||
| 22 | +const clickable = computed(() => props.row.itemIds.length > 0 || props.row.inCatalog) | ||
| 23 | +const menu = computed(() => [[{ label: 'Move to projects…', icon: 'i-lucide-folder-input', onSelect: () => emit('move') }]]) | ||
| 11 | 24 | ||
| 12 | const open = ref(false) | 25 | const open = ref(false) |
| 13 | const faded = computed(() => props.row.excluded || props.row.kind === 'removal') | 26 | const faded = computed(() => props.row.excluded || props.row.kind === 'removal') |
| @@ -30,6 +43,7 @@ const MODE_OPTIONS = computed(() => [ | |||
| 30 | function onAction(kind: RowAction) { | 43 | function onAction(kind: RowAction) { |
| 31 | if (kind === 'resolve') open.value = !open.value | 44 | if (kind === 'resolve') open.value = !open.value |
| 32 | else if (kind === 'review') emit('review') | 45 | else if (kind === 'review') emit('review') |
| 46 | + else if (kind === 'manage') emit('manage') | ||
| 33 | else emit('import') | 47 | else emit('import') |
| 34 | } | 48 | } |
| 35 | </script> | 49 | </script> |
| @@ -51,13 +65,23 @@ function onAction(kind: RowAction) { | |||
| 51 | 65 | ||
| 52 | <div class="min-w-0" :class="faded && 'opacity-55'"> | 66 | <div class="min-w-0" :class="faded && 'opacity-55'"> |
| 53 | <div class="flex items-baseline gap-2"> | 67 | <div class="flex items-baseline gap-2"> |
| 54 | - <NuxtLink v-if="row.inCatalog" :to="`/catalog/${row.type}/${encodeURIComponent(row.name)}`" class="truncate font-medium text-sk-text hover:underline">{{ row.name }}</NuxtLink> | 68 | + <button |
| 55 | - <span v-else class="truncate font-medium">{{ row.name }}</span> | 69 | + v-if="clickable" |
| 70 | + type="button" | ||
| 71 | + class="cursor-pointer truncate border-0 bg-transparent p-0 text-left font-medium text-sk-text hover:underline" | ||
| 72 | + :title="`Details of ${row.label}`" | ||
| 73 | + @click="emit('open')" | ||
| 74 | + > | ||
| 75 | + {{ row.label }} | ||
| 76 | + </button> | ||
| 77 | + <span v-else class="truncate font-medium">{{ row.label }}</span> | ||
| 56 | <SkTypeTag :type="row.type" /> | 78 | <SkTypeTag :type="row.type" /> |
| 79 | + <span v-if="flag" class="text-[11.5px] font-medium whitespace-nowrap text-sk-accent-text" :title="flag">Project-specific</span> | ||
| 57 | </div> | 80 | </div> |
| 58 | - <div class="flex flex-wrap items-center gap-x-2 text-xs text-sk-muted"> | 81 | + <div class="flex min-w-0 flex-wrap items-center gap-x-2 text-xs text-sk-muted"> |
| 59 | <span>{{ row.origin }}</span> | 82 | <span>{{ row.origin }}</span> |
| 60 | <span v-if="row.kind === 'entry' && !row.inCatalog" class="font-mono text-[11px] text-sk-accent-text">not in catalog</span> | 83 | <span v-if="row.kind === 'entry' && !row.inCatalog" class="font-mono text-[11px] text-sk-accent-text">not in catalog</span> |
| 84 | + <span v-if="location" class="min-w-0 truncate font-mono text-[11px] text-sk-faint" :title="location">{{ location }}</span> | ||
| 61 | </div> | 85 | </div> |
| 62 | </div> | 86 | </div> |
| 63 | 87 | ||
| @@ -70,6 +94,9 @@ function onAction(kind: RowAction) { | |||
| 70 | <SkSeg v-model="mode" :options="MODE_OPTIONS" size="xs" aria-label="Skill mode" /> | 94 | <SkSeg v-model="mode" :options="MODE_OPTIONS" size="xs" aria-label="Skill mode" /> |
| 71 | <p v-if="row.onDemand.pattern" class="m-0 mt-0.5 text-right font-mono text-[11px] text-sk-faint">matches {{ row.onDemand.pattern }}</p> | 95 | <p v-if="row.onDemand.pattern" class="m-0 mt-0.5 text-right font-mono text-[11px] text-sk-faint">matches {{ row.onDemand.pattern }}</p> |
| 72 | </template> | 96 | </template> |
| 97 | + <UDropdownMenu v-else-if="canMove" :items="menu" :content="{ align: 'end' }"> | ||
| 98 | + <SkButton variant="ghost" size="sm" icon="i-lucide-ellipsis" :aria-label="`More actions for ${row.label}`" /> | ||
| 99 | + </UDropdownMenu> | ||
| 73 | </div> | 100 | </div> |
| 74 | </div> | 101 | </div> |
| 75 | 102 | ||
modified
packages/ui/app/components/scope/ScopeItemsSection.vue +159 -43 | @@ -1,12 +1,26 @@ | ||
| 1 | 1 | <script setup lang="ts"> |
| 2 | -import type { CatalogItemType } from '@skilly/core' | |
| 2 | +import type { CatalogItemType, InventoryItem } from '@skilly/core' | |
| 3 | 3 | import type { HomeNames, ScopeRow } from '../../utils/scopeRows' |
| 4 | +import type { ProjectSpecific } from '../../utils/inventoryView' | |
| 5 | + | |
| 6 | +/** | |
| 7 | + * The single "Items" list of a project or of Global (mockup 9-10, merged with the old Files tab): what skilly manages | |
| 8 | + * (switch, status, mode, inline resolution) and what is only on disk (local and plugin items, with "Import" to the | |
| 9 | + * catalog and, in Global, "Move to projects…"), grouped by type. Filters: type, origin (managed / on disk only), search. | |
| 10 | + * Works without a config: the scope then only lists what is on disk. | |
| 11 | + */ | |
| 12 | +const props = defineProps<{ | |
| 13 | + /** Global items that look project-specific, by inventory item id. */ | |
| 14 | + flagged?: Map<string, ProjectSpecific> | |
| 15 | + /** Restricts the list to rows behind these inventory item ids ("Review" in the tidy-up panel). */ | |
| 16 | + onlyIds?: Set<string> | null | |
| 17 | +}>() | |
| 4 | 18 | |
| 5 | -/** "Items in this project": one row per item with switch, status, mode and inline resolution (mockup 9-10). */ | |
| 6 | 19 | const scope = useScopeContext() |
| 7 | 20 | const catalog = useCatalog() |
| 8 | 21 | const { home: homeInv, refresh: refreshHome } = useHomeInventory() |
| 9 | 22 | const home = scope.kind === 'home' |
| 23 | +const managed = computed(() => !!scope.config.value) | |
| 10 | 24 | |
| 11 | 25 | onMounted(() => { |
| 12 | 26 | if (!home && !homeInv.value) refreshHome() |
| @@ -21,31 +35,75 @@ const homeNames = computed<HomeNames>(() => { | ||
| 21 | 35 | return out |
| 22 | 36 | }) |
| 23 | 37 | |
| 24 | -const rows = computed<ScopeRow[]>(() => { | |
| 25 | - const config = scope.config.value | |
| 26 | - if (!config) return [] | |
| 27 | - return buildRows({ | |
| 28 | - config, | |
| 29 | - presets: catalog.presets.value, | |
| 30 | - catalogItems: catalog.items.value, | |
| 31 | - plan: scope.plan.value, | |
| 32 | - inventory: scope.inventory.value, | |
| 33 | - statuses: scope.statuses.value, | |
| 34 | - homeNames: home ? undefined : homeNames.value, | |
| 35 | - includeLocal: !home | |
| 36 | - }) | |
| 37 | -}) | |
| 38 | +const rows = computed<ScopeRow[]>(() => buildRows({ | |
| 39 | + config: scope.config.value, | |
| 40 | + presets: catalog.presets.value, | |
| 41 | + catalogItems: catalog.items.value, | |
| 42 | + plan: scope.plan.value, | |
| 43 | + inventory: scope.inventory.value, | |
| 44 | + statuses: scope.statuses.value, | |
| 45 | + homeNames: home ? undefined : homeNames.value, | |
| 46 | + includeLocal: true, | |
| 47 | + home | |
| 48 | +})) | |
| 38 | 49 | |
| 39 | -const hint = computed(() => writtenToHint(scope.config.value?.targets ?? [], home)) | |
| 50 | +const itemsById = computed(() => new Map((scope.inventory.value?.items ?? []).map(i => [i.id, i]))) | |
| 51 | +const itemsOf = (r: ScopeRow) => r.itemIds.map(id => itemsById.value.get(id)).filter((i): i is InventoryItem => !!i) | |
| 40 | 52 | |
| 41 | -const patterns = computed(() => { | |
| 42 | - const c = scope.config.value | |
| 43 | - if (!c) return [] | |
| 44 | - const out: string[] = [] | |
| 45 | - for (const [t, l] of Object.entries(c.exclude)) for (const p of l ?? []) if (p.includes('*')) out.push(`exclude ${t}: ${p}`) | |
| 46 | - for (const p of c.onDemand.skills) if (p.includes('*')) out.push(`on demand: ${p}`) | |
| 47 | - return out | |
| 53 | +/* ---------------------------------------------------------------- filters */ | |
| 54 | + | |
| 55 | +type Origin = 'all' | 'managed' | 'local' | |
| 56 | +const typeFilter = ref<'all' | CatalogItemType>('all') | |
| 57 | +const originFilter = ref<Origin>('all') | |
| 58 | +const search = ref('') | |
| 59 | + | |
| 60 | +const isManagedRow = (r: ScopeRow) => r.kind !== 'local' | |
| 61 | +const base = computed(() => { | |
| 62 | + const q = search.value.trim().toLowerCase() | |
| 63 | + return rows.value.filter(r => | |
| 64 | + (originFilter.value === 'all' || (originFilter.value === 'managed') === isManagedRow(r)) | |
| 65 | + && (!q || `${r.label} ${r.name} ${r.origin}`.toLowerCase().includes(q)) | |
| 66 | + && (!props.onlyIds || r.itemIds.some(id => props.onlyIds!.has(id)))) | |
| 67 | +}) | |
| 68 | +const counts = computed(() => { | |
| 69 | + const c = Object.fromEntries(ROW_TYPE_ORDER.map(t => [t, 0])) as Record<CatalogItemType, number> | |
| 70 | + for (const r of base.value) c[r.type]++ | |
| 71 | + return c | |
| 48 | 72 | }) |
| 73 | +const typeOptions = computed(() => [ | |
| 74 | + { value: 'all' as const, label: 'All', count: base.value.length }, | |
| 75 | + ...ROW_TYPE_ORDER.map(t => ({ value: t, label: TYPE_PLURAL[t], count: counts.value[t] })) | |
| 76 | +]) | |
| 77 | +const originOptions = computed(() => [ | |
| 78 | + { value: 'all' as const, label: 'All' }, | |
| 79 | + { value: 'managed' as const, label: 'Managed by skilly', count: rows.value.filter(isManagedRow).length }, | |
| 80 | + { value: 'local' as const, label: 'Not managed', count: rows.value.filter(r => !isManagedRow(r)).length } | |
| 81 | +]) | |
| 82 | + | |
| 83 | +const groups = computed(() => ROW_TYPE_ORDER | |
| 84 | + .filter(t => typeFilter.value === 'all' || typeFilter.value === t) | |
| 85 | + .map(t => ({ type: t, rows: base.value.filter(r => r.type === t) })) | |
| 86 | + .filter(g => g.rows.length)) | |
| 87 | + | |
| 88 | +/** Global MCP servers and hooks are entries of user config files: read-only in v1. */ | |
| 89 | +const readOnly = (t: CatalogItemType) => home && (t === 'mcp' || t === 'hook') | |
| 90 | + | |
| 91 | +/* ---------------------------------------------------------------- row helpers */ | |
| 92 | + | |
| 93 | +const root = computed(() => scope.inventory.value?.root ?? scope.rootPath.value) | |
| 94 | +function locationOf(r: ScopeRow): string | undefined { | |
| 95 | + if (r.kind !== 'local') return undefined | |
| 96 | + const first = itemsOf(r)[0] | |
| 97 | + return first ? itemLocation(first, root.value, home ? '~/' : '') : undefined | |
| 98 | +} | |
| 99 | +const flagOf = (r: ScopeRow) => r.itemIds.map(id => props.flagged?.get(id)).find(Boolean) | |
| 100 | +const movable = (r: ScopeRow) => (home && r.kind === 'local' ? itemsOf(r).filter(i => canMoveOutOfHome(i, scope.statuses.value[i.id])) : []) | |
| 101 | + | |
| 102 | +function onOpen(r: ScopeRow) { | |
| 103 | + const first = itemsOf(r)[0] | |
| 104 | + if (first) scope.openItem(first) | |
| 105 | + else if (r.inCatalog) navigateTo(`/catalog/${r.type}/${encodeURIComponent(r.name)}`) | |
| 106 | +} | |
| 49 | 107 | |
| 50 | 108 | function onToggle(row: ScopeRow, on: boolean) { |
| 51 | 109 | scope.edit((c) => { |
| @@ -66,11 +124,44 @@ function onMode(row: ScopeRow, onDemand: boolean) { | ||
| 66 | 124 | scope.edit((c) => { c.onDemand.skills = toggleIn(c.onDemand.skills, row.name, onDemand) }) |
| 67 | 125 | } |
| 68 | 126 | |
| 127 | +const importableOf = (r: ScopeRow) => itemsOf(r).filter((i) => { | |
| 128 | + const st = scope.statuses.value[i.id] | |
| 129 | + return !i.source.plugin && !st?.managed && (st?.state ?? 'not-in-catalog') !== 'in-sync' | |
| 130 | +}) | |
| 69 | 131 | function onImport(row: ScopeRow) { |
| 70 | - const items = (scope.inventory.value?.items ?? []).filter(i => i.type === row.type && i.name === row.name && !scope.statuses.value[i.id]?.managed) | |
| 132 | + const items = importableOf(row) | |
| 71 | 133 | if (items.length) scope.askImport(items) |
| 72 | 134 | } |
| 73 | 135 | |
| 136 | +/** Every local item that can go to the catalog (not there yet, or with different content), for the bulk import. */ | |
| 137 | +const importable = computed(() => rows.value.filter(r => r.statuses.some(s => s.action === 'import')).flatMap(importableOf)) | |
| 138 | + | |
| 139 | +/** Catalog refs of the local items of a row that are identical to a catalog item ("Manage"). */ | |
| 140 | +function manageRefsOf(r: ScopeRow): { type: CatalogItemType, name: string }[] { | |
| 141 | + if (!r.statuses.some(s => s.action === 'manage')) return [] | |
| 142 | + const refs = new Map<string, { type: CatalogItemType, name: string }>() | |
| 143 | + for (const i of itemsOf(r)) { | |
| 144 | + const ref = scope.statuses.value[i.id]?.catalogRef | |
| 145 | + if (ref && scope.statuses.value[i.id]?.state === 'in-sync') refs.set(`${ref.type}/${ref.name}`, ref) | |
| 146 | + } | |
| 147 | + return [...refs.values()] | |
| 148 | +} | |
| 149 | +const manageable = computed(() => rows.value.flatMap(manageRefsOf)) | |
| 150 | +function onManage(r: ScopeRow) { | |
| 151 | + scope.manage(manageRefsOf(r)) | |
| 152 | +} | |
| 153 | + | |
| 154 | +const hint = computed(() => (scope.config.value ? writtenToHint(scope.config.value.targets, home) : undefined)) | |
| 155 | + | |
| 156 | +const patterns = computed(() => { | |
| 157 | + const c = scope.config.value | |
| 158 | + if (!c) return [] | |
| 159 | + const out: string[] = [] | |
| 160 | + for (const [t, l] of Object.entries(c.exclude)) for (const p of l ?? []) if (p.includes('*')) out.push(`exclude ${t}: ${p}`) | |
| 161 | + for (const p of c.onDemand.skills) if (p.includes('*')) out.push(`on demand: ${p}`) | |
| 162 | + return out | |
| 163 | +}) | |
| 164 | + | |
| 74 | 165 | /* ---------------------------------------------------------------- add items */ |
| 75 | 166 | |
| 76 | 167 | const ADD_TYPES = computed<CatalogItemType[]>(() => (home ? ['skill', 'rule', 'agent'] : ['skill', 'rule', 'agent', 'mcp', 'hook'])) |
| @@ -104,34 +195,59 @@ function confirmPicker() { | ||
| 104 | 195 | </script> |
| 105 | 196 | |
| 106 | 197 | <template> |
| 107 | - <SkSection :title="home ? 'Items in Home' : 'Items in this project'"> | |
| 198 | + <SkSection :title="home ? 'Items in Global' : 'Items in this project'"> | |
| 108 | 199 | <template #actions> |
| 109 | - <span class="hidden text-pretty text-sk-muted sm:inline">{{ hint }}</span> | |
| 110 | - <SkButton label="Add items" :disabled="!pickable.length" @click="openPicker" /> | |
| 200 | + <span v-if="hint" class="hidden text-pretty text-sk-muted sm:inline">{{ hint }}</span> | |
| 201 | + <SkButton v-if="manageable.length" :label="`Manage all in catalog (${manageable.length})`" title="These items are identical to the catalog version: let skilly manage them here" @click="scope.manage(manageable)" /> | |
| 202 | + <SkButton v-if="importable.length" :label="`Import all to catalog (${importable.length})`" @click="scope.askImport(importable)" /> | |
| 203 | + <SkButton v-if="managed" label="Add items" :disabled="!pickable.length" @click="openPicker" /> | |
| 111 | 204 | </template> |
| 112 | 205 | |
| 206 | + <div class="flex flex-wrap items-center gap-x-3 gap-y-2"> | |
| 207 | + <SkSeg v-model="typeFilter" :options="typeOptions" size="md" aria-label="Filter by type" /> | |
| 208 | + <span class="flex-1" /> | |
| 209 | + <SkSeg v-model="originFilter" :options="originOptions" size="md" aria-label="Filter by origin" /> | |
| 210 | + <UInput v-model="search" placeholder="Search items" size="sm" class="w-56" aria-label="Search items" /> | |
| 211 | + </div> | |
| 212 | + | |
| 113 | 213 | <SkPanel> |
| 114 | - <ScopeItemRow | |
| 115 | - v-for="r in rows" | |
| 116 | - :key="r.key" | |
| 117 | - :row="r" | |
| 118 | - :root="scope.plan.value?.root ?? scope.rootPath.value" | |
| 119 | - :home="home" | |
| 120 | - @toggle="(on) => onToggle(r, on)" | |
| 121 | - @mode="(d) => onMode(r, d)" | |
| 122 | - @review="scope.openReview()" | |
| 123 | - @import="onImport(r)" | |
| 124 | - /> | |
| 125 | - <SkEmpty v-if="!rows.length"> | |
| 126 | - {{ catalog.exists.value ? 'Nothing enabled yet. Add a preset above, or add items one by one.' : 'No catalog yet. Create the catalog from the Catalog page, then enable presets and items here.' }} | |
| 214 | + <template v-for="g in groups" :key="g.type"> | |
| 215 | + <SkSectionLabel :count="g.rows.length" class="border-t border-sk-line2 px-4 pt-3 pb-1.5 first:border-t-0"> | |
| 216 | + {{ TYPE_PLURAL[g.type] }} | |
| 217 | + <span v-if="readOnly(g.type)" class="normal-case tracking-normal" title="Entries of your user configuration files: shown read-only in v1. You can still import them to the catalog.">· read-only in v1</span> | |
| 218 | + </SkSectionLabel> | |
| 219 | + <ScopeItemRow | |
| 220 | + v-for="r in g.rows" | |
| 221 | + :key="r.key" | |
| 222 | + :row="r" | |
| 223 | + :root="scope.plan.value?.root ?? scope.rootPath.value" | |
| 224 | + :home="home" | |
| 225 | + :location="locationOf(r)" | |
| 226 | + :flag="flagOf(r)?.hint" | |
| 227 | + :can-move="movable(r).length > 0" | |
| 228 | + @open="onOpen(r)" | |
| 229 | + @toggle="(on) => onToggle(r, on)" | |
| 230 | + @mode="(d) => onMode(r, d)" | |
| 231 | + @review="scope.openReview()" | |
| 232 | + @import="onImport(r)" | |
| 233 | + @manage="onManage(r)" | |
| 234 | + @move="scope.askMove(movable(r))" | |
| 235 | + /> | |
| 236 | + </template> | |
| 237 | + <SkEmpty v-if="!groups.length"> | |
| 238 | + <template v-if="rows.length">Nothing matches these filters.</template> | |
| 239 | + <template v-else-if="managed"> | |
| 240 | + {{ catalog.exists.value ? 'Nothing here yet. Add a preset above, or add items one by one.' : 'No catalog yet. Create the catalog from the Catalog page, then enable presets and items here.' }} | |
| 241 | + </template> | |
| 242 | + <template v-else>No skill, rule, MCP server, hook or subagent found here.</template> | |
| 127 | 243 | </SkEmpty> |
| 128 | 244 | </SkPanel> |
| 129 | 245 | <p v-if="patterns.length" class="m-0 font-mono text-[11px] text-sk-faint">Patterns kept as is: {{ patterns.join(' · ') }}</p> |
| 130 | 246 | </SkSection> |
| 131 | 247 | |
| 132 | - <SkModal v-model:open="pickerOpen" :title="home ? 'Add items to Home' : 'Add items'" description="Pick items from the catalog to enable one by one." width="lg"> | |
| 248 | + <SkModal v-model:open="pickerOpen" :title="home ? 'Add items to Global' : 'Add items'" description="Pick items from the catalog to enable one by one." width="lg"> | |
| 133 | 249 | <ItemPicker v-model="pickerSel" :items="pickable" :types="ADD_TYPES" :locked="locked" height="h-80" /> |
| 134 | - <p v-if="home" class="m-0 mt-2.5 text-xs text-sk-muted">Home can receive skills, rules and subagents. Its MCP servers and hooks are read-only in v1.</p> | |
| 250 | + <p v-if="home" class="m-0 mt-2.5 text-xs text-sk-muted">Global can receive skills, rules and subagents. Its MCP servers and hooks are read-only in v1.</p> | |
| 135 | 251 | <template #footer> |
| 136 | 252 | <SkButton label="Cancel" @click="pickerOpen = false" /> |
| 137 | 253 | <SkButton variant="primary" label="Save selection" @click="confirmPicker" /> |
| @@ -1,12 +1,26 @@ | |||
| 1 | <script setup lang="ts"> | 1 | <script setup lang="ts"> |
| 2 | -import type { CatalogItemType } from '@skilly/core' | 2 | +import type { CatalogItemType, InventoryItem } from '@skilly/core' |
| 3 | import type { HomeNames, ScopeRow } from '../../utils/scopeRows' | 3 | import type { HomeNames, ScopeRow } from '../../utils/scopeRows' |
| 4 | +import type { ProjectSpecific } from '../../utils/inventoryView' | ||
| 5 | + | ||
| 6 | +/** | ||
| 7 | + * The single "Items" list of a project or of Global (mockup 9-10, merged with the old Files tab): what skilly manages | ||
| 8 | + * (switch, status, mode, inline resolution) and what is only on disk (local and plugin items, with "Import" to the | ||
| 9 | + * catalog and, in Global, "Move to projects…"), grouped by type. Filters: type, origin (managed / on disk only), search. | ||
| 10 | + * Works without a config: the scope then only lists what is on disk. | ||
| 11 | + */ | ||
| 12 | +const props = defineProps<{ | ||
| 13 | + /** Global items that look project-specific, by inventory item id. */ | ||
| 14 | + flagged?: Map<string, ProjectSpecific> | ||
| 15 | + /** Restricts the list to rows behind these inventory item ids ("Review" in the tidy-up panel). */ | ||
| 16 | + onlyIds?: Set<string> | null | ||
| 17 | +}>() | ||
| 4 | 18 | ||
| 5 | -/** "Items in this project": one row per item with switch, status, mode and inline resolution (mockup 9-10). */ | ||
| 6 | const scope = useScopeContext() | 19 | const scope = useScopeContext() |
| 7 | const catalog = useCatalog() | 20 | const catalog = useCatalog() |
| 8 | const { home: homeInv, refresh: refreshHome } = useHomeInventory() | 21 | const { home: homeInv, refresh: refreshHome } = useHomeInventory() |
| 9 | const home = scope.kind === 'home' | 22 | const home = scope.kind === 'home' |
| 23 | +const managed = computed(() => !!scope.config.value) | ||
| 10 | 24 | ||
| 11 | onMounted(() => { | 25 | onMounted(() => { |
| 12 | if (!home && !homeInv.value) refreshHome() | 26 | if (!home && !homeInv.value) refreshHome() |
| @@ -21,31 +35,75 @@ const homeNames = computed<HomeNames>(() => { | |||
| 21 | return out | 35 | return out |
| 22 | }) | 36 | }) |
| 23 | 37 | ||
| 24 | -const rows = computed<ScopeRow[]>(() => { | 38 | +const rows = computed<ScopeRow[]>(() => buildRows({ |
| 25 | - const config = scope.config.value | 39 | + config: scope.config.value, |
| 26 | - if (!config) return [] | 40 | + presets: catalog.presets.value, |
| 27 | - return buildRows({ | 41 | + catalogItems: catalog.items.value, |
| 28 | - config, | 42 | + plan: scope.plan.value, |
| 29 | - presets: catalog.presets.value, | 43 | + inventory: scope.inventory.value, |
| 30 | - catalogItems: catalog.items.value, | 44 | + statuses: scope.statuses.value, |
| 31 | - plan: scope.plan.value, | 45 | + homeNames: home ? undefined : homeNames.value, |
| 32 | - inventory: scope.inventory.value, | 46 | + includeLocal: true, |
| 33 | - statuses: scope.statuses.value, | 47 | + home |
| 34 | - homeNames: home ? undefined : homeNames.value, | 48 | +})) |
| 35 | - includeLocal: !home | ||
| 36 | - }) | ||
| 37 | -}) | ||
| 38 | 49 | ||
| 39 | -const hint = computed(() => writtenToHint(scope.config.value?.targets ?? [], home)) | 50 | +const itemsById = computed(() => new Map((scope.inventory.value?.items ?? []).map(i => [i.id, i]))) |
| 51 | +const itemsOf = (r: ScopeRow) => r.itemIds.map(id => itemsById.value.get(id)).filter((i): i is InventoryItem => !!i) | ||
| 40 | 52 | ||
| 41 | -const patterns = computed(() => { | 53 | +/* ---------------------------------------------------------------- filters */ |
| 42 | - const c = scope.config.value | 54 | + |
| 43 | - if (!c) return [] | 55 | +type Origin = 'all' | 'managed' | 'local' |
| 44 | - const out: string[] = [] | 56 | +const typeFilter = ref<'all' | CatalogItemType>('all') |
| 45 | - for (const [t, l] of Object.entries(c.exclude)) for (const p of l ?? []) if (p.includes('*')) out.push(`exclude ${t}: ${p}`) | 57 | +const originFilter = ref<Origin>('all') |
| 46 | - for (const p of c.onDemand.skills) if (p.includes('*')) out.push(`on demand: ${p}`) | 58 | +const search = ref('') |
| 47 | - return out | 59 | + |
| 60 | +const isManagedRow = (r: ScopeRow) => r.kind !== 'local' | ||
| 61 | +const base = computed(() => { | ||
| 62 | + const q = search.value.trim().toLowerCase() | ||
| 63 | + return rows.value.filter(r => | ||
| 64 | + (originFilter.value === 'all' || (originFilter.value === 'managed') === isManagedRow(r)) | ||
| 65 | + && (!q || `${r.label} ${r.name} ${r.origin}`.toLowerCase().includes(q)) | ||
| 66 | + && (!props.onlyIds || r.itemIds.some(id => props.onlyIds!.has(id)))) | ||
| 67 | +}) | ||
| 68 | +const counts = computed(() => { | ||
| 69 | + const c = Object.fromEntries(ROW_TYPE_ORDER.map(t => [t, 0])) as Record<CatalogItemType, number> | ||
| 70 | + for (const r of base.value) c[r.type]++ | ||
| 71 | + return c | ||
| 48 | }) | 72 | }) |
| 73 | +const typeOptions = computed(() => [ | ||
| 74 | + { value: 'all' as const, label: 'All', count: base.value.length }, | ||
| 75 | + ...ROW_TYPE_ORDER.map(t => ({ value: t, label: TYPE_PLURAL[t], count: counts.value[t] })) | ||
| 76 | +]) | ||
| 77 | +const originOptions = computed(() => [ | ||
| 78 | + { value: 'all' as const, label: 'All' }, | ||
| 79 | + { value: 'managed' as const, label: 'Managed by skilly', count: rows.value.filter(isManagedRow).length }, | ||
| 80 | + { value: 'local' as const, label: 'Not managed', count: rows.value.filter(r => !isManagedRow(r)).length } | ||
| 81 | +]) | ||
| 82 | + | ||
| 83 | +const groups = computed(() => ROW_TYPE_ORDER | ||
| 84 | + .filter(t => typeFilter.value === 'all' || typeFilter.value === t) | ||
| 85 | + .map(t => ({ type: t, rows: base.value.filter(r => r.type === t) })) | ||
| 86 | + .filter(g => g.rows.length)) | ||
| 87 | + | ||
| 88 | +/** Global MCP servers and hooks are entries of user config files: read-only in v1. */ | ||
| 89 | +const readOnly = (t: CatalogItemType) => home && (t === 'mcp' || t === 'hook') | ||
| 90 | + | ||
| 91 | +/* ---------------------------------------------------------------- row helpers */ | ||
| 92 | + | ||
| 93 | +const root = computed(() => scope.inventory.value?.root ?? scope.rootPath.value) | ||
| 94 | +function locationOf(r: ScopeRow): string | undefined { | ||
| 95 | + if (r.kind !== 'local') return undefined | ||
| 96 | + const first = itemsOf(r)[0] | ||
| 97 | + return first ? itemLocation(first, root.value, home ? '~/' : '') : undefined | ||
| 98 | +} | ||
| 99 | +const flagOf = (r: ScopeRow) => r.itemIds.map(id => props.flagged?.get(id)).find(Boolean) | ||
| 100 | +const movable = (r: ScopeRow) => (home && r.kind === 'local' ? itemsOf(r).filter(i => canMoveOutOfHome(i, scope.statuses.value[i.id])) : []) | ||
| 101 | + | ||
| 102 | +function onOpen(r: ScopeRow) { | ||
| 103 | + const first = itemsOf(r)[0] | ||
| 104 | + if (first) scope.openItem(first) | ||
| 105 | + else if (r.inCatalog) navigateTo(`/catalog/${r.type}/${encodeURIComponent(r.name)}`) | ||
| 106 | +} | ||
| 49 | 107 | ||
| 50 | function onToggle(row: ScopeRow, on: boolean) { | 108 | function onToggle(row: ScopeRow, on: boolean) { |
| 51 | scope.edit((c) => { | 109 | scope.edit((c) => { |
| @@ -66,11 +124,44 @@ function onMode(row: ScopeRow, onDemand: boolean) { | |||
| 66 | scope.edit((c) => { c.onDemand.skills = toggleIn(c.onDemand.skills, row.name, onDemand) }) | 124 | scope.edit((c) => { c.onDemand.skills = toggleIn(c.onDemand.skills, row.name, onDemand) }) |
| 67 | } | 125 | } |
| 68 | 126 | ||
| 127 | +const importableOf = (r: ScopeRow) => itemsOf(r).filter((i) => { | ||
| 128 | + const st = scope.statuses.value[i.id] | ||
| 129 | + return !i.source.plugin && !st?.managed && (st?.state ?? 'not-in-catalog') !== 'in-sync' | ||
| 130 | +}) | ||
| 69 | function onImport(row: ScopeRow) { | 131 | function onImport(row: ScopeRow) { |
| 70 | - const items = (scope.inventory.value?.items ?? []).filter(i => i.type === row.type && i.name === row.name && !scope.statuses.value[i.id]?.managed) | 132 | + const items = importableOf(row) |
| 71 | if (items.length) scope.askImport(items) | 133 | if (items.length) scope.askImport(items) |
| 72 | } | 134 | } |
| 73 | 135 | ||
| 136 | +/** Every local item that can go to the catalog (not there yet, or with different content), for the bulk import. */ | ||
| 137 | +const importable = computed(() => rows.value.filter(r => r.statuses.some(s => s.action === 'import')).flatMap(importableOf)) | ||
| 138 | + | ||
| 139 | +/** Catalog refs of the local items of a row that are identical to a catalog item ("Manage"). */ | ||
| 140 | +function manageRefsOf(r: ScopeRow): { type: CatalogItemType, name: string }[] { | ||
| 141 | + if (!r.statuses.some(s => s.action === 'manage')) return [] | ||
| 142 | + const refs = new Map<string, { type: CatalogItemType, name: string }>() | ||
| 143 | + for (const i of itemsOf(r)) { | ||
| 144 | + const ref = scope.statuses.value[i.id]?.catalogRef | ||
| 145 | + if (ref && scope.statuses.value[i.id]?.state === 'in-sync') refs.set(`${ref.type}/${ref.name}`, ref) | ||
| 146 | + } | ||
| 147 | + return [...refs.values()] | ||
| 148 | +} | ||
| 149 | +const manageable = computed(() => rows.value.flatMap(manageRefsOf)) | ||
| 150 | +function onManage(r: ScopeRow) { | ||
| 151 | + scope.manage(manageRefsOf(r)) | ||
| 152 | +} | ||
| 153 | + | ||
| 154 | +const hint = computed(() => (scope.config.value ? writtenToHint(scope.config.value.targets, home) : undefined)) | ||
| 155 | + | ||
| 156 | +const patterns = computed(() => { | ||
| 157 | + const c = scope.config.value | ||
| 158 | + if (!c) return [] | ||
| 159 | + const out: string[] = [] | ||
| 160 | + for (const [t, l] of Object.entries(c.exclude)) for (const p of l ?? []) if (p.includes('*')) out.push(`exclude ${t}: ${p}`) | ||
| 161 | + for (const p of c.onDemand.skills) if (p.includes('*')) out.push(`on demand: ${p}`) | ||
| 162 | + return out | ||
| 163 | +}) | ||
| 164 | + | ||
| 74 | /* ---------------------------------------------------------------- add items */ | 165 | /* ---------------------------------------------------------------- add items */ |
| 75 | 166 | ||
| 76 | const ADD_TYPES = computed<CatalogItemType[]>(() => (home ? ['skill', 'rule', 'agent'] : ['skill', 'rule', 'agent', 'mcp', 'hook'])) | 167 | const ADD_TYPES = computed<CatalogItemType[]>(() => (home ? ['skill', 'rule', 'agent'] : ['skill', 'rule', 'agent', 'mcp', 'hook'])) |
| @@ -104,34 +195,59 @@ function confirmPicker() { | |||
| 104 | </script> | 195 | </script> |
| 105 | 196 | ||
| 106 | <template> | 197 | <template> |
| 107 | - <SkSection :title="home ? 'Items in Home' : 'Items in this project'"> | 198 | + <SkSection :title="home ? 'Items in Global' : 'Items in this project'"> |
| 108 | <template #actions> | 199 | <template #actions> |
| 109 | - <span class="hidden text-pretty text-sk-muted sm:inline">{{ hint }}</span> | 200 | + <span v-if="hint" class="hidden text-pretty text-sk-muted sm:inline">{{ hint }}</span> |
| 110 | - <SkButton label="Add items" :disabled="!pickable.length" @click="openPicker" /> | 201 | + <SkButton v-if="manageable.length" :label="`Manage all in catalog (${manageable.length})`" title="These items are identical to the catalog version: let skilly manage them here" @click="scope.manage(manageable)" /> |
| 202 | + <SkButton v-if="importable.length" :label="`Import all to catalog (${importable.length})`" @click="scope.askImport(importable)" /> | ||
| 203 | + <SkButton v-if="managed" label="Add items" :disabled="!pickable.length" @click="openPicker" /> | ||
| 111 | </template> | 204 | </template> |
| 112 | 205 | ||
| 206 | + <div class="flex flex-wrap items-center gap-x-3 gap-y-2"> | ||
| 207 | + <SkSeg v-model="typeFilter" :options="typeOptions" size="md" aria-label="Filter by type" /> | ||
| 208 | + <span class="flex-1" /> | ||
| 209 | + <SkSeg v-model="originFilter" :options="originOptions" size="md" aria-label="Filter by origin" /> | ||
| 210 | + <UInput v-model="search" placeholder="Search items" size="sm" class="w-56" aria-label="Search items" /> | ||
| 211 | + </div> | ||
| 212 | + | ||
| 113 | <SkPanel> | 213 | <SkPanel> |
| 114 | - <ScopeItemRow | 214 | + <template v-for="g in groups" :key="g.type"> |
| 115 | - v-for="r in rows" | 215 | + <SkSectionLabel :count="g.rows.length" class="border-t border-sk-line2 px-4 pt-3 pb-1.5 first:border-t-0"> |
| 116 | - :key="r.key" | 216 | + {{ TYPE_PLURAL[g.type] }} |
| 117 | - :row="r" | 217 | + <span v-if="readOnly(g.type)" class="normal-case tracking-normal" title="Entries of your user configuration files: shown read-only in v1. You can still import them to the catalog.">· read-only in v1</span> |
| 118 | - :root="scope.plan.value?.root ?? scope.rootPath.value" | 218 | + </SkSectionLabel> |
| 119 | - :home="home" | 219 | + <ScopeItemRow |
| 120 | - @toggle="(on) => onToggle(r, on)" | 220 | + v-for="r in g.rows" |
| 121 | - @mode="(d) => onMode(r, d)" | 221 | + :key="r.key" |
| 122 | - @review="scope.openReview()" | 222 | + :row="r" |
| 123 | - @import="onImport(r)" | 223 | + :root="scope.plan.value?.root ?? scope.rootPath.value" |
| 124 | - /> | 224 | + :home="home" |
| 125 | - <SkEmpty v-if="!rows.length"> | 225 | + :location="locationOf(r)" |
| 126 | - {{ catalog.exists.value ? 'Nothing enabled yet. Add a preset above, or add items one by one.' : 'No catalog yet. Create the catalog from the Catalog page, then enable presets and items here.' }} | 226 | + :flag="flagOf(r)?.hint" |
| 227 | + :can-move="movable(r).length > 0" | ||
| 228 | + @open="onOpen(r)" | ||
| 229 | + @toggle="(on) => onToggle(r, on)" | ||
| 230 | + @mode="(d) => onMode(r, d)" | ||
| 231 | + @review="scope.openReview()" | ||
| 232 | + @import="onImport(r)" | ||
| 233 | + @manage="onManage(r)" | ||
| 234 | + @move="scope.askMove(movable(r))" | ||
| 235 | + /> | ||
| 236 | + </template> | ||
| 237 | + <SkEmpty v-if="!groups.length"> | ||
| 238 | + <template v-if="rows.length">Nothing matches these filters.</template> | ||
| 239 | + <template v-else-if="managed"> | ||
| 240 | + {{ catalog.exists.value ? 'Nothing here yet. Add a preset above, or add items one by one.' : 'No catalog yet. Create the catalog from the Catalog page, then enable presets and items here.' }} | ||
| 241 | + </template> | ||
| 242 | + <template v-else>No skill, rule, MCP server, hook or subagent found here.</template> | ||
| 127 | </SkEmpty> | 243 | </SkEmpty> |
| 128 | </SkPanel> | 244 | </SkPanel> |
| 129 | <p v-if="patterns.length" class="m-0 font-mono text-[11px] text-sk-faint">Patterns kept as is: {{ patterns.join(' · ') }}</p> | 245 | <p v-if="patterns.length" class="m-0 font-mono text-[11px] text-sk-faint">Patterns kept as is: {{ patterns.join(' · ') }}</p> |
| 130 | </SkSection> | 246 | </SkSection> |
| 131 | 247 | ||
| 132 | - <SkModal v-model:open="pickerOpen" :title="home ? 'Add items to Home' : 'Add items'" description="Pick items from the catalog to enable one by one." width="lg"> | 248 | + <SkModal v-model:open="pickerOpen" :title="home ? 'Add items to Global' : 'Add items'" description="Pick items from the catalog to enable one by one." width="lg"> |
| 133 | <ItemPicker v-model="pickerSel" :items="pickable" :types="ADD_TYPES" :locked="locked" height="h-80" /> | 249 | <ItemPicker v-model="pickerSel" :items="pickable" :types="ADD_TYPES" :locked="locked" height="h-80" /> |
| 134 | - <p v-if="home" class="m-0 mt-2.5 text-xs text-sk-muted">Home can receive skills, rules and subagents. Its MCP servers and hooks are read-only in v1.</p> | 250 | + <p v-if="home" class="m-0 mt-2.5 text-xs text-sk-muted">Global can receive skills, rules and subagents. Its MCP servers and hooks are read-only in v1.</p> |
| 135 | <template #footer> | 251 | <template #footer> |
| 136 | <SkButton label="Cancel" @click="pickerOpen = false" /> | 252 | <SkButton label="Cancel" @click="pickerOpen = false" /> |
| 137 | <SkButton variant="primary" label="Save selection" @click="confirmPicker" /> | 253 | <SkButton variant="primary" label="Save selection" @click="confirmPicker" /> |
added
packages/ui/app/components/scope/ScopeItemsTab.vue +57 -0 | new file mode 100644 | ||
| @@ -0,0 +1,57 @@ | ||
| 1 | +<script setup lang="ts"> | |
| 2 | +import type { InventoryItem } from '@skilly/core' | |
| 3 | +import type { ProjectSpecific } from '../../utils/inventoryView' | |
| 4 | + | |
| 5 | +/** | |
| 6 | + * Items tab of a project or of Global: everything the agents load here, in one place. Start-managing card when the | |
| 7 | + * scope has no config yet, the "To tidy up" panel (Global), presets, the single Items list (managed + on disk) and | |
| 8 | + * inherited-from-Global (projects). | |
| 9 | + */ | |
| 10 | +const scope = useScopeContext() | |
| 11 | +const catalog = useCatalog() | |
| 12 | +const { projects } = useProjects() | |
| 13 | +const home = scope.kind === 'home' | |
| 14 | + | |
| 15 | +const missing = computed(() => scope.plan.value?.missing ?? []) | |
| 16 | +const noTarget = computed(() => scope.config.value && !scope.config.value.targets.length) | |
| 17 | + | |
| 18 | +/** Global items that look project-specific (brief 4.5), by inventory item id. */ | |
| 19 | +const flagged = computed(() => { | |
| 20 | + const m = new Map<string, ProjectSpecific>() | |
| 21 | + if (!home) return m | |
| 22 | + for (const i of scope.inventory.value?.items ?? []) { | |
| 23 | + const st = scope.statuses.value[i.id] | |
| 24 | + const p = projectSpecific(i, st, catalog.usage.value, projects.value) | |
| 25 | + if (p && canMoveOutOfHome(i, st)) m.set(i.id, p) | |
| 26 | + } | |
| 27 | + return m | |
| 28 | +}) | |
| 29 | +const flaggedItems = computed(() => (scope.inventory.value?.items ?? []).filter(i => flagged.value.has(i.id))) | |
| 30 | + | |
| 31 | +const reviewOnly = ref(false) | |
| 32 | +const onlyIds = computed(() => (reviewOnly.value ? new Set(flagged.value.keys()) : null)) | |
| 33 | +watch(() => flagged.value.size, (n) => { if (!n) reviewOnly.value = false }) | |
| 34 | +</script> | |
| 35 | + | |
| 36 | +<template> | |
| 37 | + <div class="flex flex-col gap-[22px]"> | |
| 38 | + <SkNote v-if="noTarget" tone="accent">No target agent is selected: nothing will be written. Choose at least one agent under "Writes for" above.</SkNote> | |
| 39 | + <SkNote v-if="missing.length" tone="accent"> | |
| 40 | + {{ missing.length }} referenced item{{ missing.length > 1 ? 's are' : ' is' }} not in the catalog and will be skipped: | |
| 41 | + <span class="font-mono text-xs">{{ missing.map(m => `${m.type}/${m.name}`).join(', ') }}</span> | |
| 42 | + </SkNote> | |
| 43 | + <ScopeManageCard v-if="!scope.config.value" /> | |
| 44 | + <HomeSuggestions | |
| 45 | + v-if="flaggedItems.length" | |
| 46 | + :items="flaggedItems" | |
| 47 | + :flagged="flagged" | |
| 48 | + :statuses="scope.statuses.value" | |
| 49 | + :review="reviewOnly" | |
| 50 | + @toggle-review="reviewOnly = !reviewOnly" | |
| 51 | + @move="(i: InventoryItem) => scope.askMove([i])" | |
| 52 | + /> | |
| 53 | + <ScopePresetsSection v-if="scope.config.value" /> | |
| 54 | + <ScopeItemsSection :flagged="flagged" :only-ids="onlyIds" /> | |
| 55 | + <ScopeInheritedSection v-if="!home && scope.config.value" /> | |
| 56 | + </div> | |
| 57 | +</template> | |
| new file mode 100644 | |||
| @@ -0,0 +1,57 @@ | |||
| 1 | +<script setup lang="ts"> | ||
| 2 | +import type { InventoryItem } from '@skilly/core' | ||
| 3 | +import type { ProjectSpecific } from '../../utils/inventoryView' | ||
| 4 | + | ||
| 5 | +/** | ||
| 6 | + * Items tab of a project or of Global: everything the agents load here, in one place. Start-managing card when the | ||
| 7 | + * scope has no config yet, the "To tidy up" panel (Global), presets, the single Items list (managed + on disk) and | ||
| 8 | + * inherited-from-Global (projects). | ||
| 9 | + */ | ||
| 10 | +const scope = useScopeContext() | ||
| 11 | +const catalog = useCatalog() | ||
| 12 | +const { projects } = useProjects() | ||
| 13 | +const home = scope.kind === 'home' | ||
| 14 | + | ||
| 15 | +const missing = computed(() => scope.plan.value?.missing ?? []) | ||
| 16 | +const noTarget = computed(() => scope.config.value && !scope.config.value.targets.length) | ||
| 17 | + | ||
| 18 | +/** Global items that look project-specific (brief 4.5), by inventory item id. */ | ||
| 19 | +const flagged = computed(() => { | ||
| 20 | + const m = new Map<string, ProjectSpecific>() | ||
| 21 | + if (!home) return m | ||
| 22 | + for (const i of scope.inventory.value?.items ?? []) { | ||
| 23 | + const st = scope.statuses.value[i.id] | ||
| 24 | + const p = projectSpecific(i, st, catalog.usage.value, projects.value) | ||
| 25 | + if (p && canMoveOutOfHome(i, st)) m.set(i.id, p) | ||
| 26 | + } | ||
| 27 | + return m | ||
| 28 | +}) | ||
| 29 | +const flaggedItems = computed(() => (scope.inventory.value?.items ?? []).filter(i => flagged.value.has(i.id))) | ||
| 30 | + | ||
| 31 | +const reviewOnly = ref(false) | ||
| 32 | +const onlyIds = computed(() => (reviewOnly.value ? new Set(flagged.value.keys()) : null)) | ||
| 33 | +watch(() => flagged.value.size, (n) => { if (!n) reviewOnly.value = false }) | ||
| 34 | +</script> | ||
| 35 | + | ||
| 36 | +<template> | ||
| 37 | + <div class="flex flex-col gap-[22px]"> | ||
| 38 | + <SkNote v-if="noTarget" tone="accent">No target agent is selected: nothing will be written. Choose at least one agent under "Writes for" above.</SkNote> | ||
| 39 | + <SkNote v-if="missing.length" tone="accent"> | ||
| 40 | + {{ missing.length }} referenced item{{ missing.length > 1 ? 's are' : ' is' }} not in the catalog and will be skipped: | ||
| 41 | + <span class="font-mono text-xs">{{ missing.map(m => `${m.type}/${m.name}`).join(', ') }}</span> | ||
| 42 | + </SkNote> | ||
| 43 | + <ScopeManageCard v-if="!scope.config.value" /> | ||
| 44 | + <HomeSuggestions | ||
| 45 | + v-if="flaggedItems.length" | ||
| 46 | + :items="flaggedItems" | ||
| 47 | + :flagged="flagged" | ||
| 48 | + :statuses="scope.statuses.value" | ||
| 49 | + :review="reviewOnly" | ||
| 50 | + @toggle-review="reviewOnly = !reviewOnly" | ||
| 51 | + @move="(i: InventoryItem) => scope.askMove([i])" | ||
| 52 | + /> | ||
| 53 | + <ScopePresetsSection v-if="scope.config.value" /> | ||
| 54 | + <ScopeItemsSection :flagged="flagged" :only-ids="onlyIds" /> | ||
| 55 | + <ScopeInheritedSection v-if="!home && scope.config.value" /> | ||
| 56 | + </div> | ||
| 57 | +</template> | ||
modified
packages/ui/app/components/scope/ScopeManageCard.vue +5 -4 | @@ -1,7 +1,7 @@ | ||
| 1 | 1 | <script setup lang="ts"> |
| 2 | 2 | import type { AgentId } from '@skilly/core' |
| 3 | 3 | |
| 4 | -/** Empty state of a scope without skilly.json / home.json: choose the target agents and start managing. */ | |
| 4 | +/** Card above the Items list of a scope without skilly.json / home.json: choose the target agents and start managing. */ | |
| 5 | 5 | const scope = useScopeContext() |
| 6 | 6 | const home = scope.kind === 'home' |
| 7 | 7 | const AGENT_LIST: AgentId[] = ['claude', 'codex'] |
| @@ -23,11 +23,12 @@ function start() { | ||
| 23 | 23 | <template> |
| 24 | 24 | <SkPanel padded class="flex flex-col gap-5"> |
| 25 | 25 | <div class="flex flex-col gap-1"> |
| 26 | - <h2 class="m-0 text-[15px] font-semibold">{{ home ? 'Manage Home with skilly' : 'Manage this project with skilly' }}</h2> | |
| 26 | + <h2 class="m-0 text-[15px] font-semibold">{{ home ? 'skilly does not manage Global yet' : 'skilly does not manage this project yet' }}</h2> | |
| 27 | 27 | <p class="m-0 max-w-2xl text-pretty text-sk-muted"> |
| 28 | - Enable presets and catalog items, switch skills to on demand and keep the files in sync. | |
| 28 | + The list below is what the agents load here today. Start managing to enable presets and catalog items, switch | |
| 29 | + skills to on demand and keep the files in sync with the catalog. | |
| 29 | 30 | {{ home |
| 30 | - ? 'Settings are stored in ~/.skilly/home.json. Nothing is written into your agent folders until you apply.' | |
| 31 | + ? 'Settings are stored in ~/.skilly/home.json (not in a repository). Nothing is written into ~/.claude, ~/.agents or ~/.codex until you apply.' | |
| 31 | 32 | : 'A skilly.json file is created at the project root: commit it with the project. Nothing else is written until you apply.' }} |
| 32 | 33 | </p> |
| 33 | 34 | </div> |
| @@ -1,7 +1,7 @@ | |||
| 1 | <script setup lang="ts"> | 1 | <script setup lang="ts"> |
| 2 | import type { AgentId } from '@skilly/core' | 2 | import type { AgentId } from '@skilly/core' |
| 3 | 3 | ||
| 4 | -/** Empty state of a scope without skilly.json / home.json: choose the target agents and start managing. */ | 4 | +/** Card above the Items list of a scope without skilly.json / home.json: choose the target agents and start managing. */ |
| 5 | const scope = useScopeContext() | 5 | const scope = useScopeContext() |
| 6 | const home = scope.kind === 'home' | 6 | const home = scope.kind === 'home' |
| 7 | const AGENT_LIST: AgentId[] = ['claude', 'codex'] | 7 | const AGENT_LIST: AgentId[] = ['claude', 'codex'] |
| @@ -23,11 +23,12 @@ function start() { | |||
| 23 | <template> | 23 | <template> |
| 24 | <SkPanel padded class="flex flex-col gap-5"> | 24 | <SkPanel padded class="flex flex-col gap-5"> |
| 25 | <div class="flex flex-col gap-1"> | 25 | <div class="flex flex-col gap-1"> |
| 26 | - <h2 class="m-0 text-[15px] font-semibold">{{ home ? 'Manage Home with skilly' : 'Manage this project with skilly' }}</h2> | 26 | + <h2 class="m-0 text-[15px] font-semibold">{{ home ? 'skilly does not manage Global yet' : 'skilly does not manage this project yet' }}</h2> |
| 27 | <p class="m-0 max-w-2xl text-pretty text-sk-muted"> | 27 | <p class="m-0 max-w-2xl text-pretty text-sk-muted"> |
| 28 | - Enable presets and catalog items, switch skills to on demand and keep the files in sync. | 28 | + The list below is what the agents load here today. Start managing to enable presets and catalog items, switch |
| 29 | + skills to on demand and keep the files in sync with the catalog. | ||
| 29 | {{ home | 30 | {{ home |
| 30 | - ? 'Settings are stored in ~/.skilly/home.json. Nothing is written into your agent folders until you apply.' | 31 | + ? 'Settings are stored in ~/.skilly/home.json (not in a repository). Nothing is written into ~/.claude, ~/.agents or ~/.codex until you apply.' |
| 31 | : 'A skilly.json file is created at the project root: commit it with the project. Nothing else is written until you apply.' }} | 32 | : 'A skilly.json file is created at the project root: commit it with the project. Nothing else is written until you apply.' }} |
| 32 | </p> | 33 | </p> |
| 33 | </div> | 34 | </div> |
modified
packages/ui/app/components/scope/ScopePage.vue +34 -38 | @@ -1,7 +1,9 @@ | ||
| 1 | 1 | <script setup lang="ts"> |
| 2 | 2 | /** |
| 3 | - * Home and project page (design/BRIEF.md 2.2, 2.4, 4.1): header, tab segment, tab content, the sticky "pending | |
| 4 | - * changes" bar and the overlays (item drawer, review drawer, import and remove-from-global modals). | |
| 3 | + * Project page, also used for Global (the Home scope, `~`) (design/BRIEF.md 2.0, 2.4, 4.1), full width: header, tab | |
| 4 | + * segment (Items · Instructions), tab content, the sticky "pending changes" bar and the overlays (item drawer, review | |
| 5 | + * drawer, import and move modals). Global has the same tabs as a project; it differs by its "loaded in every project" | |
| 6 | + * banner, its "to tidy up" panel and the absence of "Remove from skilly". | |
| 5 | 7 | * State lives in `createScope` and is shared with the scope components through `useScopeContext()`. |
| 6 | 8 | */ |
| 7 | 9 | const props = defineProps<{ kind: 'home' | 'project', projectId?: string }>() |
| @@ -20,10 +22,11 @@ const router = useRouter() | ||
| 20 | 22 | const toast = useToast() |
| 21 | 23 | const home = props.kind === 'home' |
| 22 | 24 | |
| 23 | -type Tab = 'inventory' | 'config' | 'files' | 'instructions' | |
| 24 | -const TABS: { value: Tab, label: string }[] = home | |
| 25 | - ? [{ value: 'inventory', label: 'Inventory' }, { value: 'config', label: 'Configuration' }] | |
| 26 | - : [{ value: 'config', label: 'Configuration' }, { value: 'files', label: 'Files' }, { value: 'instructions', label: 'Instructions' }] | |
| 25 | +type Tab = 'items' | 'instructions' | |
| 26 | +const TABS: { value: Tab, label: string }[] = [ | |
| 27 | + { value: 'items', label: 'Items' }, | |
| 28 | + { value: 'instructions', label: 'Instructions' } | |
| 29 | +] | |
| 27 | 30 | const tab = computed<Tab>({ |
| 28 | 31 | get: () => { |
| 29 | 32 | const q = String(route.query.tab ?? '') |
| @@ -34,7 +37,13 @@ const tab = computed<Tab>({ | ||
| 34 | 37 | |
| 35 | 38 | onMounted(async () => { |
| 36 | 39 | if (!catalog.data.value) catalog.refresh() |
| 40 | + if (!home && props.projectId) rememberRecentProject(props.projectId) | |
| 37 | 41 | await scope.load() |
| 42 | + // `?review=1` (dashboard "Review" buttons) opens the review drawer once the plan is loaded. | |
| 43 | + if (route.query.review) { | |
| 44 | + router.replace({ query: { ...route.query, review: undefined } }) | |
| 45 | + if (config.value && plan.value) scope.openReview() | |
| 46 | + } | |
| 38 | 47 | }) |
| 39 | 48 | |
| 40 | 49 | /* ---------------------------------------------------------------- header */ |
| @@ -81,18 +90,17 @@ const agentsText = computed(() => { | ||
| 81 | 90 | /* ---------------------------------------------------------------- pending bar */ |
| 82 | 91 | |
| 83 | 92 | const barVisible = computed(() => !!config.value && (pending.value.length > 0 || (plan.value?.missing.length ?? 0) > 0)) |
| 84 | - | |
| 85 | -const homeInstructionCount = computed(() => (inventory.value?.items ?? []).filter(i => i.type === 'instructions').length) | |
| 86 | 93 | </script> |
| 87 | 94 | |
| 88 | 95 | <template> |
| 89 | - <SkPage> | |
| 90 | - <SkPageHeader :title="home ? 'Home · user-level config' : title" :subtitle="home ? 'Everything here is visible in every project.' : undefined" :aside="home" truncate-subtitle :subtitle-title="rootPath"> | |
| 91 | - <template v-if="home" #lead><SkAvatar /></template> | |
| 92 | - <template v-else #breadcrumb><SkBreadcrumb :items="[{ label: 'Projects', to: '/projects' }]" /></template> | |
| 93 | - <template v-if="!home" #subtitle> | |
| 96 | + <SkPage wide> | |
| 97 | + <SkPageHeader :title="title" truncate-subtitle :subtitle-title="rootPath"> | |
| 98 | + <template #breadcrumb><SkBreadcrumb :items="[{ label: 'Projects', to: '/projects' }]" /></template> | |
| 99 | + <template #subtitle> | |
| 94 | 100 | <span class="flex items-center gap-x-2 font-mono text-xs text-sk-faint"> |
| 95 | - <span class="min-w-0 max-w-[360px] shrink truncate" :title="rootPath">{{ rootPath }}</span> | |
| 101 | + <span v-if="home" class="shrink-0 font-sans text-[11.5px] font-medium text-sk-accent-text">Loaded in every project</span> | |
| 102 | + <span v-if="home" aria-hidden="true">·</span> | |
| 103 | + <span class="min-w-0 max-w-[360px] shrink truncate" :title="rootPath">{{ home ? '~' : rootPath }}</span> | |
| 96 | 104 | <template v-if="inventory"> |
| 97 | 105 | <span aria-hidden="true">·</span> |
| 98 | 106 | <ScopeTargets v-if="config" /> |
| @@ -101,9 +109,8 @@ const homeInstructionCount = computed(() => (inventory.value?.items ?? []).filte | ||
| 101 | 109 | </span> |
| 102 | 110 | </template> |
| 103 | 111 | <template #actions> |
| 104 | - <SkButton v-if="home" variant="ghost" icon="i-lucide-refresh-cw" label="Rescan" :loading="loading && !!inventory" @click="scope.rescan()" /> | |
| 105 | 112 | <SkSeg v-if="inventory || !loading" v-model="tab" :options="TABS" aria-label="Page section" /> |
| 106 | - <UDropdownMenu v-if="!home" :items="menu" :content="{ align: 'end' }"> | |
| 113 | + <UDropdownMenu :items="menu" :content="{ align: 'end' }"> | |
| 107 | 114 | <SkButton variant="ghost" icon="i-lucide-ellipsis" aria-label="More actions" /> |
| 108 | 115 | </UDropdownMenu> |
| 109 | 116 | </template> |
| @@ -128,19 +135,25 @@ const homeInstructionCount = computed(() => (inventory.value?.items ?? []).filte | ||
| 128 | 135 | </template> |
| 129 | 136 | </SkNote> |
| 130 | 137 | <SkNote v-else tone="accent"> |
| 131 | - <p class="m-0 font-semibold">Could not load this {{ home ? 'Home' : 'project' }}</p> | |
| 138 | + <p class="m-0 font-semibold">Could not load {{ home ? 'Global' : 'this project' }}</p> | |
| 132 | 139 | <p class="m-0 mt-1">{{ error }}</p> |
| 133 | 140 | <template #aside><SkButton size="sm" label="Retry" @click="scope.load()" /></template> |
| 134 | 141 | </SkNote> |
| 135 | 142 | </template> |
| 136 | 143 | |
| 137 | 144 | <template v-else-if="inventory"> |
| 145 | + <SkNote v-if="home" tone="info"> | |
| 146 | + <span class="font-medium">Everything here is loaded in every session of every project,</span> | |
| 147 | + and takes up context there. Keep what you use everywhere; move the rest to the projects that need it. | |
| 148 | + <span class="block font-mono text-[11px] text-sk-faint">~/.claude · ~/.agents · ~/.codex</span> | |
| 149 | + </SkNote> | |
| 150 | + | |
| 138 | 151 | <SkNote v-if="folderMissing" tone="accent"> |
| 139 | 152 | <p class="m-0 font-semibold">This folder no longer exists on disk</p> |
| 140 | 153 | <p class="m-0 mt-1">The inventory below may be empty or outdated. You can remove the project from skilly.</p> |
| 141 | 154 | </SkNote> |
| 142 | 155 | |
| 143 | - <SkNote v-if="inventory.errors.length && tab !== 'inventory'" tone="accent"> | |
| 156 | + <SkNote v-if="inventory.errors.length" tone="accent"> | |
| 144 | 157 | <button type="button" class="cursor-pointer border-0 bg-transparent p-0 text-left font-semibold text-inherit" @click="scanErrorsOpen = !scanErrorsOpen"> |
| 145 | 158 | {{ inventory.errors.length }} item{{ inventory.errors.length > 1 ? 's' : '' }} could not be read |
| 146 | 159 | <span class="font-normal opacity-80">· {{ scanErrorsOpen ? 'hide' : 'show' }} details</span> |
| @@ -148,25 +161,8 @@ const homeInstructionCount = computed(() => (inventory.value?.items ?? []).filte | ||
| 148 | 161 | <ScopeScanErrors v-if="scanErrorsOpen" :errors="inventory.errors" :root="rootPath" /> |
| 149 | 162 | </SkNote> |
| 150 | 163 | |
| 151 | - <!-- Home --> | |
| 152 | - <template v-if="home"> | |
| 153 | - <HomeInventory v-if="tab === 'inventory'" /> | |
| 154 | - <template v-else> | |
| 155 | - <SkNote tone="accent">This writes into your Home folders (~/.claude, ~/.agents, ~/.codex). Every project sees what is enabled here.</SkNote> | |
| 156 | - <ScopeManageCard v-if="!config" /> | |
| 157 | - <ScopeConfigTab v-else /> | |
| 158 | - </template> | |
| 159 | - </template> | |
| 160 | - | |
| 161 | - <!-- Project --> | |
| 162 | - <template v-else> | |
| 163 | - <template v-if="tab === 'config'"> | |
| 164 | - <ScopeManageCard v-if="!config" /> | |
| 165 | - <ScopeConfigTab v-else /> | |
| 166 | - </template> | |
| 167 | - <ScopeFilesTab v-else-if="tab === 'files'" /> | |
| 168 | - <ScopeInstructionsTab v-else /> | |
| 169 | - </template> | |
| 164 | + <ScopeItemsTab v-if="tab === 'items'" /> | |
| 165 | + <ScopeInstructionsTab v-else /> | |
| 170 | 166 | </template> |
| 171 | 167 | |
| 172 | 168 | <SkActionBar |
| @@ -184,7 +180,7 @@ const homeInstructionCount = computed(() => (inventory.value?.items ?? []).filte | ||
| 184 | 180 | |
| 185 | 181 | <ScopeItemDrawer v-model:open="itemOpen" /> |
| 186 | 182 | <ScopeReviewDrawer v-if="plan" v-model:open="reviewOpen" :scope-id="scopeId" :plan="plan" :home="home" @applied="scope.adoptState" /> |
| 187 | - <AddToCatalogModal v-model:open="importOpen" :scope="scopeId" :items="importItems" :statuses="statuses" @done="scope.afterCatalogChange" /> | |
| 183 | + <AddToCatalogModal v-model:open="importOpen" :scope="scopeId" :items="importItems" :statuses="statuses" @done="scope.afterImport" /> | |
| 188 | 184 | <RemoveFromGlobalModal v-model:open="moveOpen" :items="moveItems" :statuses="statuses" @done="scope.afterCatalogChange" /> |
| 189 | 185 | |
| 190 | 186 | <SkModal v-model:open="removeOpen" title="Remove project from skilly" description="This only forgets the project in skilly." width="sm"> |
| @@ -1,7 +1,9 @@ | |||
| 1 | <script setup lang="ts"> | 1 | <script setup lang="ts"> |
| 2 | /** | 2 | /** |
| 3 | - * Home and project page (design/BRIEF.md 2.2, 2.4, 4.1): header, tab segment, tab content, the sticky "pending | 3 | + * Project page, also used for Global (the Home scope, `~`) (design/BRIEF.md 2.0, 2.4, 4.1), full width: header, tab |
| 4 | - * changes" bar and the overlays (item drawer, review drawer, import and remove-from-global modals). | 4 | + * segment (Items · Instructions), tab content, the sticky "pending changes" bar and the overlays (item drawer, review |
| 5 | + * drawer, import and move modals). Global has the same tabs as a project; it differs by its "loaded in every project" | ||
| 6 | + * banner, its "to tidy up" panel and the absence of "Remove from skilly". | ||
| 5 | * State lives in `createScope` and is shared with the scope components through `useScopeContext()`. | 7 | * State lives in `createScope` and is shared with the scope components through `useScopeContext()`. |
| 6 | */ | 8 | */ |
| 7 | const props = defineProps<{ kind: 'home' | 'project', projectId?: string }>() | 9 | const props = defineProps<{ kind: 'home' | 'project', projectId?: string }>() |
| @@ -20,10 +22,11 @@ const router = useRouter() | |||
| 20 | const toast = useToast() | 22 | const toast = useToast() |
| 21 | const home = props.kind === 'home' | 23 | const home = props.kind === 'home' |
| 22 | 24 | ||
| 23 | -type Tab = 'inventory' | 'config' | 'files' | 'instructions' | 25 | +type Tab = 'items' | 'instructions' |
| 24 | -const TABS: { value: Tab, label: string }[] = home | 26 | +const TABS: { value: Tab, label: string }[] = [ |
| 25 | - ? [{ value: 'inventory', label: 'Inventory' }, { value: 'config', label: 'Configuration' }] | 27 | + { value: 'items', label: 'Items' }, |
| 26 | - : [{ value: 'config', label: 'Configuration' }, { value: 'files', label: 'Files' }, { value: 'instructions', label: 'Instructions' }] | 28 | + { value: 'instructions', label: 'Instructions' } |
| 29 | +] | ||
| 27 | const tab = computed<Tab>({ | 30 | const tab = computed<Tab>({ |
| 28 | get: () => { | 31 | get: () => { |
| 29 | const q = String(route.query.tab ?? '') | 32 | const q = String(route.query.tab ?? '') |
| @@ -34,7 +37,13 @@ const tab = computed<Tab>({ | |||
| 34 | 37 | ||
| 35 | onMounted(async () => { | 38 | onMounted(async () => { |
| 36 | if (!catalog.data.value) catalog.refresh() | 39 | if (!catalog.data.value) catalog.refresh() |
| 40 | + if (!home && props.projectId) rememberRecentProject(props.projectId) | ||
| 37 | await scope.load() | 41 | await scope.load() |
| 42 | + // `?review=1` (dashboard "Review" buttons) opens the review drawer once the plan is loaded. | ||
| 43 | + if (route.query.review) { | ||
| 44 | + router.replace({ query: { ...route.query, review: undefined } }) | ||
| 45 | + if (config.value && plan.value) scope.openReview() | ||
| 46 | + } | ||
| 38 | }) | 47 | }) |
| 39 | 48 | ||
| 40 | /* ---------------------------------------------------------------- header */ | 49 | /* ---------------------------------------------------------------- header */ |
| @@ -81,18 +90,17 @@ const agentsText = computed(() => { | |||
| 81 | /* ---------------------------------------------------------------- pending bar */ | 90 | /* ---------------------------------------------------------------- pending bar */ |
| 82 | 91 | ||
| 83 | const barVisible = computed(() => !!config.value && (pending.value.length > 0 || (plan.value?.missing.length ?? 0) > 0)) | 92 | const barVisible = computed(() => !!config.value && (pending.value.length > 0 || (plan.value?.missing.length ?? 0) > 0)) |
| 84 | - | ||
| 85 | -const homeInstructionCount = computed(() => (inventory.value?.items ?? []).filter(i => i.type === 'instructions').length) | ||
| 86 | </script> | 93 | </script> |
| 87 | 94 | ||
| 88 | <template> | 95 | <template> |
| 89 | - <SkPage> | 96 | + <SkPage wide> |
| 90 | - <SkPageHeader :title="home ? 'Home · user-level config' : title" :subtitle="home ? 'Everything here is visible in every project.' : undefined" :aside="home" truncate-subtitle :subtitle-title="rootPath"> | 97 | + <SkPageHeader :title="title" truncate-subtitle :subtitle-title="rootPath"> |
| 91 | - <template v-if="home" #lead><SkAvatar /></template> | 98 | + <template #breadcrumb><SkBreadcrumb :items="[{ label: 'Projects', to: '/projects' }]" /></template> |
| 92 | - <template v-else #breadcrumb><SkBreadcrumb :items="[{ label: 'Projects', to: '/projects' }]" /></template> | 99 | + <template #subtitle> |
| 93 | - <template v-if="!home" #subtitle> | ||
| 94 | <span class="flex items-center gap-x-2 font-mono text-xs text-sk-faint"> | 100 | <span class="flex items-center gap-x-2 font-mono text-xs text-sk-faint"> |
| 95 | - <span class="min-w-0 max-w-[360px] shrink truncate" :title="rootPath">{{ rootPath }}</span> | 101 | + <span v-if="home" class="shrink-0 font-sans text-[11.5px] font-medium text-sk-accent-text">Loaded in every project</span> |
| 102 | + <span v-if="home" aria-hidden="true">·</span> | ||
| 103 | + <span class="min-w-0 max-w-[360px] shrink truncate" :title="rootPath">{{ home ? '~' : rootPath }}</span> | ||
| 96 | <template v-if="inventory"> | 104 | <template v-if="inventory"> |
| 97 | <span aria-hidden="true">·</span> | 105 | <span aria-hidden="true">·</span> |
| 98 | <ScopeTargets v-if="config" /> | 106 | <ScopeTargets v-if="config" /> |
| @@ -101,9 +109,8 @@ const homeInstructionCount = computed(() => (inventory.value?.items ?? []).filte | |||
| 101 | </span> | 109 | </span> |
| 102 | </template> | 110 | </template> |
| 103 | <template #actions> | 111 | <template #actions> |
| 104 | - <SkButton v-if="home" variant="ghost" icon="i-lucide-refresh-cw" label="Rescan" :loading="loading && !!inventory" @click="scope.rescan()" /> | ||
| 105 | <SkSeg v-if="inventory || !loading" v-model="tab" :options="TABS" aria-label="Page section" /> | 112 | <SkSeg v-if="inventory || !loading" v-model="tab" :options="TABS" aria-label="Page section" /> |
| 106 | - <UDropdownMenu v-if="!home" :items="menu" :content="{ align: 'end' }"> | 113 | + <UDropdownMenu :items="menu" :content="{ align: 'end' }"> |
| 107 | <SkButton variant="ghost" icon="i-lucide-ellipsis" aria-label="More actions" /> | 114 | <SkButton variant="ghost" icon="i-lucide-ellipsis" aria-label="More actions" /> |
| 108 | </UDropdownMenu> | 115 | </UDropdownMenu> |
| 109 | </template> | 116 | </template> |
| @@ -128,19 +135,25 @@ const homeInstructionCount = computed(() => (inventory.value?.items ?? []).filte | |||
| 128 | </template> | 135 | </template> |
| 129 | </SkNote> | 136 | </SkNote> |
| 130 | <SkNote v-else tone="accent"> | 137 | <SkNote v-else tone="accent"> |
| 131 | - <p class="m-0 font-semibold">Could not load this {{ home ? 'Home' : 'project' }}</p> | 138 | + <p class="m-0 font-semibold">Could not load {{ home ? 'Global' : 'this project' }}</p> |
| 132 | <p class="m-0 mt-1">{{ error }}</p> | 139 | <p class="m-0 mt-1">{{ error }}</p> |
| 133 | <template #aside><SkButton size="sm" label="Retry" @click="scope.load()" /></template> | 140 | <template #aside><SkButton size="sm" label="Retry" @click="scope.load()" /></template> |
| 134 | </SkNote> | 141 | </SkNote> |
| 135 | </template> | 142 | </template> |
| 136 | 143 | ||
| 137 | <template v-else-if="inventory"> | 144 | <template v-else-if="inventory"> |
| 145 | + <SkNote v-if="home" tone="info"> | ||
| 146 | + <span class="font-medium">Everything here is loaded in every session of every project,</span> | ||
| 147 | + and takes up context there. Keep what you use everywhere; move the rest to the projects that need it. | ||
| 148 | + <span class="block font-mono text-[11px] text-sk-faint">~/.claude · ~/.agents · ~/.codex</span> | ||
| 149 | + </SkNote> | ||
| 150 | + | ||
| 138 | <SkNote v-if="folderMissing" tone="accent"> | 151 | <SkNote v-if="folderMissing" tone="accent"> |
| 139 | <p class="m-0 font-semibold">This folder no longer exists on disk</p> | 152 | <p class="m-0 font-semibold">This folder no longer exists on disk</p> |
| 140 | <p class="m-0 mt-1">The inventory below may be empty or outdated. You can remove the project from skilly.</p> | 153 | <p class="m-0 mt-1">The inventory below may be empty or outdated. You can remove the project from skilly.</p> |
| 141 | </SkNote> | 154 | </SkNote> |
| 142 | 155 | ||
| 143 | - <SkNote v-if="inventory.errors.length && tab !== 'inventory'" tone="accent"> | 156 | + <SkNote v-if="inventory.errors.length" tone="accent"> |
| 144 | <button type="button" class="cursor-pointer border-0 bg-transparent p-0 text-left font-semibold text-inherit" @click="scanErrorsOpen = !scanErrorsOpen"> | 157 | <button type="button" class="cursor-pointer border-0 bg-transparent p-0 text-left font-semibold text-inherit" @click="scanErrorsOpen = !scanErrorsOpen"> |
| 145 | {{ inventory.errors.length }} item{{ inventory.errors.length > 1 ? 's' : '' }} could not be read | 158 | {{ inventory.errors.length }} item{{ inventory.errors.length > 1 ? 's' : '' }} could not be read |
| 146 | <span class="font-normal opacity-80">· {{ scanErrorsOpen ? 'hide' : 'show' }} details</span> | 159 | <span class="font-normal opacity-80">· {{ scanErrorsOpen ? 'hide' : 'show' }} details</span> |
| @@ -148,25 +161,8 @@ const homeInstructionCount = computed(() => (inventory.value?.items ?? []).filte | |||
| 148 | <ScopeScanErrors v-if="scanErrorsOpen" :errors="inventory.errors" :root="rootPath" /> | 161 | <ScopeScanErrors v-if="scanErrorsOpen" :errors="inventory.errors" :root="rootPath" /> |
| 149 | </SkNote> | 162 | </SkNote> |
| 150 | 163 | ||
| 151 | - <!-- Home --> | 164 | + <ScopeItemsTab v-if="tab === 'items'" /> |
| 152 | - <template v-if="home"> | 165 | + <ScopeInstructionsTab v-else /> |
| 153 | - <HomeInventory v-if="tab === 'inventory'" /> | ||
| 154 | - <template v-else> | ||
| 155 | - <SkNote tone="accent">This writes into your Home folders (~/.claude, ~/.agents, ~/.codex). Every project sees what is enabled here.</SkNote> | ||
| 156 | - <ScopeManageCard v-if="!config" /> | ||
| 157 | - <ScopeConfigTab v-else /> | ||
| 158 | - </template> | ||
| 159 | - </template> | ||
| 160 | - | ||
| 161 | - <!-- Project --> | ||
| 162 | - <template v-else> | ||
| 163 | - <template v-if="tab === 'config'"> | ||
| 164 | - <ScopeManageCard v-if="!config" /> | ||
| 165 | - <ScopeConfigTab v-else /> | ||
| 166 | - </template> | ||
| 167 | - <ScopeFilesTab v-else-if="tab === 'files'" /> | ||
| 168 | - <ScopeInstructionsTab v-else /> | ||
| 169 | - </template> | ||
| 170 | </template> | 166 | </template> |
| 171 | 167 | ||
| 172 | <SkActionBar | 168 | <SkActionBar |
| @@ -184,7 +180,7 @@ const homeInstructionCount = computed(() => (inventory.value?.items ?? []).filte | |||
| 184 | 180 | ||
| 185 | <ScopeItemDrawer v-model:open="itemOpen" /> | 181 | <ScopeItemDrawer v-model:open="itemOpen" /> |
| 186 | <ScopeReviewDrawer v-if="plan" v-model:open="reviewOpen" :scope-id="scopeId" :plan="plan" :home="home" @applied="scope.adoptState" /> | 182 | <ScopeReviewDrawer v-if="plan" v-model:open="reviewOpen" :scope-id="scopeId" :plan="plan" :home="home" @applied="scope.adoptState" /> |
| 187 | - <AddToCatalogModal v-model:open="importOpen" :scope="scopeId" :items="importItems" :statuses="statuses" @done="scope.afterCatalogChange" /> | 183 | + <AddToCatalogModal v-model:open="importOpen" :scope="scopeId" :items="importItems" :statuses="statuses" @done="scope.afterImport" /> |
| 188 | <RemoveFromGlobalModal v-model:open="moveOpen" :items="moveItems" :statuses="statuses" @done="scope.afterCatalogChange" /> | 184 | <RemoveFromGlobalModal v-model:open="moveOpen" :items="moveItems" :statuses="statuses" @done="scope.afterCatalogChange" /> |
| 189 | 185 | ||
| 190 | <SkModal v-model:open="removeOpen" title="Remove project from skilly" description="This only forgets the project in skilly." width="sm"> | 186 | <SkModal v-model:open="removeOpen" title="Remove project from skilly" description="This only forgets the project in skilly." width="sm"> |
modified
packages/ui/app/components/scope/ScopeResolveBox.vue +1 -1 | @@ -13,7 +13,7 @@ const scope = useScopeContext() | ||
| 13 | 13 | <span v-if="conflicts.length > 1" class="font-mono text-[11px] text-sk-faint">{{ AGENT_LABELS[a.agent] }}</span> |
| 14 | 14 | {{ a.conflict ? CONFLICT_HEADLINE[a.conflict] : a.reason }} |
| 15 | 15 | </p> |
| 16 | - <ScopeConflictCompare :action="a" :root="root" :local-label="home ? 'Home' : 'This project'" /> | |
| 16 | + <ScopeConflictCompare :action="a" :root="root" :local-label="home ? 'Global' : 'This project'" /> | |
| 17 | 17 | <ScopeConflictChoice |
| 18 | 18 | :model-value="scope.resolutionFor(a.id)" |
| 19 | 19 | :action="a" |
| @@ -13,7 +13,7 @@ const scope = useScopeContext() | |||
| 13 | <span v-if="conflicts.length > 1" class="font-mono text-[11px] text-sk-faint">{{ AGENT_LABELS[a.agent] }}</span> | 13 | <span v-if="conflicts.length > 1" class="font-mono text-[11px] text-sk-faint">{{ AGENT_LABELS[a.agent] }}</span> |
| 14 | {{ a.conflict ? CONFLICT_HEADLINE[a.conflict] : a.reason }} | 14 | {{ a.conflict ? CONFLICT_HEADLINE[a.conflict] : a.reason }} |
| 15 | </p> | 15 | </p> |
| 16 | - <ScopeConflictCompare :action="a" :root="root" :local-label="home ? 'Home' : 'This project'" /> | 16 | + <ScopeConflictCompare :action="a" :root="root" :local-label="home ? 'Global' : 'This project'" /> |
| 17 | <ScopeConflictChoice | 17 | <ScopeConflictChoice |
| 18 | :model-value="scope.resolutionFor(a.id)" | 18 | :model-value="scope.resolutionFor(a.id)" |
| 19 | :action="a" | 19 | :action="a" |
modified
packages/ui/app/components/scope/ScopeReviewDrawer.vue +3 -3 | @@ -63,7 +63,7 @@ function toggle(id: string) { | ||
| 63 | 63 | const filesOf = (a: PlanAction) => a.files.length + a.configKeys.length |
| 64 | 64 | const isHide = (a: PlanAction) => hideIds.value.has(a.id) |
| 65 | 65 | const reasonOf = (a: PlanAction) => { |
| 66 | - if (isHide(a)) return a.kind === 'remove' ? 'Stops hiding this Home item in the project' : 'Hides this Home item in the project (Claude Code only)' | |
| 66 | + if (isHide(a)) return a.kind === 'remove' ? 'Stops hiding this Global item in the project' : 'Hides this Global item in the project (Claude Code only)' | |
| 67 | 67 | return a.conflict ? `${CONFLICT_HEADLINE[a.conflict]}${a.catalogUpdated ? ' · the catalog changed too' : ''}` : a.reason |
| 68 | 68 | } |
| 69 | 69 | |
| @@ -135,7 +135,7 @@ const kindLabel = (k: PlanActionKind) => PLAN_GROUPS.find(g => g.kind === k)?.la | ||
| 135 | 135 | <!-- Review --> |
| 136 | 136 | <div v-else class="flex flex-col gap-4"> |
| 137 | 137 | <SkNote v-if="error" tone="accent"><span class="font-medium">Apply failed.</span> {{ error }}</SkNote> |
| 138 | - <SkNote v-if="home" tone="accent">This writes into your Home agent folders (~/.claude, ~/.agents, ~/.codex). Every project sees these files.</SkNote> | |
| 138 | + <SkNote v-if="home" tone="accent">This writes into ~/.claude, ~/.agents and ~/.codex. Every project sees these files.</SkNote> | |
| 139 | 139 | <SkNote v-for="w in plan.warnings" :key="w" tone="accent">{{ w }}</SkNote> |
| 140 | 140 | <SkNote v-if="plan.missing.length" tone="accent"> |
| 141 | 141 | <p class="m-0">{{ plan.missing.length }} item{{ plan.missing.length === 1 ? '' : 's' }} referenced by the configuration but not found in the catalog. They will be skipped.</p> |
| @@ -181,7 +181,7 @@ const kindLabel = (k: PlanActionKind) => PLAN_GROUPS.find(g => g.kind === k)?.la | ||
| 181 | 181 | <li v-for="f in a.files" :key="f" class="break-all">{{ f }}</li> |
| 182 | 182 | <li v-for="c in a.configKeys" :key="c.file + c.key" class="break-all">{{ c.file }} : {{ c.key }}</li> |
| 183 | 183 | </ul> |
| 184 | - <ScopeConflictCompare v-if="a.kind === 'conflict'" :action="a" :root="plan.root" mode="split" :local-label="home ? 'Home' : 'This project'" /> | |
| 184 | + <ScopeConflictCompare v-if="a.kind === 'conflict'" :action="a" :root="plan.root" mode="split" :local-label="home ? 'Global' : 'This project'" /> | |
| 185 | 185 | </template> |
| 186 | 186 | </SkRowCard> |
| 187 | 187 | </SkTray> |
| @@ -63,7 +63,7 @@ function toggle(id: string) { | |||
| 63 | const filesOf = (a: PlanAction) => a.files.length + a.configKeys.length | 63 | const filesOf = (a: PlanAction) => a.files.length + a.configKeys.length |
| 64 | const isHide = (a: PlanAction) => hideIds.value.has(a.id) | 64 | const isHide = (a: PlanAction) => hideIds.value.has(a.id) |
| 65 | const reasonOf = (a: PlanAction) => { | 65 | const reasonOf = (a: PlanAction) => { |
| 66 | - if (isHide(a)) return a.kind === 'remove' ? 'Stops hiding this Home item in the project' : 'Hides this Home item in the project (Claude Code only)' | 66 | + if (isHide(a)) return a.kind === 'remove' ? 'Stops hiding this Global item in the project' : 'Hides this Global item in the project (Claude Code only)' |
| 67 | return a.conflict ? `${CONFLICT_HEADLINE[a.conflict]}${a.catalogUpdated ? ' · the catalog changed too' : ''}` : a.reason | 67 | return a.conflict ? `${CONFLICT_HEADLINE[a.conflict]}${a.catalogUpdated ? ' · the catalog changed too' : ''}` : a.reason |
| 68 | } | 68 | } |
| 69 | 69 | ||
| @@ -135,7 +135,7 @@ const kindLabel = (k: PlanActionKind) => PLAN_GROUPS.find(g => g.kind === k)?.la | |||
| 135 | <!-- Review --> | 135 | <!-- Review --> |
| 136 | <div v-else class="flex flex-col gap-4"> | 136 | <div v-else class="flex flex-col gap-4"> |
| 137 | <SkNote v-if="error" tone="accent"><span class="font-medium">Apply failed.</span> {{ error }}</SkNote> | 137 | <SkNote v-if="error" tone="accent"><span class="font-medium">Apply failed.</span> {{ error }}</SkNote> |
| 138 | - <SkNote v-if="home" tone="accent">This writes into your Home agent folders (~/.claude, ~/.agents, ~/.codex). Every project sees these files.</SkNote> | 138 | + <SkNote v-if="home" tone="accent">This writes into ~/.claude, ~/.agents and ~/.codex. Every project sees these files.</SkNote> |
| 139 | <SkNote v-for="w in plan.warnings" :key="w" tone="accent">{{ w }}</SkNote> | 139 | <SkNote v-for="w in plan.warnings" :key="w" tone="accent">{{ w }}</SkNote> |
| 140 | <SkNote v-if="plan.missing.length" tone="accent"> | 140 | <SkNote v-if="plan.missing.length" tone="accent"> |
| 141 | <p class="m-0">{{ plan.missing.length }} item{{ plan.missing.length === 1 ? '' : 's' }} referenced by the configuration but not found in the catalog. They will be skipped.</p> | 141 | <p class="m-0">{{ plan.missing.length }} item{{ plan.missing.length === 1 ? '' : 's' }} referenced by the configuration but not found in the catalog. They will be skipped.</p> |
| @@ -181,7 +181,7 @@ const kindLabel = (k: PlanActionKind) => PLAN_GROUPS.find(g => g.kind === k)?.la | |||
| 181 | <li v-for="f in a.files" :key="f" class="break-all">{{ f }}</li> | 181 | <li v-for="f in a.files" :key="f" class="break-all">{{ f }}</li> |
| 182 | <li v-for="c in a.configKeys" :key="c.file + c.key" class="break-all">{{ c.file }} : {{ c.key }}</li> | 182 | <li v-for="c in a.configKeys" :key="c.file + c.key" class="break-all">{{ c.file }} : {{ c.key }}</li> |
| 183 | </ul> | 183 | </ul> |
| 184 | - <ScopeConflictCompare v-if="a.kind === 'conflict'" :action="a" :root="plan.root" mode="split" :local-label="home ? 'Home' : 'This project'" /> | 184 | + <ScopeConflictCompare v-if="a.kind === 'conflict'" :action="a" :root="plan.root" mode="split" :local-label="home ? 'Global' : 'This project'" /> |
| 185 | </template> | 185 | </template> |
| 186 | </SkRowCard> | 186 | </SkRowCard> |
| 187 | </SkTray> | 187 | </SkTray> |
modified
packages/ui/app/components/scope/ScopeRowStatus.vue +1 -1 | @@ -9,7 +9,7 @@ const props = defineProps<{ row: ScopeRow }>() | ||
| 9 | 9 | const emit = defineEmits<{ action: [kind: RowAction] }>() |
| 10 | 10 | const scope = useScopeContext() |
| 11 | 11 | |
| 12 | -const ACTION_LABEL: Record<RowAction, string> = { resolve: 'Resolve', review: 'Review', import: 'Import' } | |
| 12 | +const ACTION_LABEL: Record<RowAction, string> = { resolve: 'Resolve', review: 'Review', import: 'Import', manage: 'Manage' } | |
| 13 | 13 | |
| 14 | 14 | const merged = computed(() => mergeStatuses(props.row.statuses)) |
| 15 | 15 | const staged = computed(() => props.row.conflicts.length > 0 && props.row.conflicts.every(a => scope.decided.value.has(a.id))) |
| @@ -9,7 +9,7 @@ const props = defineProps<{ row: ScopeRow }>() | |||
| 9 | const emit = defineEmits<{ action: [kind: RowAction] }>() | 9 | const emit = defineEmits<{ action: [kind: RowAction] }>() |
| 10 | const scope = useScopeContext() | 10 | const scope = useScopeContext() |
| 11 | 11 | ||
| 12 | -const ACTION_LABEL: Record<RowAction, string> = { resolve: 'Resolve', review: 'Review', import: 'Import' } | 12 | +const ACTION_LABEL: Record<RowAction, string> = { resolve: 'Resolve', review: 'Review', import: 'Import', manage: 'Manage' } |
| 13 | 13 | ||
| 14 | const merged = computed(() => mergeStatuses(props.row.statuses)) | 14 | const merged = computed(() => mergeStatuses(props.row.statuses)) |
| 15 | const staged = computed(() => props.row.conflicts.length > 0 && props.row.conflicts.every(a => scope.decided.value.has(a.id))) | 15 | const staged = computed(() => props.row.conflicts.length > 0 && props.row.conflicts.every(a => scope.decided.value.has(a.id))) |
modified
packages/ui/app/composables/useProjects.ts +1 -1 | @@ -35,7 +35,7 @@ export function useProjects() { | ||
| 35 | 35 | return { projects, loading, loaded, error, refresh, add, remove } |
| 36 | 36 | } |
| 37 | 37 | |
| 38 | -/** Shared home inventory (used by the dashboard card and the Home page). */ | |
| 38 | +/** Shared Home (Global scope) inventory: onboarding, inherited items of a project, the move modal. */ | |
| 39 | 39 | export function useHomeInventory() { |
| 40 | 40 | const { request } = useApi() |
| 41 | 41 | const home = useState<ScopeInventory | null>('skilly-home', () => null) |
| @@ -35,7 +35,7 @@ export function useProjects() { | |||
| 35 | return { projects, loading, loaded, error, refresh, add, remove } | 35 | return { projects, loading, loaded, error, refresh, add, remove } |
| 36 | } | 36 | } |
| 37 | 37 | ||
| 38 | -/** Shared home inventory (used by the dashboard card and the Home page). */ | 38 | +/** Shared Home (Global scope) inventory: onboarding, inherited items of a project, the move modal. */ |
| 39 | export function useHomeInventory() { | 39 | export function useHomeInventory() { |
| 40 | const { request } = useApi() | 40 | const { request } = useApi() |
| 41 | const home = useState<ScopeInventory | null>('skilly-home', () => null) | 41 | const home = useState<ScopeInventory | null>('skilly-home', () => null) |
modified
packages/ui/app/composables/useScopeState.ts +26 -3 | @@ -1,4 +1,4 @@ | ||
| 1 | -import type { AgentId, ConflictResolution, InventoryItem, ScopeConfig, ScopeState, SyncPlan } from '@skilly/core' | |
| 1 | +import type { AgentId, CatalogItemType, ConflictResolution, ImportResult, InventoryItem, ScopeConfig, ScopeState, SyncPlan } from '@skilly/core' | |
| 2 | 2 | import type { InjectionKey } from 'vue' |
| 3 | 3 | |
| 4 | 4 | /** |
| @@ -27,7 +27,7 @@ export function createScope(kind: 'home' | 'project', projectId: () => string | | ||
| 27 | 27 | const plan = computed<SyncPlan | null>(() => state.value?.plan ?? null) |
| 28 | 28 | const project = computed(() => (kind === 'project' ? projects.value.find(p => p.id === scopeId.value) : undefined)) |
| 29 | 29 | const folderMissing = computed(() => kind === 'project' && !!project.value && !project.value.exists) |
| 30 | - const title = computed(() => (kind === 'home' ? 'Home' : project.value?.name ?? scopeId.value)) | |
| 30 | + const title = computed(() => (kind === 'home' ? 'Global' : project.value?.name ?? scopeId.value)) | |
| 31 | 31 | const rootPath = computed(() => inventory.value?.root ?? project.value?.path ?? '') |
| 32 | 32 | |
| 33 | 33 | async function load(showLoading = true) { |
| @@ -167,6 +167,29 @@ export function createScope(kind: 'home' | 'project', projectId: () => string | | ||
| 167 | 167 | async function afterCatalogChange() { |
| 168 | 168 | await Promise.all([load(false), catalog.refresh(), refreshProjects()]) |
| 169 | 169 | } |
| 170 | + | |
| 171 | + /** | |
| 172 | + * "Manage": adds catalog items to this scope's config, creating the config (with the detected agents) when the | |
| 173 | + * scope is not managed yet. Files already on disk with the catalog content are taken over on Apply, not rewritten. | |
| 174 | + * Global's config only takes skills, rules and subagents. Returns false when nothing changed. | |
| 175 | + */ | |
| 176 | + function manage(refs: { type: CatalogItemType, name: string }[]): boolean { | |
| 177 | + const usable = refs.filter(r => kind === 'project' || HOME_CONFIG_TYPES.has(r.type)) | |
| 178 | + if (!usable.length) return false | |
| 179 | + const detected = inventory.value?.agentsDetected ?? [] | |
| 180 | + const next = config.value ? cloneConfig(config.value) : emptyConfig(detected.length ? [...detected] : ['claude', 'codex']) | |
| 181 | + if (!manageInConfig(next, usable, catalog.presets.value) && config.value) return false | |
| 182 | + saveConfig(next, true) | |
| 183 | + return true | |
| 184 | + } | |
| 185 | + | |
| 186 | + /** After "Add to catalog" from this scope: the imported items stay here, now managed by skilly. */ | |
| 187 | + async function afterImport(res: ImportResult | null) { | |
| 188 | + await catalog.refresh() | |
| 189 | + if (res && manage(res.imported.map(i => ({ type: i.type, name: i.name })))) await flush() | |
| 190 | + else await load(false) | |
| 191 | + refreshProjects() | |
| 192 | + } | |
| 170 | 193 | function adoptState(next: ScopeState) { |
| 171 | 194 | state.value = next |
| 172 | 195 | config.value = next.config ? cloneConfig(next.config) : null |
| @@ -183,7 +206,7 @@ export function createScope(kind: 'home' | 'project', projectId: () => string | | ||
| 183 | 206 | resolutions, decided, resolutionFor, setResolution, resolutionBody, |
| 184 | 207 | pending, conflictActions, openConflicts, targets, |
| 185 | 208 | selectedItem, itemOpen, reviewOpen, importItems, importOpen, moveItems, moveOpen, |
| 186 | - openItem, askImport, askMove, openReview, afterCatalogChange, adoptState | |
| 209 | + openItem, askImport, askMove, openReview, afterCatalogChange, afterImport, manage, adoptState | |
| 187 | 210 | } |
| 188 | 211 | } |
| 189 | 212 | |
| @@ -1,4 +1,4 @@ | |||
| 1 | -import type { AgentId, ConflictResolution, InventoryItem, ScopeConfig, ScopeState, SyncPlan } from '@skilly/core' | 1 | +import type { AgentId, CatalogItemType, ConflictResolution, ImportResult, InventoryItem, ScopeConfig, ScopeState, SyncPlan } from '@skilly/core' |
| 2 | import type { InjectionKey } from 'vue' | 2 | import type { InjectionKey } from 'vue' |
| 3 | 3 | ||
| 4 | /** | 4 | /** |
| @@ -27,7 +27,7 @@ export function createScope(kind: 'home' | 'project', projectId: () => string | | |||
| 27 | const plan = computed<SyncPlan | null>(() => state.value?.plan ?? null) | 27 | const plan = computed<SyncPlan | null>(() => state.value?.plan ?? null) |
| 28 | const project = computed(() => (kind === 'project' ? projects.value.find(p => p.id === scopeId.value) : undefined)) | 28 | const project = computed(() => (kind === 'project' ? projects.value.find(p => p.id === scopeId.value) : undefined)) |
| 29 | const folderMissing = computed(() => kind === 'project' && !!project.value && !project.value.exists) | 29 | const folderMissing = computed(() => kind === 'project' && !!project.value && !project.value.exists) |
| 30 | - const title = computed(() => (kind === 'home' ? 'Home' : project.value?.name ?? scopeId.value)) | 30 | + const title = computed(() => (kind === 'home' ? 'Global' : project.value?.name ?? scopeId.value)) |
| 31 | const rootPath = computed(() => inventory.value?.root ?? project.value?.path ?? '') | 31 | const rootPath = computed(() => inventory.value?.root ?? project.value?.path ?? '') |
| 32 | 32 | ||
| 33 | async function load(showLoading = true) { | 33 | async function load(showLoading = true) { |
| @@ -167,6 +167,29 @@ export function createScope(kind: 'home' | 'project', projectId: () => string | | |||
| 167 | async function afterCatalogChange() { | 167 | async function afterCatalogChange() { |
| 168 | await Promise.all([load(false), catalog.refresh(), refreshProjects()]) | 168 | await Promise.all([load(false), catalog.refresh(), refreshProjects()]) |
| 169 | } | 169 | } |
| 170 | + | ||
| 171 | + /** | ||
| 172 | + * "Manage": adds catalog items to this scope's config, creating the config (with the detected agents) when the | ||
| 173 | + * scope is not managed yet. Files already on disk with the catalog content are taken over on Apply, not rewritten. | ||
| 174 | + * Global's config only takes skills, rules and subagents. Returns false when nothing changed. | ||
| 175 | + */ | ||
| 176 | + function manage(refs: { type: CatalogItemType, name: string }[]): boolean { | ||
| 177 | + const usable = refs.filter(r => kind === 'project' || HOME_CONFIG_TYPES.has(r.type)) | ||
| 178 | + if (!usable.length) return false | ||
| 179 | + const detected = inventory.value?.agentsDetected ?? [] | ||
| 180 | + const next = config.value ? cloneConfig(config.value) : emptyConfig(detected.length ? [...detected] : ['claude', 'codex']) | ||
| 181 | + if (!manageInConfig(next, usable, catalog.presets.value) && config.value) return false | ||
| 182 | + saveConfig(next, true) | ||
| 183 | + return true | ||
| 184 | + } | ||
| 185 | + | ||
| 186 | + /** After "Add to catalog" from this scope: the imported items stay here, now managed by skilly. */ | ||
| 187 | + async function afterImport(res: ImportResult | null) { | ||
| 188 | + await catalog.refresh() | ||
| 189 | + if (res && manage(res.imported.map(i => ({ type: i.type, name: i.name })))) await flush() | ||
| 190 | + else await load(false) | ||
| 191 | + refreshProjects() | ||
| 192 | + } | ||
| 170 | function adoptState(next: ScopeState) { | 193 | function adoptState(next: ScopeState) { |
| 171 | state.value = next | 194 | state.value = next |
| 172 | config.value = next.config ? cloneConfig(next.config) : null | 195 | config.value = next.config ? cloneConfig(next.config) : null |
| @@ -183,7 +206,7 @@ export function createScope(kind: 'home' | 'project', projectId: () => string | | |||
| 183 | resolutions, decided, resolutionFor, setResolution, resolutionBody, | 206 | resolutions, decided, resolutionFor, setResolution, resolutionBody, |
| 184 | pending, conflictActions, openConflicts, targets, | 207 | pending, conflictActions, openConflicts, targets, |
| 185 | selectedItem, itemOpen, reviewOpen, importItems, importOpen, moveItems, moveOpen, | 208 | selectedItem, itemOpen, reviewOpen, importItems, importOpen, moveItems, moveOpen, |
| 186 | - openItem, askImport, askMove, openReview, afterCatalogChange, adoptState | 209 | + openItem, askImport, askMove, openReview, afterCatalogChange, afterImport, manage, adoptState |
| 187 | } | 210 | } |
| 188 | } | 211 | } |
| 189 | 212 | ||
added
packages/ui/app/composables/useScopeSummaries.ts +67 -0 | new file mode 100644 | ||
| @@ -0,0 +1,67 @@ | ||
| 1 | +import type { ScopeState } from '@skilly/core' | |
| 2 | +import type { Tone } from '../utils/status' | |
| 3 | + | |
| 4 | +export interface ScopeSummary { | |
| 5 | + managed: boolean | |
| 6 | + agents: string | |
| 7 | + presets: string[] | |
| 8 | + items: number | |
| 9 | + status: { tone: Tone, label: string } | |
| 10 | + root?: string | |
| 11 | + /** Distinct items with a pending plan action (0 when not managed). */ | |
| 12 | + pending: number | |
| 13 | + /** Items referenced by the config but absent from the catalog. */ | |
| 14 | + missing: number | |
| 15 | +} | |
| 16 | + | |
| 17 | +/** | |
| 18 | + * Per-scope status shared by the Projects table and the dashboard. It needs a scan of each scope, so it is fetched | |
| 19 | + * lazily, three at a time, and tolerates failures (`null` = unavailable). The Global scope state is kept whole for | |
| 20 | + * the dashboard's "to tidy up" count. | |
| 21 | + */ | |
| 22 | +export function useScopeSummaries() { | |
| 23 | + const { request } = useApi() | |
| 24 | + const { projects } = useProjects() | |
| 25 | + const catalog = useCatalog() | |
| 26 | + const summaries = useState<Record<string, ScopeSummary | null>>('skilly-scope-summaries', () => ({})) | |
| 27 | + const homeState = useState<ScopeState | null>('skilly-home-state', () => null) | |
| 28 | + const loading = useState('skilly-scope-summaries-loading', () => false) | |
| 29 | + | |
| 30 | + function summarize(s: ScopeState): ScopeSummary { | |
| 31 | + const cfg = s.config | |
| 32 | + const inv = s.inventory | |
| 33 | + let items = inv.items.filter(i => i.type !== 'instructions').length | |
| 34 | + if (cfg) { | |
| 35 | + const entries = resolveItems(catalog.presets.value, cfg.presets, cfg.add).entries | |
| 36 | + items = entries.filter(e => !matchIn(cfg.exclude[e.type], e.name)).length | |
| 37 | + } | |
| 38 | + const targets = cfg?.targets ?? inv.agentsDetected | |
| 39 | + const agents = targets.length ? targets.map(a => AGENT_LABELS[a]).join(', ') : '—' | |
| 40 | + const status = !cfg ? { tone: 'faint' as Tone, label: 'Not managed' } : summarizePlan(s.plan) | |
| 41 | + const pending = cfg ? new Set((s.plan?.actions ?? []).filter(isPending).map(a => `${a.type}/${a.name}`)).size : 0 | |
| 42 | + return { managed: !!cfg, agents, presets: cfg?.presets ?? [], items, status, root: inv.root, pending, missing: s.plan?.missing.length ?? 0 } | |
| 43 | + } | |
| 44 | + | |
| 45 | + async function load() { | |
| 46 | + loading.value = true | |
| 47 | + const queue = ['home', ...projects.value.filter(p => p.exists).map(p => p.id)] | |
| 48 | + const worker = async () => { | |
| 49 | + for (let id = queue.shift(); id; id = queue.shift()) { | |
| 50 | + try { | |
| 51 | + const s = await request<ScopeState>(`/api/scopes/${encodeURIComponent(id)}/state`, { silent: true }) | |
| 52 | + if (id === 'home') homeState.value = s | |
| 53 | + summaries.value = { ...summaries.value, [id]: summarize(s) } | |
| 54 | + } catch { | |
| 55 | + summaries.value = { ...summaries.value, [id]: null } | |
| 56 | + } | |
| 57 | + } | |
| 58 | + } | |
| 59 | + try { | |
| 60 | + await Promise.all([worker(), worker(), worker()]) | |
| 61 | + } finally { | |
| 62 | + loading.value = false | |
| 63 | + } | |
| 64 | + } | |
| 65 | + | |
| 66 | + return { summaries, homeState, loading, load } | |
| 67 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,67 @@ | |||
| 1 | +import type { ScopeState } from '@skilly/core' | ||
| 2 | +import type { Tone } from '../utils/status' | ||
| 3 | + | ||
| 4 | +export interface ScopeSummary { | ||
| 5 | + managed: boolean | ||
| 6 | + agents: string | ||
| 7 | + presets: string[] | ||
| 8 | + items: number | ||
| 9 | + status: { tone: Tone, label: string } | ||
| 10 | + root?: string | ||
| 11 | + /** Distinct items with a pending plan action (0 when not managed). */ | ||
| 12 | + pending: number | ||
| 13 | + /** Items referenced by the config but absent from the catalog. */ | ||
| 14 | + missing: number | ||
| 15 | +} | ||
| 16 | + | ||
| 17 | +/** | ||
| 18 | + * Per-scope status shared by the Projects table and the dashboard. It needs a scan of each scope, so it is fetched | ||
| 19 | + * lazily, three at a time, and tolerates failures (`null` = unavailable). The Global scope state is kept whole for | ||
| 20 | + * the dashboard's "to tidy up" count. | ||
| 21 | + */ | ||
| 22 | +export function useScopeSummaries() { | ||
| 23 | + const { request } = useApi() | ||
| 24 | + const { projects } = useProjects() | ||
| 25 | + const catalog = useCatalog() | ||
| 26 | + const summaries = useState<Record<string, ScopeSummary | null>>('skilly-scope-summaries', () => ({})) | ||
| 27 | + const homeState = useState<ScopeState | null>('skilly-home-state', () => null) | ||
| 28 | + const loading = useState('skilly-scope-summaries-loading', () => false) | ||
| 29 | + | ||
| 30 | + function summarize(s: ScopeState): ScopeSummary { | ||
| 31 | + const cfg = s.config | ||
| 32 | + const inv = s.inventory | ||
| 33 | + let items = inv.items.filter(i => i.type !== 'instructions').length | ||
| 34 | + if (cfg) { | ||
| 35 | + const entries = resolveItems(catalog.presets.value, cfg.presets, cfg.add).entries | ||
| 36 | + items = entries.filter(e => !matchIn(cfg.exclude[e.type], e.name)).length | ||
| 37 | + } | ||
| 38 | + const targets = cfg?.targets ?? inv.agentsDetected | ||
| 39 | + const agents = targets.length ? targets.map(a => AGENT_LABELS[a]).join(', ') : '—' | ||
| 40 | + const status = !cfg ? { tone: 'faint' as Tone, label: 'Not managed' } : summarizePlan(s.plan) | ||
| 41 | + const pending = cfg ? new Set((s.plan?.actions ?? []).filter(isPending).map(a => `${a.type}/${a.name}`)).size : 0 | ||
| 42 | + return { managed: !!cfg, agents, presets: cfg?.presets ?? [], items, status, root: inv.root, pending, missing: s.plan?.missing.length ?? 0 } | ||
| 43 | + } | ||
| 44 | + | ||
| 45 | + async function load() { | ||
| 46 | + loading.value = true | ||
| 47 | + const queue = ['home', ...projects.value.filter(p => p.exists).map(p => p.id)] | ||
| 48 | + const worker = async () => { | ||
| 49 | + for (let id = queue.shift(); id; id = queue.shift()) { | ||
| 50 | + try { | ||
| 51 | + const s = await request<ScopeState>(`/api/scopes/${encodeURIComponent(id)}/state`, { silent: true }) | ||
| 52 | + if (id === 'home') homeState.value = s | ||
| 53 | + summaries.value = { ...summaries.value, [id]: summarize(s) } | ||
| 54 | + } catch { | ||
| 55 | + summaries.value = { ...summaries.value, [id]: null } | ||
| 56 | + } | ||
| 57 | + } | ||
| 58 | + } | ||
| 59 | + try { | ||
| 60 | + await Promise.all([worker(), worker(), worker()]) | ||
| 61 | + } finally { | ||
| 62 | + loading.value = false | ||
| 63 | + } | ||
| 64 | + } | ||
| 65 | + | ||
| 66 | + return { summaries, homeState, loading, load } | ||
| 67 | +} | ||
modified
packages/ui/app/pages/home.vue +7 -1 | @@ -1,3 +1,9 @@ | ||
| 1 | +<script setup lang="ts"> | |
| 2 | +/** Old route of the Global scope: kept so bookmarks and older links still work. Old tab names are dropped. */ | |
| 3 | +const route = useRoute() | |
| 4 | +await navigateTo({ path: GLOBAL_PATH, query: { ...route.query, tab: undefined } }, { replace: true }) | |
| 5 | +</script> | |
| 6 | + | |
| 1 | 7 | <template> |
| 2 | - <ScopePage kind="home" /> | |
| 8 | + <div /> | |
| 3 | 9 | </template> |
| @@ -1,3 +1,9 @@ | |||
| 1 | +<script setup lang="ts"> | ||
| 2 | +/** Old route of the Global scope: kept so bookmarks and older links still work. Old tab names are dropped. */ | ||
| 3 | +const route = useRoute() | ||
| 4 | +await navigateTo({ path: GLOBAL_PATH, query: { ...route.query, tab: undefined } }, { replace: true }) | ||
| 5 | +</script> | ||
| 6 | + | ||
| 1 | <template> | 7 | <template> |
| 2 | - <ScopePage kind="home" /> | 8 | + <div /> |
| 3 | </template> | 9 | </template> |
modified
packages/ui/app/pages/index.vue +124 -11 | @@ -1,20 +1,133 @@ | ||
| 1 | 1 | <script setup lang="ts"> |
| 2 | +import type { DashboardAction, OverviewCard } from '../utils/dashboard' | |
| 3 | + | |
| 2 | 4 | /** |
| 3 | - * Landing: Projects, or the onboarding on first run (no catalog and no project yet), unless the user skipped or | |
| 4 | - * finished it once (`skilly-onboarding-done`). | |
| 5 | + * Dashboard (`/`): what skilly does, what needs attention ("To do now"), a "Getting started" checklist until it is | |
| 6 | + * done, entry points per area and the recent projects. First run (no catalog and no project yet) goes to the | |
| 7 | + * onboarding instead, unless the user skipped or finished it once (`skilly-onboarding-done`). | |
| 5 | 8 | */ |
| 6 | -const { refresh: refreshProjects } = useProjects() | |
| 7 | -const { refresh: refreshCatalog } = useCatalog() | |
| 9 | +const { projects, refresh: refreshProjects } = useProjects() | |
| 10 | +const catalog = useCatalog() | |
| 11 | + | |
| 12 | +await Promise.all([refreshProjects(), catalog.refresh()]) | |
| 13 | +let onboarded = false | |
| 14 | +try { onboarded = !!localStorage.getItem('skilly-onboarding-done') } catch { /* private mode */ } | |
| 15 | +if (!catalog.exists.value && !projects.value.length && !onboarded) await navigateTo('/onboarding', { replace: true }) | |
| 16 | + | |
| 17 | +const { summaries, homeState, load } = useScopeSummaries() | |
| 18 | +const { busy, run } = useCatalogGit() | |
| 19 | +const addOpen = useAddProjectModal() | |
| 20 | + | |
| 21 | +onMounted(() => load()) | |
| 22 | + | |
| 23 | +async function refreshAll() { | |
| 24 | + await Promise.all([refreshProjects(), catalog.refresh()]) | |
| 25 | + await load() | |
| 26 | +} | |
| 27 | + | |
| 28 | +const live = computed(() => projects.value.filter(p => p.exists)) | |
| 29 | +const scanning = computed(() => ['home', ...live.value.map(p => p.id)].some(id => summaries.value[id] === undefined)) | |
| 30 | + | |
| 31 | +const tidyUp = computed(() => (homeState.value | |
| 32 | + ? tidyUpCount(homeState.value.inventory.items, homeState.value.statuses, catalog.usage.value, projects.value) | |
| 33 | + : 0)) | |
| 8 | 34 | |
| 9 | -await Promise.all([refreshProjects(), refreshCatalog()]) | |
| 10 | -const catalogExists = useCatalog().exists.value | |
| 11 | -const hasProjects = useProjects().projects.value.length > 0 | |
| 12 | -let done = false | |
| 13 | -try { done = !!localStorage.getItem('skilly-onboarding-done') } catch { /* private mode */ } | |
| 35 | +const todos = computed(() => dashboardTodos({ | |
| 36 | + projects: projects.value, | |
| 37 | + summaries: summaries.value, | |
| 38 | + git: catalog.git.value, | |
| 39 | + catalogExists: catalog.exists.value, | |
| 40 | + tidyUp: tidyUp.value | |
| 41 | +})) | |
| 14 | 42 | |
| 15 | -await navigateTo(!catalogExists && !hasProjects && !done ? '/onboarding' : '/projects', { replace: true }) | |
| 43 | +/* ---------------------------------------------------------------- getting started */ | |
| 44 | + | |
| 45 | +const START_HIDDEN = 'skilly-dashboard-start-hidden' | |
| 46 | +const startHidden = ref(false) | |
| 47 | +try { startHidden.value = !!localStorage.getItem(START_HIDDEN) } catch { /* private mode */ } | |
| 48 | +function hideStart() { | |
| 49 | + startHidden.value = true | |
| 50 | + try { localStorage.setItem(START_HIDDEN, '1') } catch { /* private mode */ } | |
| 51 | +} | |
| 52 | + | |
| 53 | +const steps = computed(() => startSteps({ | |
| 54 | + catalogExists: catalog.exists.value, | |
| 55 | + catalogItems: catalog.items.value.length, | |
| 56 | + projects: projects.value, | |
| 57 | + managedProjects: projects.value.filter(p => summaries.value[p.id]?.managed).length, | |
| 58 | + remote: !!catalog.git.value?.remote | |
| 59 | +})) | |
| 60 | +// Wait for the scans so a finished checklist doesn't flash while the summaries load. | |
| 61 | +const showStart = computed(() => !startHidden.value && !scanning.value && steps.value.some(s => !s.done)) | |
| 62 | + | |
| 63 | +async function onRun(a: DashboardAction) { | |
| 64 | + if (a.run === 'add-project') addOpen.value = true | |
| 65 | + else if (a.run === 'sync') { | |
| 66 | + if (await run('sync', undefined, 'Catalog synced')) load() | |
| 67 | + } | |
| 68 | +} | |
| 69 | + | |
| 70 | +/* ---------------------------------------------------------------- overview */ | |
| 71 | + | |
| 72 | +const cards = computed<OverviewCard[]>(() => { | |
| 73 | + const global = summaries.value.home | |
| 74 | + const globalCount = homeState.value?.inventory.items.filter(i => i.type !== 'instructions').length | |
| 75 | + const toApply = live.value.filter(p => (summaries.value[p.id]?.pending ?? 0) > 0).length | |
| 76 | + const pill = gitPillState(catalog.git.value, { catalogExists: catalog.exists.value, busy: !!busy.value }) | |
| 77 | + return [ | |
| 78 | + { | |
| 79 | + id: 'global', | |
| 80 | + title: 'Global', | |
| 81 | + to: GLOBAL_PATH, | |
| 82 | + value: globalCount ?? '…', | |
| 83 | + caption: 'items loaded in every project', | |
| 84 | + status: global ? global.status : undefined | |
| 85 | + }, | |
| 86 | + { | |
| 87 | + id: 'projects', | |
| 88 | + title: 'Projects', | |
| 89 | + to: '/projects', | |
| 90 | + value: projects.value.length, | |
| 91 | + caption: projects.value.length === 1 ? 'project' : 'projects', | |
| 92 | + status: !projects.value.length | |
| 93 | + ? { tone: 'faint', label: 'None yet' } | |
| 94 | + : scanning.value ? undefined | |
| 95 | + : toApply ? { tone: 'info', label: `${toApply} with changes to apply` } : { tone: 'ok', label: 'All up to date' } | |
| 96 | + }, | |
| 97 | + { | |
| 98 | + id: 'catalog', | |
| 99 | + title: 'Catalog', | |
| 100 | + to: '/catalog', | |
| 101 | + value: catalog.exists.value ? catalog.items.value.length : '—', | |
| 102 | + caption: catalog.exists.value ? `items · ${catalog.presets.value.length} preset${catalog.presets.value.length === 1 ? '' : 's'}` : 'no catalog yet' | |
| 103 | + }, | |
| 104 | + { | |
| 105 | + id: 'sync', | |
| 106 | + title: 'Sync', | |
| 107 | + to: '/sync', | |
| 108 | + value: catalog.git.value?.branch ?? '—', | |
| 109 | + caption: catalog.git.value?.remote ? 'catalog branch, shared with git' : 'not shared yet', | |
| 110 | + status: { tone: pill.tone, label: pill.label } | |
| 111 | + } | |
| 112 | + ] | |
| 113 | +}) | |
| 16 | 114 | </script> |
| 17 | 115 | |
| 18 | 116 | <template> |
| 19 | - <div /> | |
| 117 | + <SkPage> | |
| 118 | + <SkPageHeader | |
| 119 | + title="Dashboard" | |
| 120 | + subtitle="skilly keeps your skills, rules, MCP servers, hooks and subagents in one catalog, and writes them for Claude Code and Codex where you need them: everywhere (Global) or per project." | |
| 121 | + > | |
| 122 | + <template #actions> | |
| 123 | + <SkButton variant="ghost" icon="i-lucide-refresh-cw" label="Refresh" :loading="scanning" @click="refreshAll()" /> | |
| 124 | + </template> | |
| 125 | + </SkPageHeader> | |
| 126 | + | |
| 127 | + <!-- "Nothing to do" is noise next to an unfinished checklist. --> | |
| 128 | + <DashboardTodo v-if="todos.length || scanning || !showStart" :todos="todos" :scanning="scanning" :syncing="busy === 'sync'" @run="onRun" /> | |
| 129 | + <DashboardStart v-if="showStart" :steps="steps" @run="onRun" @hide="hideStart" /> | |
| 130 | + <DashboardOverview :cards="cards" /> | |
| 131 | + <DashboardRecent :projects="projects" :summaries="summaries" /> | |
| 132 | + </SkPage> | |
| 20 | 133 | </template> |
| @@ -1,20 +1,133 @@ | |||
| 1 | <script setup lang="ts"> | 1 | <script setup lang="ts"> |
| 2 | +import type { DashboardAction, OverviewCard } from '../utils/dashboard' | ||
| 3 | + | ||
| 2 | /** | 4 | /** |
| 3 | - * Landing: Projects, or the onboarding on first run (no catalog and no project yet), unless the user skipped or | 5 | + * Dashboard (`/`): what skilly does, what needs attention ("To do now"), a "Getting started" checklist until it is |
| 4 | - * finished it once (`skilly-onboarding-done`). | 6 | + * done, entry points per area and the recent projects. First run (no catalog and no project yet) goes to the |
| 7 | + * onboarding instead, unless the user skipped or finished it once (`skilly-onboarding-done`). | ||
| 5 | */ | 8 | */ |
| 6 | -const { refresh: refreshProjects } = useProjects() | 9 | +const { projects, refresh: refreshProjects } = useProjects() |
| 7 | -const { refresh: refreshCatalog } = useCatalog() | 10 | +const catalog = useCatalog() |
| 11 | + | ||
| 12 | +await Promise.all([refreshProjects(), catalog.refresh()]) | ||
| 13 | +let onboarded = false | ||
| 14 | +try { onboarded = !!localStorage.getItem('skilly-onboarding-done') } catch { /* private mode */ } | ||
| 15 | +if (!catalog.exists.value && !projects.value.length && !onboarded) await navigateTo('/onboarding', { replace: true }) | ||
| 16 | + | ||
| 17 | +const { summaries, homeState, load } = useScopeSummaries() | ||
| 18 | +const { busy, run } = useCatalogGit() | ||
| 19 | +const addOpen = useAddProjectModal() | ||
| 20 | + | ||
| 21 | +onMounted(() => load()) | ||
| 22 | + | ||
| 23 | +async function refreshAll() { | ||
| 24 | + await Promise.all([refreshProjects(), catalog.refresh()]) | ||
| 25 | + await load() | ||
| 26 | +} | ||
| 27 | + | ||
| 28 | +const live = computed(() => projects.value.filter(p => p.exists)) | ||
| 29 | +const scanning = computed(() => ['home', ...live.value.map(p => p.id)].some(id => summaries.value[id] === undefined)) | ||
| 30 | + | ||
| 31 | +const tidyUp = computed(() => (homeState.value | ||
| 32 | + ? tidyUpCount(homeState.value.inventory.items, homeState.value.statuses, catalog.usage.value, projects.value) | ||
| 33 | + : 0)) | ||
| 8 | 34 | ||
| 9 | -await Promise.all([refreshProjects(), refreshCatalog()]) | 35 | +const todos = computed(() => dashboardTodos({ |
| 10 | -const catalogExists = useCatalog().exists.value | 36 | + projects: projects.value, |
| 11 | -const hasProjects = useProjects().projects.value.length > 0 | 37 | + summaries: summaries.value, |
| 12 | -let done = false | 38 | + git: catalog.git.value, |
| 13 | -try { done = !!localStorage.getItem('skilly-onboarding-done') } catch { /* private mode */ } | 39 | + catalogExists: catalog.exists.value, |
| 40 | + tidyUp: tidyUp.value | ||
| 41 | +})) | ||
| 14 | 42 | ||
| 15 | -await navigateTo(!catalogExists && !hasProjects && !done ? '/onboarding' : '/projects', { replace: true }) | 43 | +/* ---------------------------------------------------------------- getting started */ |
| 44 | + | ||
| 45 | +const START_HIDDEN = 'skilly-dashboard-start-hidden' | ||
| 46 | +const startHidden = ref(false) | ||
| 47 | +try { startHidden.value = !!localStorage.getItem(START_HIDDEN) } catch { /* private mode */ } | ||
| 48 | +function hideStart() { | ||
| 49 | + startHidden.value = true | ||
| 50 | + try { localStorage.setItem(START_HIDDEN, '1') } catch { /* private mode */ } | ||
| 51 | +} | ||
| 52 | + | ||
| 53 | +const steps = computed(() => startSteps({ | ||
| 54 | + catalogExists: catalog.exists.value, | ||
| 55 | + catalogItems: catalog.items.value.length, | ||
| 56 | + projects: projects.value, | ||
| 57 | + managedProjects: projects.value.filter(p => summaries.value[p.id]?.managed).length, | ||
| 58 | + remote: !!catalog.git.value?.remote | ||
| 59 | +})) | ||
| 60 | +// Wait for the scans so a finished checklist doesn't flash while the summaries load. | ||
| 61 | +const showStart = computed(() => !startHidden.value && !scanning.value && steps.value.some(s => !s.done)) | ||
| 62 | + | ||
| 63 | +async function onRun(a: DashboardAction) { | ||
| 64 | + if (a.run === 'add-project') addOpen.value = true | ||
| 65 | + else if (a.run === 'sync') { | ||
| 66 | + if (await run('sync', undefined, 'Catalog synced')) load() | ||
| 67 | + } | ||
| 68 | +} | ||
| 69 | + | ||
| 70 | +/* ---------------------------------------------------------------- overview */ | ||
| 71 | + | ||
| 72 | +const cards = computed<OverviewCard[]>(() => { | ||
| 73 | + const global = summaries.value.home | ||
| 74 | + const globalCount = homeState.value?.inventory.items.filter(i => i.type !== 'instructions').length | ||
| 75 | + const toApply = live.value.filter(p => (summaries.value[p.id]?.pending ?? 0) > 0).length | ||
| 76 | + const pill = gitPillState(catalog.git.value, { catalogExists: catalog.exists.value, busy: !!busy.value }) | ||
| 77 | + return [ | ||
| 78 | + { | ||
| 79 | + id: 'global', | ||
| 80 | + title: 'Global', | ||
| 81 | + to: GLOBAL_PATH, | ||
| 82 | + value: globalCount ?? '…', | ||
| 83 | + caption: 'items loaded in every project', | ||
| 84 | + status: global ? global.status : undefined | ||
| 85 | + }, | ||
| 86 | + { | ||
| 87 | + id: 'projects', | ||
| 88 | + title: 'Projects', | ||
| 89 | + to: '/projects', | ||
| 90 | + value: projects.value.length, | ||
| 91 | + caption: projects.value.length === 1 ? 'project' : 'projects', | ||
| 92 | + status: !projects.value.length | ||
| 93 | + ? { tone: 'faint', label: 'None yet' } | ||
| 94 | + : scanning.value ? undefined | ||
| 95 | + : toApply ? { tone: 'info', label: `${toApply} with changes to apply` } : { tone: 'ok', label: 'All up to date' } | ||
| 96 | + }, | ||
| 97 | + { | ||
| 98 | + id: 'catalog', | ||
| 99 | + title: 'Catalog', | ||
| 100 | + to: '/catalog', | ||
| 101 | + value: catalog.exists.value ? catalog.items.value.length : '—', | ||
| 102 | + caption: catalog.exists.value ? `items · ${catalog.presets.value.length} preset${catalog.presets.value.length === 1 ? '' : 's'}` : 'no catalog yet' | ||
| 103 | + }, | ||
| 104 | + { | ||
| 105 | + id: 'sync', | ||
| 106 | + title: 'Sync', | ||
| 107 | + to: '/sync', | ||
| 108 | + value: catalog.git.value?.branch ?? '—', | ||
| 109 | + caption: catalog.git.value?.remote ? 'catalog branch, shared with git' : 'not shared yet', | ||
| 110 | + status: { tone: pill.tone, label: pill.label } | ||
| 111 | + } | ||
| 112 | + ] | ||
| 113 | +}) | ||
| 16 | </script> | 114 | </script> |
| 17 | 115 | ||
| 18 | <template> | 116 | <template> |
| 19 | - <div /> | 117 | + <SkPage> |
| 118 | + <SkPageHeader | ||
| 119 | + title="Dashboard" | ||
| 120 | + subtitle="skilly keeps your skills, rules, MCP servers, hooks and subagents in one catalog, and writes them for Claude Code and Codex where you need them: everywhere (Global) or per project." | ||
| 121 | + > | ||
| 122 | + <template #actions> | ||
| 123 | + <SkButton variant="ghost" icon="i-lucide-refresh-cw" label="Refresh" :loading="scanning" @click="refreshAll()" /> | ||
| 124 | + </template> | ||
| 125 | + </SkPageHeader> | ||
| 126 | + | ||
| 127 | + <!-- "Nothing to do" is noise next to an unfinished checklist. --> | ||
| 128 | + <DashboardTodo v-if="todos.length || scanning || !showStart" :todos="todos" :scanning="scanning" :syncing="busy === 'sync'" @run="onRun" /> | ||
| 129 | + <DashboardStart v-if="showStart" :steps="steps" @run="onRun" @hide="hideStart" /> | ||
| 130 | + <DashboardOverview :cards="cards" /> | ||
| 131 | + <DashboardRecent :projects="projects" :summaries="summaries" /> | ||
| 132 | + </SkPage> | ||
| 20 | </template> | 133 | </template> |
modified
packages/ui/app/pages/onboarding.vue +1 -1 | @@ -189,7 +189,7 @@ async function addSelected(): Promise<boolean> { | ||
| 189 | 189 | /* ------------------------------------------------ Navigation */ |
| 190 | 190 | |
| 191 | 191 | const SKIP_KEY = 'skilly-onboarding-done' |
| 192 | -function finish(to = '/projects') { | |
| 192 | +function finish(to = '/') { | |
| 193 | 193 | try { localStorage.setItem(SKIP_KEY, '1') } catch { /* private mode */ } |
| 194 | 194 | return router.push(to) |
| 195 | 195 | } |
| @@ -189,7 +189,7 @@ async function addSelected(): Promise<boolean> { | |||
| 189 | /* ------------------------------------------------ Navigation */ | 189 | /* ------------------------------------------------ Navigation */ |
| 190 | 190 | ||
| 191 | const SKIP_KEY = 'skilly-onboarding-done' | 191 | const SKIP_KEY = 'skilly-onboarding-done' |
| 192 | -function finish(to = '/projects') { | 192 | +function finish(to = '/') { |
| 193 | try { localStorage.setItem(SKIP_KEY, '1') } catch { /* private mode */ } | 193 | try { localStorage.setItem(SKIP_KEY, '1') } catch { /* private mode */ } |
| 194 | return router.push(to) | 194 | return router.push(to) |
| 195 | } | 195 | } |
modified
packages/ui/app/pages/projects/[id].vue +2 -1 | @@ -4,5 +4,6 @@ const id = computed(() => String(route.params.id)) | ||
| 4 | 4 | </script> |
| 5 | 5 | |
| 6 | 6 | <template> |
| 7 | - <ScopePage :key="id" kind="project" :project-id="id" /> | |
| 7 | + <ScopePage v-if="id === GLOBAL_SEGMENT" key="home" kind="home" /> | |
| 8 | + <ScopePage v-else :key="id" kind="project" :project-id="id" /> | |
| 8 | 9 | </template> |
| @@ -4,5 +4,6 @@ const id = computed(() => String(route.params.id)) | |||
| 4 | </script> | 4 | </script> |
| 5 | 5 | ||
| 6 | <template> | 6 | <template> |
| 7 | - <ScopePage :key="id" kind="project" :project-id="id" /> | 7 | + <ScopePage v-if="id === GLOBAL_SEGMENT" key="home" kind="home" /> |
| 8 | + <ScopePage v-else :key="id" kind="project" :project-id="id" /> | ||
| 8 | </template> | 9 | </template> |
modified
packages/ui/app/utils/catalog.ts +9 -2 | @@ -34,8 +34,15 @@ export function globMatch(pattern: string, name: string): boolean { | ||
| 34 | 34 | return re.test(name) |
| 35 | 35 | } |
| 36 | 36 | |
| 37 | -export const scopeLabel = (id: string, names: Record<string, string>) => (id === 'home' ? 'Home' : names[id] ?? id) | |
| 38 | -export const scopeLink = (id: string) => (id === 'home' ? '/home' : `/projects/${encodeURIComponent(id)}`) | |
| 37 | +/** | |
| 38 | + * The Home scope (server id `home`: ~/.claude, ~/.agents, ~/.codex) is shown as the "Global" project. Project ids are | |
| 39 | + * 12-char hex hashes, so this route segment can't collide with a real project. | |
| 40 | + */ | |
| 41 | +export const GLOBAL_SEGMENT = 'global' | |
| 42 | +export const GLOBAL_PATH = `/projects/${GLOBAL_SEGMENT}` | |
| 43 | + | |
| 44 | +export const scopeLabel = (id: string, names: Record<string, string>) => (id === 'home' ? 'Global' : names[id] ?? id) | |
| 45 | +export const scopeLink = (id: string) => (id === 'home' ? GLOBAL_PATH : `/projects/${encodeURIComponent(id)}`) | |
| 39 | 46 | |
| 40 | 47 | /* ---------------------------------------------------------------- YAML */ |
| 41 | 48 | |
| @@ -34,8 +34,15 @@ export function globMatch(pattern: string, name: string): boolean { | |||
| 34 | return re.test(name) | 34 | return re.test(name) |
| 35 | } | 35 | } |
| 36 | 36 | ||
| 37 | -export const scopeLabel = (id: string, names: Record<string, string>) => (id === 'home' ? 'Home' : names[id] ?? id) | 37 | +/** |
| 38 | -export const scopeLink = (id: string) => (id === 'home' ? '/home' : `/projects/${encodeURIComponent(id)}`) | 38 | + * The Home scope (server id `home`: ~/.claude, ~/.agents, ~/.codex) is shown as the "Global" project. Project ids are |
| 39 | + * 12-char hex hashes, so this route segment can't collide with a real project. | ||
| 40 | + */ | ||
| 41 | +export const GLOBAL_SEGMENT = 'global' | ||
| 42 | +export const GLOBAL_PATH = `/projects/${GLOBAL_SEGMENT}` | ||
| 43 | + | ||
| 44 | +export const scopeLabel = (id: string, names: Record<string, string>) => (id === 'home' ? 'Global' : names[id] ?? id) | ||
| 45 | +export const scopeLink = (id: string) => (id === 'home' ? GLOBAL_PATH : `/projects/${encodeURIComponent(id)}`) | ||
| 39 | 46 | ||
| 40 | /* ---------------------------------------------------------------- YAML */ | 47 | /* ---------------------------------------------------------------- YAML */ |
| 41 | 48 | ||
added
packages/ui/app/utils/dashboard.ts +166 -0 | new file mode 100644 | ||
| @@ -0,0 +1,166 @@ | ||
| 1 | +import type { GitStatus, InventoryItem, ItemStatus } from '@skilly/core' | |
| 2 | +import type { Tone } from './status' | |
| 3 | +import { canMoveOutOfHome, projectSpecific } from './inventoryView' | |
| 4 | +import { GLOBAL_PATH, scopeLink } from './catalog' | |
| 5 | + | |
| 6 | +/** | |
| 7 | + * Dashboard (`/`) content, computed from data the app already loads: "To do now" rows and the "Getting started" | |
| 8 | + * checklist. Pure functions so they can be tested without the app. | |
| 9 | + */ | |
| 10 | + | |
| 11 | +/** The parts of a scope summary the dashboard needs (see useScopeSummaries). */ | |
| 12 | +export interface ScopeHealth { | |
| 13 | + managed: boolean | |
| 14 | + status: { tone: Tone, label: string } | |
| 15 | + pending: number | |
| 16 | + missing: number | |
| 17 | +} | |
| 18 | + | |
| 19 | +interface ProjectRef { id: string, name: string, exists: boolean } | |
| 20 | + | |
| 21 | +export interface DashboardAction { | |
| 22 | + label: string | |
| 23 | + to?: string | |
| 24 | + /** Runs an in-app action instead of navigating. */ | |
| 25 | + run?: 'sync' | 'add-project' | |
| 26 | +} | |
| 27 | + | |
| 28 | +export interface DashboardTodo { | |
| 29 | + id: string | |
| 30 | + tone: Tone | |
| 31 | + title: string | |
| 32 | + detail: string | |
| 33 | + action: DashboardAction | |
| 34 | +} | |
| 35 | + | |
| 36 | +/** Global items that look project-specific and can be moved out (the "To tidy up" panel of Global). */ | |
| 37 | +export function tidyUpCount( | |
| 38 | + items: InventoryItem[], | |
| 39 | + statuses: Record<string, ItemStatus>, | |
| 40 | + usage: Record<string, string[]>, | |
| 41 | + projects: ProjectRef[] | |
| 42 | +): number { | |
| 43 | + return items.filter(i => canMoveOutOfHome(i, statuses[i.id]) && projectSpecific(i, statuses[i.id], usage, projects)).length | |
| 44 | +} | |
| 45 | + | |
| 46 | +const plural = (n: number, w: string) => `${n} ${w}${n === 1 ? '' : 's'}` | |
| 47 | + | |
| 48 | +/** Rows of "To do now", most urgent first: conflicts and errors, then changes to apply, then housekeeping. */ | |
| 49 | +export function dashboardTodos(input: { | |
| 50 | + projects: ProjectRef[] | |
| 51 | + summaries: Record<string, ScopeHealth | null | undefined> | |
| 52 | + git: GitStatus | null | |
| 53 | + catalogExists: boolean | |
| 54 | + tidyUp: number | |
| 55 | +}): DashboardTodo[] { | |
| 56 | + const urgent: DashboardTodo[] = [] | |
| 57 | + const normal: DashboardTodo[] = [] | |
| 58 | + const later: DashboardTodo[] = [] | |
| 59 | + | |
| 60 | + const git = input.git | |
| 61 | + if (input.catalogExists && git?.isRepo) { | |
| 62 | + if (git.conflicts.length) { | |
| 63 | + urgent.push({ id: 'git-conflicts', tone: 'accent', title: 'Catalog', detail: `${plural(git.conflicts.length, 'file')} changed on both sides`, action: { label: 'Resolve', to: '/sync' } }) | |
| 64 | + } else if (git.remote && (git.ahead || git.behind)) { | |
| 65 | + const parts = [git.behind ? `${git.behind} to pull` : '', git.ahead ? `${git.ahead} to push` : ''].filter(Boolean) | |
| 66 | + normal.push({ id: 'git-sync', tone: 'info', title: 'Catalog', detail: parts.join(' · '), action: { label: 'Sync', run: 'sync' } }) | |
| 67 | + } else if (git.dirty.length) { | |
| 68 | + later.push({ id: 'git-dirty', tone: 'faint', title: 'Catalog', detail: `${plural(git.dirty.length, 'uncommitted change')}`, action: { label: 'Open Sync', to: '/sync' } }) | |
| 69 | + } | |
| 70 | + } | |
| 71 | + | |
| 72 | + const scopes = [{ id: 'home', name: 'Global', exists: true }, ...input.projects] | |
| 73 | + for (const p of scopes) { | |
| 74 | + if (!p.exists) { | |
| 75 | + urgent.push({ id: `${p.id}-missing-folder`, tone: 'accent', title: p.name, detail: 'Folder missing on disk', action: { label: 'Open', to: scopeLink(p.id) } }) | |
| 76 | + continue | |
| 77 | + } | |
| 78 | + const s = input.summaries[p.id] | |
| 79 | + if (!s?.managed) continue | |
| 80 | + if (s.pending) { | |
| 81 | + const row = { id: `${p.id}-pending`, tone: s.status.tone, title: p.name, detail: s.status.label, action: { label: 'Review', to: `${scopeLink(p.id)}?review=1` } } | |
| 82 | + ;(s.status.tone === 'accent' ? urgent : normal).push(row) | |
| 83 | + } | |
| 84 | + if (s.missing) { | |
| 85 | + urgent.push({ id: `${p.id}-not-in-catalog`, tone: 'accent', title: p.name, detail: `${plural(s.missing, 'item')} not found in the catalog`, action: { label: 'Open', to: scopeLink(p.id) } }) | |
| 86 | + } | |
| 87 | + } | |
| 88 | + | |
| 89 | + if (input.tidyUp) { | |
| 90 | + later.push({ | |
| 91 | + id: 'tidy-up', | |
| 92 | + tone: 'info', | |
| 93 | + title: 'Global', | |
| 94 | + detail: `${plural(input.tidyUp, 'item')} loaded everywhere but only used in some projects`, | |
| 95 | + action: { label: 'Tidy up', to: GLOBAL_PATH } | |
| 96 | + }) | |
| 97 | + } | |
| 98 | + | |
| 99 | + return [...urgent, ...normal, ...later] | |
| 100 | +} | |
| 101 | + | |
| 102 | +/** Overview card (Global, Projects, Catalog, Sync). */ | |
| 103 | +export interface OverviewCard { | |
| 104 | + id: string | |
| 105 | + title: string | |
| 106 | + to: string | |
| 107 | + value: string | number | |
| 108 | + caption: string | |
| 109 | + status?: { tone: Tone, label: string } | |
| 110 | +} | |
| 111 | + | |
| 112 | +export interface StartStep { | |
| 113 | + id: string | |
| 114 | + title: string | |
| 115 | + hint: string | |
| 116 | + done: boolean | |
| 117 | + action: DashboardAction | |
| 118 | +} | |
| 119 | + | |
| 120 | +/** "Getting started" checklist, in the order a new user goes through it. */ | |
| 121 | +export function startSteps(input: { | |
| 122 | + catalogExists: boolean | |
| 123 | + catalogItems: number | |
| 124 | + projects: ProjectRef[] | |
| 125 | + managedProjects: number | |
| 126 | + remote: boolean | |
| 127 | +}): StartStep[] { | |
| 128 | + const first = input.projects.find(p => p.exists) | |
| 129 | + return [ | |
| 130 | + { | |
| 131 | + id: 'catalog', | |
| 132 | + title: 'Create or clone your catalog', | |
| 133 | + hint: 'A git folder that holds your skills, rules, MCP servers, hooks and presets, in one place.', | |
| 134 | + done: input.catalogExists, | |
| 135 | + action: { label: 'Set up', to: '/catalog' } | |
| 136 | + }, | |
| 137 | + { | |
| 138 | + id: 'import', | |
| 139 | + title: 'Add your existing items to the catalog', | |
| 140 | + hint: 'Start from what is already installed in ~/.claude and ~/.codex (Global).', | |
| 141 | + done: input.catalogItems > 0, | |
| 142 | + action: { label: 'Open Global', to: GLOBAL_PATH } | |
| 143 | + }, | |
| 144 | + { | |
| 145 | + id: 'project', | |
| 146 | + title: 'Add a project', | |
| 147 | + hint: 'Pick a folder: skilly shows what Claude Code and Codex load there.', | |
| 148 | + done: input.projects.length > 0, | |
| 149 | + action: { label: 'Add project', run: 'add-project' } | |
| 150 | + }, | |
| 151 | + { | |
| 152 | + id: 'manage', | |
| 153 | + title: 'Choose what a project uses', | |
| 154 | + hint: 'Enable presets and catalog items, then review and apply: skilly writes the files.', | |
| 155 | + done: input.managedProjects > 0, | |
| 156 | + action: { label: 'Open', to: first ? scopeLink(first.id) : '/projects' } | |
| 157 | + }, | |
| 158 | + { | |
| 159 | + id: 'share', | |
| 160 | + title: 'Share the catalog with git', | |
| 161 | + hint: 'Push it to a remote to get the same setup on another machine or with your team.', | |
| 162 | + done: input.remote, | |
| 163 | + action: { label: 'Open Sync', to: '/sync' } | |
| 164 | + } | |
| 165 | + ] | |
| 166 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,166 @@ | |||
| 1 | +import type { GitStatus, InventoryItem, ItemStatus } from '@skilly/core' | ||
| 2 | +import type { Tone } from './status' | ||
| 3 | +import { canMoveOutOfHome, projectSpecific } from './inventoryView' | ||
| 4 | +import { GLOBAL_PATH, scopeLink } from './catalog' | ||
| 5 | + | ||
| 6 | +/** | ||
| 7 | + * Dashboard (`/`) content, computed from data the app already loads: "To do now" rows and the "Getting started" | ||
| 8 | + * checklist. Pure functions so they can be tested without the app. | ||
| 9 | + */ | ||
| 10 | + | ||
| 11 | +/** The parts of a scope summary the dashboard needs (see useScopeSummaries). */ | ||
| 12 | +export interface ScopeHealth { | ||
| 13 | + managed: boolean | ||
| 14 | + status: { tone: Tone, label: string } | ||
| 15 | + pending: number | ||
| 16 | + missing: number | ||
| 17 | +} | ||
| 18 | + | ||
| 19 | +interface ProjectRef { id: string, name: string, exists: boolean } | ||
| 20 | + | ||
| 21 | +export interface DashboardAction { | ||
| 22 | + label: string | ||
| 23 | + to?: string | ||
| 24 | + /** Runs an in-app action instead of navigating. */ | ||
| 25 | + run?: 'sync' | 'add-project' | ||
| 26 | +} | ||
| 27 | + | ||
| 28 | +export interface DashboardTodo { | ||
| 29 | + id: string | ||
| 30 | + tone: Tone | ||
| 31 | + title: string | ||
| 32 | + detail: string | ||
| 33 | + action: DashboardAction | ||
| 34 | +} | ||
| 35 | + | ||
| 36 | +/** Global items that look project-specific and can be moved out (the "To tidy up" panel of Global). */ | ||
| 37 | +export function tidyUpCount( | ||
| 38 | + items: InventoryItem[], | ||
| 39 | + statuses: Record<string, ItemStatus>, | ||
| 40 | + usage: Record<string, string[]>, | ||
| 41 | + projects: ProjectRef[] | ||
| 42 | +): number { | ||
| 43 | + return items.filter(i => canMoveOutOfHome(i, statuses[i.id]) && projectSpecific(i, statuses[i.id], usage, projects)).length | ||
| 44 | +} | ||
| 45 | + | ||
| 46 | +const plural = (n: number, w: string) => `${n} ${w}${n === 1 ? '' : 's'}` | ||
| 47 | + | ||
| 48 | +/** Rows of "To do now", most urgent first: conflicts and errors, then changes to apply, then housekeeping. */ | ||
| 49 | +export function dashboardTodos(input: { | ||
| 50 | + projects: ProjectRef[] | ||
| 51 | + summaries: Record<string, ScopeHealth | null | undefined> | ||
| 52 | + git: GitStatus | null | ||
| 53 | + catalogExists: boolean | ||
| 54 | + tidyUp: number | ||
| 55 | +}): DashboardTodo[] { | ||
| 56 | + const urgent: DashboardTodo[] = [] | ||
| 57 | + const normal: DashboardTodo[] = [] | ||
| 58 | + const later: DashboardTodo[] = [] | ||
| 59 | + | ||
| 60 | + const git = input.git | ||
| 61 | + if (input.catalogExists && git?.isRepo) { | ||
| 62 | + if (git.conflicts.length) { | ||
| 63 | + urgent.push({ id: 'git-conflicts', tone: 'accent', title: 'Catalog', detail: `${plural(git.conflicts.length, 'file')} changed on both sides`, action: { label: 'Resolve', to: '/sync' } }) | ||
| 64 | + } else if (git.remote && (git.ahead || git.behind)) { | ||
| 65 | + const parts = [git.behind ? `${git.behind} to pull` : '', git.ahead ? `${git.ahead} to push` : ''].filter(Boolean) | ||
| 66 | + normal.push({ id: 'git-sync', tone: 'info', title: 'Catalog', detail: parts.join(' · '), action: { label: 'Sync', run: 'sync' } }) | ||
| 67 | + } else if (git.dirty.length) { | ||
| 68 | + later.push({ id: 'git-dirty', tone: 'faint', title: 'Catalog', detail: `${plural(git.dirty.length, 'uncommitted change')}`, action: { label: 'Open Sync', to: '/sync' } }) | ||
| 69 | + } | ||
| 70 | + } | ||
| 71 | + | ||
| 72 | + const scopes = [{ id: 'home', name: 'Global', exists: true }, ...input.projects] | ||
| 73 | + for (const p of scopes) { | ||
| 74 | + if (!p.exists) { | ||
| 75 | + urgent.push({ id: `${p.id}-missing-folder`, tone: 'accent', title: p.name, detail: 'Folder missing on disk', action: { label: 'Open', to: scopeLink(p.id) } }) | ||
| 76 | + continue | ||
| 77 | + } | ||
| 78 | + const s = input.summaries[p.id] | ||
| 79 | + if (!s?.managed) continue | ||
| 80 | + if (s.pending) { | ||
| 81 | + const row = { id: `${p.id}-pending`, tone: s.status.tone, title: p.name, detail: s.status.label, action: { label: 'Review', to: `${scopeLink(p.id)}?review=1` } } | ||
| 82 | + ;(s.status.tone === 'accent' ? urgent : normal).push(row) | ||
| 83 | + } | ||
| 84 | + if (s.missing) { | ||
| 85 | + urgent.push({ id: `${p.id}-not-in-catalog`, tone: 'accent', title: p.name, detail: `${plural(s.missing, 'item')} not found in the catalog`, action: { label: 'Open', to: scopeLink(p.id) } }) | ||
| 86 | + } | ||
| 87 | + } | ||
| 88 | + | ||
| 89 | + if (input.tidyUp) { | ||
| 90 | + later.push({ | ||
| 91 | + id: 'tidy-up', | ||
| 92 | + tone: 'info', | ||
| 93 | + title: 'Global', | ||
| 94 | + detail: `${plural(input.tidyUp, 'item')} loaded everywhere but only used in some projects`, | ||
| 95 | + action: { label: 'Tidy up', to: GLOBAL_PATH } | ||
| 96 | + }) | ||
| 97 | + } | ||
| 98 | + | ||
| 99 | + return [...urgent, ...normal, ...later] | ||
| 100 | +} | ||
| 101 | + | ||
| 102 | +/** Overview card (Global, Projects, Catalog, Sync). */ | ||
| 103 | +export interface OverviewCard { | ||
| 104 | + id: string | ||
| 105 | + title: string | ||
| 106 | + to: string | ||
| 107 | + value: string | number | ||
| 108 | + caption: string | ||
| 109 | + status?: { tone: Tone, label: string } | ||
| 110 | +} | ||
| 111 | + | ||
| 112 | +export interface StartStep { | ||
| 113 | + id: string | ||
| 114 | + title: string | ||
| 115 | + hint: string | ||
| 116 | + done: boolean | ||
| 117 | + action: DashboardAction | ||
| 118 | +} | ||
| 119 | + | ||
| 120 | +/** "Getting started" checklist, in the order a new user goes through it. */ | ||
| 121 | +export function startSteps(input: { | ||
| 122 | + catalogExists: boolean | ||
| 123 | + catalogItems: number | ||
| 124 | + projects: ProjectRef[] | ||
| 125 | + managedProjects: number | ||
| 126 | + remote: boolean | ||
| 127 | +}): StartStep[] { | ||
| 128 | + const first = input.projects.find(p => p.exists) | ||
| 129 | + return [ | ||
| 130 | + { | ||
| 131 | + id: 'catalog', | ||
| 132 | + title: 'Create or clone your catalog', | ||
| 133 | + hint: 'A git folder that holds your skills, rules, MCP servers, hooks and presets, in one place.', | ||
| 134 | + done: input.catalogExists, | ||
| 135 | + action: { label: 'Set up', to: '/catalog' } | ||
| 136 | + }, | ||
| 137 | + { | ||
| 138 | + id: 'import', | ||
| 139 | + title: 'Add your existing items to the catalog', | ||
| 140 | + hint: 'Start from what is already installed in ~/.claude and ~/.codex (Global).', | ||
| 141 | + done: input.catalogItems > 0, | ||
| 142 | + action: { label: 'Open Global', to: GLOBAL_PATH } | ||
| 143 | + }, | ||
| 144 | + { | ||
| 145 | + id: 'project', | ||
| 146 | + title: 'Add a project', | ||
| 147 | + hint: 'Pick a folder: skilly shows what Claude Code and Codex load there.', | ||
| 148 | + done: input.projects.length > 0, | ||
| 149 | + action: { label: 'Add project', run: 'add-project' } | ||
| 150 | + }, | ||
| 151 | + { | ||
| 152 | + id: 'manage', | ||
| 153 | + title: 'Choose what a project uses', | ||
| 154 | + hint: 'Enable presets and catalog items, then review and apply: skilly writes the files.', | ||
| 155 | + done: input.managedProjects > 0, | ||
| 156 | + action: { label: 'Open', to: first ? scopeLink(first.id) : '/projects' } | ||
| 157 | + }, | ||
| 158 | + { | ||
| 159 | + id: 'share', | ||
| 160 | + title: 'Share the catalog with git', | ||
| 161 | + hint: 'Push it to a remote to get the same setup on another machine or with your team.', | ||
| 162 | + done: input.remote, | ||
| 163 | + action: { label: 'Open Sync', to: '/sync' } | ||
| 164 | + } | ||
| 165 | + ] | ||
| 166 | +} | ||
modified
packages/ui/app/utils/inventoryView.ts +1 -1 | @@ -2,7 +2,7 @@ import type { AgentId, CatalogItemType, HookDetails, InstructionsDetails, Invent | ||
| 2 | 2 | import type { Tone } from './status' |
| 3 | 3 | |
| 4 | 4 | /** |
| 5 | - * Presentation helpers for inventory items (Home inventory, project Files tab, item drawer). | |
| 5 | + * Presentation helpers for inventory items (Items list of a project or of Global, item drawer). | |
| 6 | 6 | * Pure functions: no Nuxt context needed, unit-tested in packages/ui/test. |
| 7 | 7 | */ |
| 8 | 8 | |
| @@ -2,7 +2,7 @@ import type { AgentId, CatalogItemType, HookDetails, InstructionsDetails, Invent | |||
| 2 | import type { Tone } from './status' | 2 | import type { Tone } from './status' |
| 3 | 3 | ||
| 4 | /** | 4 | /** |
| 5 | - * Presentation helpers for inventory items (Home inventory, project Files tab, item drawer). | 5 | + * Presentation helpers for inventory items (Items list of a project or of Global, item drawer). |
| 6 | * Pure functions: no Nuxt context needed, unit-tested in packages/ui/test. | 6 | * Pure functions: no Nuxt context needed, unit-tested in packages/ui/test. |
| 7 | */ | 7 | */ |
| 8 | 8 | ||
added
packages/ui/app/utils/recent.ts +18 -0 | new file mode 100644 | ||
| @@ -0,0 +1,18 @@ | ||
| 1 | +/** Recently opened projects (dashboard "Recent projects"), most recent first. A UI preference kept in localStorage. */ | |
| 2 | +const KEY = 'skilly-recent-projects' | |
| 3 | +const MAX = 8 | |
| 4 | + | |
| 5 | +export function recentProjectIds(): string[] { | |
| 6 | + try { | |
| 7 | + const v = JSON.parse(localStorage.getItem(KEY) ?? '[]') | |
| 8 | + return Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : [] | |
| 9 | + } catch { | |
| 10 | + return [] | |
| 11 | + } | |
| 12 | +} | |
| 13 | + | |
| 14 | +export function rememberRecentProject(id: string): void { | |
| 15 | + try { | |
| 16 | + localStorage.setItem(KEY, JSON.stringify([id, ...recentProjectIds().filter(x => x !== id)].slice(0, MAX))) | |
| 17 | + } catch { /* private mode */ } | |
| 18 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,18 @@ | |||
| 1 | +/** Recently opened projects (dashboard "Recent projects"), most recent first. A UI preference kept in localStorage. */ | ||
| 2 | +const KEY = 'skilly-recent-projects' | ||
| 3 | +const MAX = 8 | ||
| 4 | + | ||
| 5 | +export function recentProjectIds(): string[] { | ||
| 6 | + try { | ||
| 7 | + const v = JSON.parse(localStorage.getItem(KEY) ?? '[]') | ||
| 8 | + return Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : [] | ||
| 9 | + } catch { | ||
| 10 | + return [] | ||
| 11 | + } | ||
| 12 | +} | ||
| 13 | + | ||
| 14 | +export function rememberRecentProject(id: string): void { | ||
| 15 | + try { | ||
| 16 | + localStorage.setItem(KEY, JSON.stringify([id, ...recentProjectIds().filter(x => x !== id)].slice(0, MAX))) | ||
| 17 | + } catch { /* private mode */ } | ||
| 18 | +} | ||
modified
packages/ui/app/utils/scopeConfig.ts +30 -1 | @@ -1,4 +1,8 @@ | ||
| 1 | -import type { AgentId, CatalogItemType, ScopeConfig } from '@skilly/core' | |
| 1 | +import type { AgentId, CatalogItemType, Preset, ScopeConfig } from '@skilly/core' | |
| 2 | +import { resolveItems } from './resolve' | |
| 3 | + | |
| 4 | +/** Types Global's config (home.json) can hold: its MCP servers and hooks are read-only in v1. */ | |
| 5 | +export const HOME_CONFIG_TYPES: ReadonlySet<CatalogItemType> = new Set(['skill', 'rule', 'agent']) | |
| 2 | 6 | |
| 3 | 7 | export function emptyConfig(targets: AgentId[]): ScopeConfig { |
| 4 | 8 | return { version: 1, targets, presets: [], add: {}, exclude: {}, onDemand: { skills: [] }, hideGlobal: { skills: [], mcp: [] } } |
| @@ -18,3 +22,28 @@ export function withTypeList(c: ScopeConfig, field: 'add' | 'exclude', type: Cat | ||
| 18 | 22 | |
| 19 | 23 | export const toggleIn = (list: string[], name: string, on: boolean) => |
| 20 | 24 | on ? (list.includes(name) ? list : [...list, name]) : list.filter(x => x !== name) |
| 25 | + | |
| 26 | +/** | |
| 27 | + * "Manage": makes catalog items part of the config. An item already provided by a preset only loses its exact | |
| 28 | + * exclusion; any other item is added to `add`. Mutates `c`; returns false when nothing changed. | |
| 29 | + */ | |
| 30 | +export function manageInConfig(c: ScopeConfig, refs: { type: CatalogItemType, name: string }[], presets: Preset[]): boolean { | |
| 31 | + let changed = false | |
| 32 | + const viaPresets = new Set(resolveItems(presets, c.presets, {}).entries.map(e => `${e.type}/${e.name}`)) | |
| 33 | + for (const r of refs) { | |
| 34 | + if (viaPresets.has(`${r.type}/${r.name}`)) { | |
| 35 | + const ex = c.exclude[r.type] ?? [] | |
| 36 | + if (!ex.includes(r.name)) continue | |
| 37 | + const list = ex.filter(x => x !== r.name) | |
| 38 | + if (list.length) c.exclude[r.type] = list | |
| 39 | + else delete c.exclude[r.type] | |
| 40 | + changed = true | |
| 41 | + } else { | |
| 42 | + const add = c.add[r.type] ?? [] | |
| 43 | + if (add.includes(r.name)) continue | |
| 44 | + c.add[r.type] = [...add, r.name] | |
| 45 | + changed = true | |
| 46 | + } | |
| 47 | + } | |
| 48 | + return changed | |
| 49 | +} | |
| @@ -1,4 +1,8 @@ | |||
| 1 | -import type { AgentId, CatalogItemType, ScopeConfig } from '@skilly/core' | 1 | +import type { AgentId, CatalogItemType, Preset, ScopeConfig } from '@skilly/core' |
| 2 | +import { resolveItems } from './resolve' | ||
| 3 | + | ||
| 4 | +/** Types Global's config (home.json) can hold: its MCP servers and hooks are read-only in v1. */ | ||
| 5 | +export const HOME_CONFIG_TYPES: ReadonlySet<CatalogItemType> = new Set(['skill', 'rule', 'agent']) | ||
| 2 | 6 | ||
| 3 | export function emptyConfig(targets: AgentId[]): ScopeConfig { | 7 | export function emptyConfig(targets: AgentId[]): ScopeConfig { |
| 4 | return { version: 1, targets, presets: [], add: {}, exclude: {}, onDemand: { skills: [] }, hideGlobal: { skills: [], mcp: [] } } | 8 | return { version: 1, targets, presets: [], add: {}, exclude: {}, onDemand: { skills: [] }, hideGlobal: { skills: [], mcp: [] } } |
| @@ -18,3 +22,28 @@ export function withTypeList(c: ScopeConfig, field: 'add' | 'exclude', type: Cat | |||
| 18 | 22 | ||
| 19 | export const toggleIn = (list: string[], name: string, on: boolean) => | 23 | export const toggleIn = (list: string[], name: string, on: boolean) => |
| 20 | on ? (list.includes(name) ? list : [...list, name]) : list.filter(x => x !== name) | 24 | on ? (list.includes(name) ? list : [...list, name]) : list.filter(x => x !== name) |
| 25 | + | ||
| 26 | +/** | ||
| 27 | + * "Manage": makes catalog items part of the config. An item already provided by a preset only loses its exact | ||
| 28 | + * exclusion; any other item is added to `add`. Mutates `c`; returns false when nothing changed. | ||
| 29 | + */ | ||
| 30 | +export function manageInConfig(c: ScopeConfig, refs: { type: CatalogItemType, name: string }[], presets: Preset[]): boolean { | ||
| 31 | + let changed = false | ||
| 32 | + const viaPresets = new Set(resolveItems(presets, c.presets, {}).entries.map(e => `${e.type}/${e.name}`)) | ||
| 33 | + for (const r of refs) { | ||
| 34 | + if (viaPresets.has(`${r.type}/${r.name}`)) { | ||
| 35 | + const ex = c.exclude[r.type] ?? [] | ||
| 36 | + if (!ex.includes(r.name)) continue | ||
| 37 | + const list = ex.filter(x => x !== r.name) | ||
| 38 | + if (list.length) c.exclude[r.type] = list | ||
| 39 | + else delete c.exclude[r.type] | ||
| 40 | + changed = true | ||
| 41 | + } else { | ||
| 42 | + const add = c.add[r.type] ?? [] | ||
| 43 | + if (add.includes(r.name)) continue | ||
| 44 | + c.add[r.type] = [...add, r.name] | ||
| 45 | + changed = true | ||
| 46 | + } | ||
| 47 | + } | ||
| 48 | + return changed | ||
| 49 | +} | ||
modified
packages/ui/app/utils/scopeRows.ts +87 -52 | @@ -3,14 +3,18 @@ import type { | ||
| 3 | 3 | } from '@skilly/core' |
| 4 | 4 | import { ITEM_STATE_META, ITEM_STATE_SHORT, type Tone } from './status' |
| 5 | 5 | import { resolveItems } from './resolve' |
| 6 | +import { itemDisplayName } from './inventoryView' | |
| 7 | +import { HOME_CONFIG_TYPES } from './scopeConfig' | |
| 6 | 8 | |
| 7 | 9 | /** |
| 8 | - * View model of the "Items in this project" list (project Configuration tab, Home Configuration tab). | |
| 9 | - * One row per logical item (`type/name`), with one status per target agent. Built from the local config copy, | |
| 10 | - * the sync plan and the inventory. Pure and unit-tested. | |
| 10 | + * View model of the single "Items" list of a project or of Global: what skilly manages (config entries, removals) | |
| 11 | + * and what is only on disk (local and plugin items), in one list. One row per logical item (`type/name`; hooks: one | |
| 12 | + * per inventory entry), with one status per agent. Built from the local config copy (null when the scope is not | |
| 13 | + * managed yet), the sync plan and the inventory. Pure and unit-tested. | |
| 11 | 14 | */ |
| 12 | 15 | |
| 13 | -export type RowAction = 'resolve' | 'review' | 'import' | |
| 16 | +/** `manage`: the local item is identical to a catalog item; add it to the config so skilly manages it. */ | |
| 17 | +export type RowAction = 'resolve' | 'review' | 'import' | 'manage' | |
| 14 | 18 | |
| 15 | 19 | export interface AgentRowStatus { |
| 16 | 20 | agent: AgentId |
| @@ -30,6 +34,12 @@ export interface ScopeRow { | ||
| 30 | 34 | kind: RowKind |
| 31 | 35 | type: CatalogItemType |
| 32 | 36 | name: string |
| 37 | + /** Shown name (hooks: "PreToolUse · Bash"). */ | |
| 38 | + label: string | |
| 39 | + /** Inventory items on disk behind the row (item drawer, import, move). */ | |
| 40 | + itemIds: string[] | |
| 41 | + /** Provided by this plugin: read-only. */ | |
| 42 | + plugin?: string | |
| 33 | 43 | /** Presets that provide the item. */ |
| 34 | 44 | presets: string[] |
| 35 | 45 | /** Added one by one. */ |
| @@ -113,7 +123,8 @@ function statusFromAction(a: PlanAction | undefined, inv: ItemStatus | undefined | ||
| 113 | 123 | } |
| 114 | 124 | |
| 115 | 125 | export interface BuildRowsInput { |
| 116 | - config: ScopeConfig | |
| 126 | + /** Null when the scope is not managed yet: only the items on disk are listed. */ | |
| 127 | + config: ScopeConfig | null | |
| 117 | 128 | presets: Preset[] |
| 118 | 129 | catalogItems: CatalogItem[] |
| 119 | 130 | plan: SyncPlan | null |
| @@ -121,15 +132,17 @@ export interface BuildRowsInput { | ||
| 121 | 132 | statuses: Record<string, ItemStatus> |
| 122 | 133 | /** Names of the Home skills / MCP servers, to tell "hide global" actions from item actions. */ |
| 123 | 134 | homeNames?: HomeNames |
| 124 | - /** Also list unmanaged local items of the scope (projects). */ | |
| 135 | + /** Also list the items on disk that skilly does not manage (local and plugin items). */ | |
| 125 | 136 | includeLocal: boolean |
| 137 | + /** Global: its config only takes skills, rules and subagents, so its MCP servers and hooks can't be managed. */ | |
| 138 | + home?: boolean | |
| 126 | 139 | } |
| 127 | 140 | |
| 128 | 141 | export function buildRows(input: BuildRowsInput): ScopeRow[] { |
| 129 | 142 | const { config, presets, catalogItems, plan, inventory, statuses } = input |
| 130 | - const resolved = resolveItems(presets, config.presets, config.add) | |
| 143 | + const entries = config ? resolveItems(presets, config.presets, config.add).entries : [] | |
| 131 | 144 | const inCatalog = (t: CatalogItemType, n: string) => catalogItems.some(i => i.type === t && i.name === n) |
| 132 | - const entryKeys = new Set(resolved.entries.map(e => `${e.type}/${e.name}`)) | |
| 145 | + const entryKeys = new Set(entries.map(e => `${e.type}/${e.name}`)) | |
| 133 | 146 | const actionsOf = (t: string, n: string) => (plan?.actions ?? []).filter(a => a.type === t && a.name === n) |
| 134 | 147 | const invOf = (t: string, n: string, agent: AgentId): ItemStatus | undefined => { |
| 135 | 148 | for (const it of inventory?.items ?? []) { |
| @@ -138,52 +151,62 @@ export function buildRows(input: BuildRowsInput): ScopeRow[] { | ||
| 138 | 151 | } |
| 139 | 152 | return undefined |
| 140 | 153 | } |
| 154 | + /** Inventory items behind a managed row: matched by catalog ref, or by type and name. */ | |
| 155 | + const idsOf = (t: string, n: string) => (inventory?.items ?? []).filter((it) => { | |
| 156 | + if (it.source.plugin) return false | |
| 157 | + const ref = statuses[it.id]?.catalogRef | |
| 158 | + return ref ? ref.type === t && ref.name === n : it.type === t && it.name === n | |
| 159 | + }).map(it => it.id) | |
| 141 | 160 | |
| 142 | 161 | const rows: ScopeRow[] = [] |
| 143 | 162 | |
| 144 | - for (const e of resolved.entries) { | |
| 145 | - const ex = matchIn(config.exclude[e.type], e.name) | |
| 146 | - const presetsVia = e.via.filter(v => v !== 'add') | |
| 147 | - const added = e.via.includes('add') | |
| 148 | - const acts = actionsOf(e.type, e.name) | |
| 149 | - const statusesPerAgent = config.targets.map(agent => | |
| 150 | - statusFromAction(acts.find(a => a.agent === agent), invOf(e.type, e.name, agent), !!ex, agent)) | |
| 151 | - let origin: string | |
| 152 | - if (ex) origin = presetsVia.length ? `Excluded from ${presetsVia.join(', ')}` : ex.exact ? 'Excluded' : `Excluded by ${ex.pattern}` | |
| 153 | - else if (presetsVia.length) origin = `Preset · ${presetsVia.join(', ')}${added ? ' + added' : ''}` | |
| 154 | - else origin = 'Catalog' | |
| 155 | - const od = matchIn(config.onDemand.skills, e.name) | |
| 156 | - rows.push({ | |
| 157 | - key: `${e.type}/${e.name}`, | |
| 158 | - kind: 'entry', | |
| 159 | - type: e.type, | |
| 160 | - name: e.name, | |
| 161 | - presets: presetsVia, | |
| 162 | - added, | |
| 163 | - excluded: !!ex, | |
| 164 | - excludedExact: !!ex?.exact, | |
| 165 | - excludePattern: ex && !ex.exact ? ex.pattern : undefined, | |
| 166 | - inCatalog: inCatalog(e.type, e.name), | |
| 167 | - origin, | |
| 168 | - statuses: statusesPerAgent, | |
| 169 | - conflicts: statusesPerAgent.flatMap(s => (s.conflict ? [s.conflict] : [])), | |
| 170 | - viaPreset: presetsVia.length > 0, | |
| 171 | - onDemand: { on: !!od, exact: od?.exact ?? true, pattern: od && !od.exact ? od.pattern : undefined } | |
| 172 | - }) | |
| 163 | + if (config) { | |
| 164 | + for (const e of entries) { | |
| 165 | + const ex = matchIn(config.exclude[e.type], e.name) | |
| 166 | + const presetsVia = e.via.filter(v => v !== 'add') | |
| 167 | + const added = e.via.includes('add') | |
| 168 | + const acts = actionsOf(e.type, e.name) | |
| 169 | + const statusesPerAgent = config.targets.map(agent => | |
| 170 | + statusFromAction(acts.find(a => a.agent === agent), invOf(e.type, e.name, agent), !!ex, agent)) | |
| 171 | + let origin: string | |
| 172 | + if (ex) origin = presetsVia.length ? `Excluded from ${presetsVia.join(', ')}` : ex.exact ? 'Excluded' : `Excluded by ${ex.pattern}` | |
| 173 | + else if (presetsVia.length) origin = `Preset · ${presetsVia.join(', ')}${added ? ' + added' : ''}` | |
| 174 | + else origin = 'Catalog' | |
| 175 | + const od = matchIn(config.onDemand.skills, e.name) | |
| 176 | + rows.push({ | |
| 177 | + key: `${e.type}/${e.name}`, | |
| 178 | + kind: 'entry', | |
| 179 | + type: e.type, | |
| 180 | + name: e.name, | |
| 181 | + label: e.name, | |
| 182 | + itemIds: idsOf(e.type, e.name), | |
| 183 | + presets: presetsVia, | |
| 184 | + added, | |
| 185 | + excluded: !!ex, | |
| 186 | + excludedExact: !!ex?.exact, | |
| 187 | + excludePattern: ex && !ex.exact ? ex.pattern : undefined, | |
| 188 | + inCatalog: inCatalog(e.type, e.name), | |
| 189 | + origin, | |
| 190 | + statuses: statusesPerAgent, | |
| 191 | + conflicts: statusesPerAgent.flatMap(s => (s.conflict ? [s.conflict] : [])), | |
| 192 | + viaPreset: presetsVia.length > 0, | |
| 193 | + onDemand: { on: !!od, exact: od?.exact ?? true, pattern: od && !od.exact ? od.pattern : undefined } | |
| 194 | + }) | |
| 195 | + } | |
| 173 | 196 | } |
| 174 | 197 | |
| 175 | 198 | // Lock entries that are no longer enabled: removal or conflict actions without a configured item. |
| 176 | 199 | const seen = new Set<string>() |
| 177 | - for (const a of plan?.actions ?? []) { | |
| 200 | + for (const a of config ? plan?.actions ?? [] : []) { | |
| 178 | 201 | const key = `${a.type}/${a.name}` |
| 179 | 202 | if (entryKeys.has(key) || seen.has(key)) continue |
| 180 | 203 | if (a.kind !== 'remove' && a.kind !== 'conflict') continue |
| 181 | - if (isHideAction(a, config, entryKeys, input.homeNames)) continue | |
| 204 | + if (isHideAction(a, config!, entryKeys, input.homeNames)) continue | |
| 182 | 205 | seen.add(key) |
| 183 | 206 | const st = actionsOf(a.type, a.name).map(x => statusFromAction(x, undefined, false, x.agent)) |
| 184 | 207 | rows.push({ |
| 185 | - key, kind: 'removal', type: a.type, name: a.name, presets: [], added: false, excluded: true, excludedExact: false, | |
| 186 | - inCatalog: inCatalog(a.type, a.name), origin: 'No longer enabled', statuses: st, | |
| 208 | + key, kind: 'removal', type: a.type, name: a.name, label: a.name, itemIds: idsOf(a.type, a.name), presets: [], added: false, | |
| 209 | + excluded: true, excludedExact: false, inCatalog: inCatalog(a.type, a.name), origin: 'No longer enabled', statuses: st, | |
| 187 | 210 | conflicts: st.flatMap(s => (s.conflict ? [s.conflict] : [])), viaPreset: false, onDemand: { on: false, exact: true } |
| 188 | 211 | }) |
| 189 | 212 | } |
| @@ -192,22 +215,34 @@ export function buildRows(input: BuildRowsInput): ScopeRow[] { | ||
| 192 | 215 | const groups = new Map<string, ScopeRow>() |
| 193 | 216 | for (const it of inventory?.items ?? []) { |
| 194 | 217 | const st = statuses[it.id] |
| 195 | - if (it.type === 'instructions' || it.type === 'hook' || it.source.plugin || st?.managed) continue | |
| 218 | + // Written by skilly: already an entry or a removal row when there is a config. | |
| 219 | + if (it.type === 'instructions' || (config && st?.managed)) continue | |
| 196 | 220 | const t = it.type as CatalogItemType |
| 197 | - const key = `${t}/${it.name}` | |
| 221 | + const plugin = it.source.plugin | |
| 222 | + // Hook names are positional ("PreToolUse:Bash:0"): one row per entry. Plugin items never merge with others. | |
| 223 | + const key = t === 'hook' ? `hook/${it.id}` : plugin ? `${t}/${it.name}@${plugin}` : `${t}/${it.name}` | |
| 198 | 224 | if (entryKeys.has(key) || seen.has(key)) continue |
| 199 | 225 | const state = st?.state ?? 'not-in-catalog' |
| 200 | 226 | const meta = ITEM_STATE_META[state] |
| 201 | - const status: AgentRowStatus = state === 'in-sync' | |
| 202 | - ? { agent: it.agent, tone: 'ok', label: 'In catalog', short: 'In catalog' } | |
| 203 | - : { | |
| 204 | - agent: it.agent, tone: meta.tone, label: meta.label, short: ITEM_STATE_SHORT[state], | |
| 205 | - action: state === 'not-in-catalog' || state === 'differs-from-catalog' ? 'import' : undefined | |
| 206 | - } | |
| 227 | + let status: AgentRowStatus | |
| 228 | + if (plugin) status = { agent: it.agent, tone: 'faint', label: 'From a plugin', short: 'Plugin' } | |
| 229 | + else if (state === 'in-sync') { | |
| 230 | + status = input.home && !HOME_CONFIG_TYPES.has(t) | |
| 231 | + ? { agent: it.agent, tone: 'ok', label: 'In catalog', short: 'In catalog' } | |
| 232 | + : { agent: it.agent, tone: 'info', label: 'In catalog, not managed', short: 'Not managed', action: 'manage' } | |
| 233 | + } else { | |
| 234 | + status = { | |
| 235 | + agent: it.agent, tone: meta.tone, label: meta.label, short: ITEM_STATE_SHORT[state], | |
| 236 | + action: state === 'not-in-catalog' || state === 'differs-from-catalog' ? 'import' : undefined | |
| 237 | + } | |
| 238 | + } | |
| 207 | 239 | const row = groups.get(key) ?? { |
| 208 | - key, kind: 'local' as const, type: t, name: it.name, presets: [], added: false, excluded: false, excludedExact: false, | |
| 209 | - inCatalog: inCatalog(t, it.name), origin: 'Local', statuses: [], conflicts: [], viaPreset: false, onDemand: { on: false, exact: true } | |
| 240 | + key, kind: 'local' as const, type: t, name: it.name, label: itemDisplayName(it), itemIds: [], plugin, | |
| 241 | + presets: [], added: false, excluded: false, excludedExact: false, inCatalog: !plugin && inCatalog(t, it.name), | |
| 242 | + origin: plugin ? `Plugin · ${plugin}` : 'Local · not managed by skilly', | |
| 243 | + statuses: [], conflicts: [], viaPreset: false, onDemand: { on: false, exact: true } | |
| 210 | 244 | } |
| 245 | + row.itemIds.push(it.id) | |
| 211 | 246 | row.statuses.push(status) |
| 212 | 247 | groups.set(key, row) |
| 213 | 248 | } |
| @@ -218,7 +253,7 @@ export function buildRows(input: BuildRowsInput): ScopeRow[] { | ||
| 218 | 253 | return rows.sort((a, b) => |
| 219 | 254 | ROW_TYPE_ORDER.indexOf(a.type) - ROW_TYPE_ORDER.indexOf(b.type) |
| 220 | 255 | || kindOrder[a.kind] - kindOrder[b.kind] |
| 221 | - || a.name.localeCompare(b.name)) | |
| 256 | + || a.label.localeCompare(b.label, undefined, { sensitivity: 'base' })) | |
| 222 | 257 | } |
| 223 | 258 | |
| 224 | 259 | /** One merged status when every agent agrees, otherwise the caller shows one mini status per agent. */ |
| @@ -3,14 +3,18 @@ import type { | |||
| 3 | } from '@skilly/core' | 3 | } from '@skilly/core' |
| 4 | import { ITEM_STATE_META, ITEM_STATE_SHORT, type Tone } from './status' | 4 | import { ITEM_STATE_META, ITEM_STATE_SHORT, type Tone } from './status' |
| 5 | import { resolveItems } from './resolve' | 5 | import { resolveItems } from './resolve' |
| 6 | +import { itemDisplayName } from './inventoryView' | ||
| 7 | +import { HOME_CONFIG_TYPES } from './scopeConfig' | ||
| 6 | 8 | ||
| 7 | /** | 9 | /** |
| 8 | - * View model of the "Items in this project" list (project Configuration tab, Home Configuration tab). | 10 | + * View model of the single "Items" list of a project or of Global: what skilly manages (config entries, removals) |
| 9 | - * One row per logical item (`type/name`), with one status per target agent. Built from the local config copy, | 11 | + * and what is only on disk (local and plugin items), in one list. One row per logical item (`type/name`; hooks: one |
| 10 | - * the sync plan and the inventory. Pure and unit-tested. | 12 | + * per inventory entry), with one status per agent. Built from the local config copy (null when the scope is not |
| 13 | + * managed yet), the sync plan and the inventory. Pure and unit-tested. | ||
| 11 | */ | 14 | */ |
| 12 | 15 | ||
| 13 | -export type RowAction = 'resolve' | 'review' | 'import' | 16 | +/** `manage`: the local item is identical to a catalog item; add it to the config so skilly manages it. */ |
| 17 | +export type RowAction = 'resolve' | 'review' | 'import' | 'manage' | ||
| 14 | 18 | ||
| 15 | export interface AgentRowStatus { | 19 | export interface AgentRowStatus { |
| 16 | agent: AgentId | 20 | agent: AgentId |
| @@ -30,6 +34,12 @@ export interface ScopeRow { | |||
| 30 | kind: RowKind | 34 | kind: RowKind |
| 31 | type: CatalogItemType | 35 | type: CatalogItemType |
| 32 | name: string | 36 | name: string |
| 37 | + /** Shown name (hooks: "PreToolUse · Bash"). */ | ||
| 38 | + label: string | ||
| 39 | + /** Inventory items on disk behind the row (item drawer, import, move). */ | ||
| 40 | + itemIds: string[] | ||
| 41 | + /** Provided by this plugin: read-only. */ | ||
| 42 | + plugin?: string | ||
| 33 | /** Presets that provide the item. */ | 43 | /** Presets that provide the item. */ |
| 34 | presets: string[] | 44 | presets: string[] |
| 35 | /** Added one by one. */ | 45 | /** Added one by one. */ |
| @@ -113,7 +123,8 @@ function statusFromAction(a: PlanAction | undefined, inv: ItemStatus | undefined | |||
| 113 | } | 123 | } |
| 114 | 124 | ||
| 115 | export interface BuildRowsInput { | 125 | export interface BuildRowsInput { |
| 116 | - config: ScopeConfig | 126 | + /** Null when the scope is not managed yet: only the items on disk are listed. */ |
| 127 | + config: ScopeConfig | null | ||
| 117 | presets: Preset[] | 128 | presets: Preset[] |
| 118 | catalogItems: CatalogItem[] | 129 | catalogItems: CatalogItem[] |
| 119 | plan: SyncPlan | null | 130 | plan: SyncPlan | null |
| @@ -121,15 +132,17 @@ export interface BuildRowsInput { | |||
| 121 | statuses: Record<string, ItemStatus> | 132 | statuses: Record<string, ItemStatus> |
| 122 | /** Names of the Home skills / MCP servers, to tell "hide global" actions from item actions. */ | 133 | /** Names of the Home skills / MCP servers, to tell "hide global" actions from item actions. */ |
| 123 | homeNames?: HomeNames | 134 | homeNames?: HomeNames |
| 124 | - /** Also list unmanaged local items of the scope (projects). */ | 135 | + /** Also list the items on disk that skilly does not manage (local and plugin items). */ |
| 125 | includeLocal: boolean | 136 | includeLocal: boolean |
| 137 | + /** Global: its config only takes skills, rules and subagents, so its MCP servers and hooks can't be managed. */ | ||
| 138 | + home?: boolean | ||
| 126 | } | 139 | } |
| 127 | 140 | ||
| 128 | export function buildRows(input: BuildRowsInput): ScopeRow[] { | 141 | export function buildRows(input: BuildRowsInput): ScopeRow[] { |
| 129 | const { config, presets, catalogItems, plan, inventory, statuses } = input | 142 | const { config, presets, catalogItems, plan, inventory, statuses } = input |
| 130 | - const resolved = resolveItems(presets, config.presets, config.add) | 143 | + const entries = config ? resolveItems(presets, config.presets, config.add).entries : [] |
| 131 | const inCatalog = (t: CatalogItemType, n: string) => catalogItems.some(i => i.type === t && i.name === n) | 144 | const inCatalog = (t: CatalogItemType, n: string) => catalogItems.some(i => i.type === t && i.name === n) |
| 132 | - const entryKeys = new Set(resolved.entries.map(e => `${e.type}/${e.name}`)) | 145 | + const entryKeys = new Set(entries.map(e => `${e.type}/${e.name}`)) |
| 133 | const actionsOf = (t: string, n: string) => (plan?.actions ?? []).filter(a => a.type === t && a.name === n) | 146 | const actionsOf = (t: string, n: string) => (plan?.actions ?? []).filter(a => a.type === t && a.name === n) |
| 134 | const invOf = (t: string, n: string, agent: AgentId): ItemStatus | undefined => { | 147 | const invOf = (t: string, n: string, agent: AgentId): ItemStatus | undefined => { |
| 135 | for (const it of inventory?.items ?? []) { | 148 | for (const it of inventory?.items ?? []) { |
| @@ -138,52 +151,62 @@ export function buildRows(input: BuildRowsInput): ScopeRow[] { | |||
| 138 | } | 151 | } |
| 139 | return undefined | 152 | return undefined |
| 140 | } | 153 | } |
| 154 | + /** Inventory items behind a managed row: matched by catalog ref, or by type and name. */ | ||
| 155 | + const idsOf = (t: string, n: string) => (inventory?.items ?? []).filter((it) => { | ||
| 156 | + if (it.source.plugin) return false | ||
| 157 | + const ref = statuses[it.id]?.catalogRef | ||
| 158 | + return ref ? ref.type === t && ref.name === n : it.type === t && it.name === n | ||
| 159 | + }).map(it => it.id) | ||
| 141 | 160 | ||
| 142 | const rows: ScopeRow[] = [] | 161 | const rows: ScopeRow[] = [] |
| 143 | 162 | ||
| 144 | - for (const e of resolved.entries) { | 163 | + if (config) { |
| 145 | - const ex = matchIn(config.exclude[e.type], e.name) | 164 | + for (const e of entries) { |
| 146 | - const presetsVia = e.via.filter(v => v !== 'add') | 165 | + const ex = matchIn(config.exclude[e.type], e.name) |
| 147 | - const added = e.via.includes('add') | 166 | + const presetsVia = e.via.filter(v => v !== 'add') |
| 148 | - const acts = actionsOf(e.type, e.name) | 167 | + const added = e.via.includes('add') |
| 149 | - const statusesPerAgent = config.targets.map(agent => | 168 | + const acts = actionsOf(e.type, e.name) |
| 150 | - statusFromAction(acts.find(a => a.agent === agent), invOf(e.type, e.name, agent), !!ex, agent)) | 169 | + const statusesPerAgent = config.targets.map(agent => |
| 151 | - let origin: string | 170 | + statusFromAction(acts.find(a => a.agent === agent), invOf(e.type, e.name, agent), !!ex, agent)) |
| 152 | - if (ex) origin = presetsVia.length ? `Excluded from ${presetsVia.join(', ')}` : ex.exact ? 'Excluded' : `Excluded by ${ex.pattern}` | 171 | + let origin: string |
| 153 | - else if (presetsVia.length) origin = `Preset · ${presetsVia.join(', ')}${added ? ' + added' : ''}` | 172 | + if (ex) origin = presetsVia.length ? `Excluded from ${presetsVia.join(', ')}` : ex.exact ? 'Excluded' : `Excluded by ${ex.pattern}` |
| 154 | - else origin = 'Catalog' | 173 | + else if (presetsVia.length) origin = `Preset · ${presetsVia.join(', ')}${added ? ' + added' : ''}` |
| 155 | - const od = matchIn(config.onDemand.skills, e.name) | 174 | + else origin = 'Catalog' |
| 156 | - rows.push({ | 175 | + const od = matchIn(config.onDemand.skills, e.name) |
| 157 | - key: `${e.type}/${e.name}`, | 176 | + rows.push({ |
| 158 | - kind: 'entry', | 177 | + key: `${e.type}/${e.name}`, |
| 159 | - type: e.type, | 178 | + kind: 'entry', |
| 160 | - name: e.name, | 179 | + type: e.type, |
| 161 | - presets: presetsVia, | 180 | + name: e.name, |
| 162 | - added, | 181 | + label: e.name, |
| 163 | - excluded: !!ex, | 182 | + itemIds: idsOf(e.type, e.name), |
| 164 | - excludedExact: !!ex?.exact, | 183 | + presets: presetsVia, |
| 165 | - excludePattern: ex && !ex.exact ? ex.pattern : undefined, | 184 | + added, |
| 166 | - inCatalog: inCatalog(e.type, e.name), | 185 | + excluded: !!ex, |
| 167 | - origin, | 186 | + excludedExact: !!ex?.exact, |
| 168 | - statuses: statusesPerAgent, | 187 | + excludePattern: ex && !ex.exact ? ex.pattern : undefined, |
| 169 | - conflicts: statusesPerAgent.flatMap(s => (s.conflict ? [s.conflict] : [])), | 188 | + inCatalog: inCatalog(e.type, e.name), |
| 170 | - viaPreset: presetsVia.length > 0, | 189 | + origin, |
| 171 | - onDemand: { on: !!od, exact: od?.exact ?? true, pattern: od && !od.exact ? od.pattern : undefined } | 190 | + statuses: statusesPerAgent, |
| 172 | - }) | 191 | + conflicts: statusesPerAgent.flatMap(s => (s.conflict ? [s.conflict] : [])), |
| 192 | + viaPreset: presetsVia.length > 0, | ||
| 193 | + onDemand: { on: !!od, exact: od?.exact ?? true, pattern: od && !od.exact ? od.pattern : undefined } | ||
| 194 | + }) | ||
| 195 | + } | ||
| 173 | } | 196 | } |
| 174 | 197 | ||
| 175 | // Lock entries that are no longer enabled: removal or conflict actions without a configured item. | 198 | // Lock entries that are no longer enabled: removal or conflict actions without a configured item. |
| 176 | const seen = new Set<string>() | 199 | const seen = new Set<string>() |
| 177 | - for (const a of plan?.actions ?? []) { | 200 | + for (const a of config ? plan?.actions ?? [] : []) { |
| 178 | const key = `${a.type}/${a.name}` | 201 | const key = `${a.type}/${a.name}` |
| 179 | if (entryKeys.has(key) || seen.has(key)) continue | 202 | if (entryKeys.has(key) || seen.has(key)) continue |
| 180 | if (a.kind !== 'remove' && a.kind !== 'conflict') continue | 203 | if (a.kind !== 'remove' && a.kind !== 'conflict') continue |
| 181 | - if (isHideAction(a, config, entryKeys, input.homeNames)) continue | 204 | + if (isHideAction(a, config!, entryKeys, input.homeNames)) continue |
| 182 | seen.add(key) | 205 | seen.add(key) |
| 183 | const st = actionsOf(a.type, a.name).map(x => statusFromAction(x, undefined, false, x.agent)) | 206 | const st = actionsOf(a.type, a.name).map(x => statusFromAction(x, undefined, false, x.agent)) |
| 184 | rows.push({ | 207 | rows.push({ |
| 185 | - key, kind: 'removal', type: a.type, name: a.name, presets: [], added: false, excluded: true, excludedExact: false, | 208 | + key, kind: 'removal', type: a.type, name: a.name, label: a.name, itemIds: idsOf(a.type, a.name), presets: [], added: false, |
| 186 | - inCatalog: inCatalog(a.type, a.name), origin: 'No longer enabled', statuses: st, | 209 | + excluded: true, excludedExact: false, inCatalog: inCatalog(a.type, a.name), origin: 'No longer enabled', statuses: st, |
| 187 | conflicts: st.flatMap(s => (s.conflict ? [s.conflict] : [])), viaPreset: false, onDemand: { on: false, exact: true } | 210 | conflicts: st.flatMap(s => (s.conflict ? [s.conflict] : [])), viaPreset: false, onDemand: { on: false, exact: true } |
| 188 | }) | 211 | }) |
| 189 | } | 212 | } |
| @@ -192,22 +215,34 @@ export function buildRows(input: BuildRowsInput): ScopeRow[] { | |||
| 192 | const groups = new Map<string, ScopeRow>() | 215 | const groups = new Map<string, ScopeRow>() |
| 193 | for (const it of inventory?.items ?? []) { | 216 | for (const it of inventory?.items ?? []) { |
| 194 | const st = statuses[it.id] | 217 | const st = statuses[it.id] |
| 195 | - if (it.type === 'instructions' || it.type === 'hook' || it.source.plugin || st?.managed) continue | 218 | + // Written by skilly: already an entry or a removal row when there is a config. |
| 219 | + if (it.type === 'instructions' || (config && st?.managed)) continue | ||
| 196 | const t = it.type as CatalogItemType | 220 | const t = it.type as CatalogItemType |
| 197 | - const key = `${t}/${it.name}` | 221 | + const plugin = it.source.plugin |
| 222 | + // Hook names are positional ("PreToolUse:Bash:0"): one row per entry. Plugin items never merge with others. | ||
| 223 | + const key = t === 'hook' ? `hook/${it.id}` : plugin ? `${t}/${it.name}@${plugin}` : `${t}/${it.name}` | ||
| 198 | if (entryKeys.has(key) || seen.has(key)) continue | 224 | if (entryKeys.has(key) || seen.has(key)) continue |
| 199 | const state = st?.state ?? 'not-in-catalog' | 225 | const state = st?.state ?? 'not-in-catalog' |
| 200 | const meta = ITEM_STATE_META[state] | 226 | const meta = ITEM_STATE_META[state] |
| 201 | - const status: AgentRowStatus = state === 'in-sync' | 227 | + let status: AgentRowStatus |
| 202 | - ? { agent: it.agent, tone: 'ok', label: 'In catalog', short: 'In catalog' } | 228 | + if (plugin) status = { agent: it.agent, tone: 'faint', label: 'From a plugin', short: 'Plugin' } |
| 203 | - : { | 229 | + else if (state === 'in-sync') { |
| 204 | - agent: it.agent, tone: meta.tone, label: meta.label, short: ITEM_STATE_SHORT[state], | 230 | + status = input.home && !HOME_CONFIG_TYPES.has(t) |
| 205 | - action: state === 'not-in-catalog' || state === 'differs-from-catalog' ? 'import' : undefined | 231 | + ? { agent: it.agent, tone: 'ok', label: 'In catalog', short: 'In catalog' } |
| 206 | - } | 232 | + : { agent: it.agent, tone: 'info', label: 'In catalog, not managed', short: 'Not managed', action: 'manage' } |
| 233 | + } else { | ||
| 234 | + status = { | ||
| 235 | + agent: it.agent, tone: meta.tone, label: meta.label, short: ITEM_STATE_SHORT[state], | ||
| 236 | + action: state === 'not-in-catalog' || state === 'differs-from-catalog' ? 'import' : undefined | ||
| 237 | + } | ||
| 238 | + } | ||
| 207 | const row = groups.get(key) ?? { | 239 | const row = groups.get(key) ?? { |
| 208 | - key, kind: 'local' as const, type: t, name: it.name, presets: [], added: false, excluded: false, excludedExact: false, | 240 | + key, kind: 'local' as const, type: t, name: it.name, label: itemDisplayName(it), itemIds: [], plugin, |
| 209 | - inCatalog: inCatalog(t, it.name), origin: 'Local', statuses: [], conflicts: [], viaPreset: false, onDemand: { on: false, exact: true } | 241 | + presets: [], added: false, excluded: false, excludedExact: false, inCatalog: !plugin && inCatalog(t, it.name), |
| 242 | + origin: plugin ? `Plugin · ${plugin}` : 'Local · not managed by skilly', | ||
| 243 | + statuses: [], conflicts: [], viaPreset: false, onDemand: { on: false, exact: true } | ||
| 210 | } | 244 | } |
| 245 | + row.itemIds.push(it.id) | ||
| 211 | row.statuses.push(status) | 246 | row.statuses.push(status) |
| 212 | groups.set(key, row) | 247 | groups.set(key, row) |
| 213 | } | 248 | } |
| @@ -218,7 +253,7 @@ export function buildRows(input: BuildRowsInput): ScopeRow[] { | |||
| 218 | return rows.sort((a, b) => | 253 | return rows.sort((a, b) => |
| 219 | ROW_TYPE_ORDER.indexOf(a.type) - ROW_TYPE_ORDER.indexOf(b.type) | 254 | ROW_TYPE_ORDER.indexOf(a.type) - ROW_TYPE_ORDER.indexOf(b.type) |
| 220 | || kindOrder[a.kind] - kindOrder[b.kind] | 255 | || kindOrder[a.kind] - kindOrder[b.kind] |
| 221 | - || a.name.localeCompare(b.name)) | 256 | + || a.label.localeCompare(b.label, undefined, { sensitivity: 'base' })) |
| 222 | } | 257 | } |
| 223 | 258 | ||
| 224 | /** One merged status when every agent agrees, otherwise the caller shows one mini status per agent. */ | 259 | /** One merged status when every agent agrees, otherwise the caller shows one mini status per agent. */ |
added
packages/ui/test/dashboard.test.ts +62 -0 | new file mode 100644 | ||
| @@ -0,0 +1,62 @@ | ||
| 1 | +import { describe, expect, it } from 'vitest' | |
| 2 | +import type { GitStatus } from '@skilly/core' | |
| 3 | +import { dashboardTodos, startSteps, type ScopeHealth } from '../app/utils/dashboard' | |
| 4 | + | |
| 5 | +const git = (over: Partial<GitStatus> = {}): GitStatus => ({ | |
| 6 | + isRepo: true, branch: 'main', remote: 'origin', ahead: 0, behind: 0, dirty: [], conflicts: [], ...over | |
| 7 | +} as GitStatus) | |
| 8 | +const health = (over: Partial<ScopeHealth> = {}): ScopeHealth => ({ | |
| 9 | + managed: true, status: { tone: 'ok', label: 'Up to date' }, pending: 0, missing: 0, ...over | |
| 10 | +}) | |
| 11 | +const projects = [ | |
| 12 | + { id: 'a1', name: 'site', exists: true }, | |
| 13 | + { id: 'b2', name: 'api', exists: true } | |
| 14 | +] | |
| 15 | + | |
| 16 | +describe('dashboardTodos', () => { | |
| 17 | + it('is empty when everything is in sync', () => { | |
| 18 | + expect(dashboardTodos({ projects, summaries: { home: health(), a1: health(), b2: health() }, git: git(), catalogExists: true, tidyUp: 0 })).toEqual([]) | |
| 19 | + }) | |
| 20 | + | |
| 21 | + it('puts conflicts and drift first, then changes to apply, then housekeeping', () => { | |
| 22 | + const todos = dashboardTodos({ | |
| 23 | + projects: [...projects, { id: 'c3', name: 'gone', exists: false }], | |
| 24 | + summaries: { | |
| 25 | + home: health(), | |
| 26 | + a1: health({ pending: 2, status: { tone: 'info', label: '2 updates' } }), | |
| 27 | + b2: health({ pending: 1, status: { tone: 'accent', label: '1 drift' } }) | |
| 28 | + }, | |
| 29 | + git: git({ behind: 3 }), | |
| 30 | + catalogExists: true, | |
| 31 | + tidyUp: 2 | |
| 32 | + }) | |
| 33 | + expect(todos.map(t => t.id)).toEqual(['b2-pending', 'c3-missing-folder', 'git-sync', 'a1-pending', 'tidy-up']) | |
| 34 | + expect(todos.find(t => t.id === 'a1-pending')!.action.to).toBe('/projects/a1?review=1') | |
| 35 | + expect(todos.find(t => t.id === 'git-sync')!.action.run).toBe('sync') | |
| 36 | + expect(todos.find(t => t.id === 'tidy-up')!.action.to).toBe('/projects/global') | |
| 37 | + }) | |
| 38 | + | |
| 39 | + it('reports git conflicts instead of ahead/behind, and ignores unmanaged scopes', () => { | |
| 40 | + const todos = dashboardTodos({ | |
| 41 | + projects, | |
| 42 | + summaries: { home: health({ managed: false, pending: 0 }), a1: health({ missing: 1 }) }, | |
| 43 | + git: git({ ahead: 1, conflicts: ['skills/x/SKILL.md'] as GitStatus['conflicts'] }), | |
| 44 | + catalogExists: true, | |
| 45 | + tidyUp: 0 | |
| 46 | + }) | |
| 47 | + expect(todos.map(t => t.id)).toEqual(['git-conflicts', 'a1-not-in-catalog']) | |
| 48 | + }) | |
| 49 | + | |
| 50 | + it('links Global to its project route', () => { | |
| 51 | + const [t] = dashboardTodos({ projects: [], summaries: { home: health({ pending: 1, status: { tone: 'info', label: '1 change' } }) }, git: null, catalogExists: false, tidyUp: 0 }) | |
| 52 | + expect(t).toMatchObject({ title: 'Global', action: { to: '/projects/global?review=1' } }) | |
| 53 | + }) | |
| 54 | +}) | |
| 55 | + | |
| 56 | +describe('startSteps', () => { | |
| 57 | + it('marks steps done from the app state and points to the first project', () => { | |
| 58 | + const steps = startSteps({ catalogExists: true, catalogItems: 4, projects, managedProjects: 0, remote: false }) | |
| 59 | + expect(steps.map(s => [s.id, s.done])).toEqual([['catalog', true], ['import', true], ['project', true], ['manage', false], ['share', false]]) | |
| 60 | + expect(steps.find(s => s.id === 'manage')!.action.to).toBe('/projects/a1') | |
| 61 | + }) | |
| 62 | +}) | |
| new file mode 100644 | |||
| @@ -0,0 +1,62 @@ | |||
| 1 | +import { describe, expect, it } from 'vitest' | ||
| 2 | +import type { GitStatus } from '@skilly/core' | ||
| 3 | +import { dashboardTodos, startSteps, type ScopeHealth } from '../app/utils/dashboard' | ||
| 4 | + | ||
| 5 | +const git = (over: Partial<GitStatus> = {}): GitStatus => ({ | ||
| 6 | + isRepo: true, branch: 'main', remote: 'origin', ahead: 0, behind: 0, dirty: [], conflicts: [], ...over | ||
| 7 | +} as GitStatus) | ||
| 8 | +const health = (over: Partial<ScopeHealth> = {}): ScopeHealth => ({ | ||
| 9 | + managed: true, status: { tone: 'ok', label: 'Up to date' }, pending: 0, missing: 0, ...over | ||
| 10 | +}) | ||
| 11 | +const projects = [ | ||
| 12 | + { id: 'a1', name: 'site', exists: true }, | ||
| 13 | + { id: 'b2', name: 'api', exists: true } | ||
| 14 | +] | ||
| 15 | + | ||
| 16 | +describe('dashboardTodos', () => { | ||
| 17 | + it('is empty when everything is in sync', () => { | ||
| 18 | + expect(dashboardTodos({ projects, summaries: { home: health(), a1: health(), b2: health() }, git: git(), catalogExists: true, tidyUp: 0 })).toEqual([]) | ||
| 19 | + }) | ||
| 20 | + | ||
| 21 | + it('puts conflicts and drift first, then changes to apply, then housekeeping', () => { | ||
| 22 | + const todos = dashboardTodos({ | ||
| 23 | + projects: [...projects, { id: 'c3', name: 'gone', exists: false }], | ||
| 24 | + summaries: { | ||
| 25 | + home: health(), | ||
| 26 | + a1: health({ pending: 2, status: { tone: 'info', label: '2 updates' } }), | ||
| 27 | + b2: health({ pending: 1, status: { tone: 'accent', label: '1 drift' } }) | ||
| 28 | + }, | ||
| 29 | + git: git({ behind: 3 }), | ||
| 30 | + catalogExists: true, | ||
| 31 | + tidyUp: 2 | ||
| 32 | + }) | ||
| 33 | + expect(todos.map(t => t.id)).toEqual(['b2-pending', 'c3-missing-folder', 'git-sync', 'a1-pending', 'tidy-up']) | ||
| 34 | + expect(todos.find(t => t.id === 'a1-pending')!.action.to).toBe('/projects/a1?review=1') | ||
| 35 | + expect(todos.find(t => t.id === 'git-sync')!.action.run).toBe('sync') | ||
| 36 | + expect(todos.find(t => t.id === 'tidy-up')!.action.to).toBe('/projects/global') | ||
| 37 | + }) | ||
| 38 | + | ||
| 39 | + it('reports git conflicts instead of ahead/behind, and ignores unmanaged scopes', () => { | ||
| 40 | + const todos = dashboardTodos({ | ||
| 41 | + projects, | ||
| 42 | + summaries: { home: health({ managed: false, pending: 0 }), a1: health({ missing: 1 }) }, | ||
| 43 | + git: git({ ahead: 1, conflicts: ['skills/x/SKILL.md'] as GitStatus['conflicts'] }), | ||
| 44 | + catalogExists: true, | ||
| 45 | + tidyUp: 0 | ||
| 46 | + }) | ||
| 47 | + expect(todos.map(t => t.id)).toEqual(['git-conflicts', 'a1-not-in-catalog']) | ||
| 48 | + }) | ||
| 49 | + | ||
| 50 | + it('links Global to its project route', () => { | ||
| 51 | + const [t] = dashboardTodos({ projects: [], summaries: { home: health({ pending: 1, status: { tone: 'info', label: '1 change' } }) }, git: null, catalogExists: false, tidyUp: 0 }) | ||
| 52 | + expect(t).toMatchObject({ title: 'Global', action: { to: '/projects/global?review=1' } }) | ||
| 53 | + }) | ||
| 54 | +}) | ||
| 55 | + | ||
| 56 | +describe('startSteps', () => { | ||
| 57 | + it('marks steps done from the app state and points to the first project', () => { | ||
| 58 | + const steps = startSteps({ catalogExists: true, catalogItems: 4, projects, managedProjects: 0, remote: false }) | ||
| 59 | + expect(steps.map(s => [s.id, s.done])).toEqual([['catalog', true], ['import', true], ['project', true], ['manage', false], ['share', false]]) | ||
| 60 | + expect(steps.find(s => s.id === 'manage')!.action.to).toBe('/projects/a1') | ||
| 61 | + }) | ||
| 62 | +}) | ||
modified
packages/ui/test/scope-views.test.ts +55 -0 | @@ -1,6 +1,7 @@ | ||
| 1 | 1 | import { describe, expect, it } from 'vitest' |
| 2 | 2 | import type { InventoryItem, ScopeConfig, SyncPlan } from '@skilly/core' |
| 3 | 3 | import { buildRows, matchIn, mergeStatuses } from '../app/utils/scopeRows' |
| 4 | +import { manageInConfig } from '../app/utils/scopeConfig' | |
| 4 | 5 | import { projectSpecific, splitManagedBlock, homeDirOf } from '../app/utils/inventoryView' |
| 5 | 6 | import { summarizePlan, writtenToHint } from '../app/utils/planView' |
| 6 | 7 | |
| @@ -35,6 +36,60 @@ describe('buildRows', () => { | ||
| 35 | 36 | expect(rows[0]!.kind).toBe('removal') |
| 36 | 37 | }) |
| 37 | 38 | |
| 39 | + const inv = (id: string, type: string, name: string, extra: { agent?: string, plugin?: string, details?: object } = {}) => ({ | |
| 40 | + id, type, name, agent: extra.agent ?? 'claude', source: { path: `/p/.claude/${name}`, kind: 'dir', plugin: extra.plugin }, | |
| 41 | + details: extra.details ?? { type }, warnings: [] | |
| 42 | + }) as unknown as InventoryItem | |
| 43 | + const inventory = (items: InventoryItem[]) => ({ root: '/p', items, errors: [], agentsDetected: ['claude'] }) as any | |
| 44 | + | |
| 45 | + it('lists what is on disk when the scope is not managed: local items merged by name, hooks and plugin items apart', () => { | |
| 46 | + const items = [ | |
| 47 | + inv('s1', 'skill', 'seo'), inv('s2', 'skill', 'seo', { agent: 'codex' }), | |
| 48 | + inv('h1', 'hook', 'PreToolUse:Bash:0', { details: { type: 'hook', event: 'PreToolUse', matcher: 'Bash' } }), | |
| 49 | + inv('h2', 'hook', 'PreToolUse:Bash:1', { details: { type: 'hook', event: 'PreToolUse', matcher: 'Bash' } }), | |
| 50 | + inv('p1', 'skill', 'seo', { plugin: 'marketing' }), | |
| 51 | + inv('i1', 'instructions', 'CLAUDE.md') | |
| 52 | + ] | |
| 53 | + const rows = buildRows({ config: null, presets, catalogItems: [], includeLocal: true, plan: null, inventory: inventory(items), statuses: {} }) | |
| 54 | + expect(rows.map(r => [r.key, r.itemIds])).toEqual([ | |
| 55 | + ['skill/seo', ['s1', 's2']], | |
| 56 | + ['skill/seo@marketing', ['p1']], | |
| 57 | + ['hook/h1', ['h1']], | |
| 58 | + ['hook/h2', ['h2']] | |
| 59 | + ]) | |
| 60 | + expect(rows[0]!.statuses.map(s => s.action)).toEqual(['import', 'import']) | |
| 61 | + expect(rows[1]).toMatchObject({ plugin: 'marketing', origin: 'Plugin · marketing', inCatalog: false }) | |
| 62 | + expect(rows[1]!.statuses[0]!.action).toBeUndefined() | |
| 63 | + expect(rows[2]!.label).toBe('PreToolUse · Bash') | |
| 64 | + }) | |
| 65 | + | |
| 66 | + it('offers "Manage" for local items identical to the catalog, except Global MCP servers and hooks', () => { | |
| 67 | + const items = [inv('s1', 'skill', 'seo'), inv('m1', 'mcp', 'github')] | |
| 68 | + const statuses = { | |
| 69 | + s1: { managed: false, state: 'in-sync', catalogRef: { type: 'skill', name: 'seo' } }, | |
| 70 | + m1: { managed: false, state: 'in-sync', catalogRef: { type: 'mcp', name: 'github' } } | |
| 71 | + } as any | |
| 72 | + const project = buildRows({ config: null, presets, catalogItems: [], includeLocal: true, plan: null, inventory: inventory(items), statuses }) | |
| 73 | + expect(project.map(r => r.statuses[0]!.action)).toEqual(['manage', 'manage']) | |
| 74 | + const home = buildRows({ config: null, presets, catalogItems: [], includeLocal: true, plan: null, inventory: inventory(items), statuses, home: true }) | |
| 75 | + expect(home.map(r => r.statuses[0]!.action)).toEqual(['manage', undefined]) | |
| 76 | + }) | |
| 77 | + | |
| 78 | + it('manages catalog items: added one by one, or un-excluded when a preset provides them', () => { | |
| 79 | + const c = config({ add: {}, exclude: { skill: ['pr-review'] } }) | |
| 80 | + expect(manageInConfig(c, [{ type: 'skill', name: 'pr-review' }, { type: 'rule', name: 'style' }], presets)).toBe(true) | |
| 81 | + expect(c.exclude).toEqual({}) | |
| 82 | + expect(c.add).toEqual({ rule: ['style'] }) | |
| 83 | + expect(manageInConfig(c, [{ type: 'skill', name: 'seo-audit' }, { type: 'rule', name: 'style' }], presets)).toBe(false) | |
| 84 | + }) | |
| 85 | + | |
| 86 | + it('links managed rows to their files and does not list them twice', () => { | |
| 87 | + const items = [inv('m1', 'skill', 'extra'), inv('l1', 'rule', 'mine')] | |
| 88 | + const statuses = { m1: { managed: true, state: 'in-sync', catalogRef: { type: 'skill', name: 'extra' } } } as any | |
| 89 | + const rows = buildRows({ config: config({ presets: [] }), presets, catalogItems: [], includeLocal: true, plan: null, inventory: inventory(items), statuses }) | |
| 90 | + expect(rows.map(r => [r.key, r.kind, r.itemIds])).toEqual([['skill/extra', 'entry', ['m1']], ['rule/mine', 'local', ['l1']]]) | |
| 91 | + }) | |
| 92 | + | |
| 38 | 93 | it('merges equal statuses and keeps different ones apart', () => { |
| 39 | 94 | const a = { agent: 'claude', tone: 'ok', label: 'Up to date', short: '' } as const |
| 40 | 95 | expect(mergeStatuses([a, { ...a, agent: 'codex' }])).not.toBeNull() |
| @@ -1,6 +1,7 @@ | |||
| 1 | import { describe, expect, it } from 'vitest' | 1 | import { describe, expect, it } from 'vitest' |
| 2 | import type { InventoryItem, ScopeConfig, SyncPlan } from '@skilly/core' | 2 | import type { InventoryItem, ScopeConfig, SyncPlan } from '@skilly/core' |
| 3 | import { buildRows, matchIn, mergeStatuses } from '../app/utils/scopeRows' | 3 | import { buildRows, matchIn, mergeStatuses } from '../app/utils/scopeRows' |
| 4 | +import { manageInConfig } from '../app/utils/scopeConfig' | ||
| 4 | import { projectSpecific, splitManagedBlock, homeDirOf } from '../app/utils/inventoryView' | 5 | import { projectSpecific, splitManagedBlock, homeDirOf } from '../app/utils/inventoryView' |
| 5 | import { summarizePlan, writtenToHint } from '../app/utils/planView' | 6 | import { summarizePlan, writtenToHint } from '../app/utils/planView' |
| 6 | 7 | ||
| @@ -35,6 +36,60 @@ describe('buildRows', () => { | |||
| 35 | expect(rows[0]!.kind).toBe('removal') | 36 | expect(rows[0]!.kind).toBe('removal') |
| 36 | }) | 37 | }) |
| 37 | 38 | ||
| 39 | + const inv = (id: string, type: string, name: string, extra: { agent?: string, plugin?: string, details?: object } = {}) => ({ | ||
| 40 | + id, type, name, agent: extra.agent ?? 'claude', source: { path: `/p/.claude/${name}`, kind: 'dir', plugin: extra.plugin }, | ||
| 41 | + details: extra.details ?? { type }, warnings: [] | ||
| 42 | + }) as unknown as InventoryItem | ||
| 43 | + const inventory = (items: InventoryItem[]) => ({ root: '/p', items, errors: [], agentsDetected: ['claude'] }) as any | ||
| 44 | + | ||
| 45 | + it('lists what is on disk when the scope is not managed: local items merged by name, hooks and plugin items apart', () => { | ||
| 46 | + const items = [ | ||
| 47 | + inv('s1', 'skill', 'seo'), inv('s2', 'skill', 'seo', { agent: 'codex' }), | ||
| 48 | + inv('h1', 'hook', 'PreToolUse:Bash:0', { details: { type: 'hook', event: 'PreToolUse', matcher: 'Bash' } }), | ||
| 49 | + inv('h2', 'hook', 'PreToolUse:Bash:1', { details: { type: 'hook', event: 'PreToolUse', matcher: 'Bash' } }), | ||
| 50 | + inv('p1', 'skill', 'seo', { plugin: 'marketing' }), | ||
| 51 | + inv('i1', 'instructions', 'CLAUDE.md') | ||
| 52 | + ] | ||
| 53 | + const rows = buildRows({ config: null, presets, catalogItems: [], includeLocal: true, plan: null, inventory: inventory(items), statuses: {} }) | ||
| 54 | + expect(rows.map(r => [r.key, r.itemIds])).toEqual([ | ||
| 55 | + ['skill/seo', ['s1', 's2']], | ||
| 56 | + ['skill/seo@marketing', ['p1']], | ||
| 57 | + ['hook/h1', ['h1']], | ||
| 58 | + ['hook/h2', ['h2']] | ||
| 59 | + ]) | ||
| 60 | + expect(rows[0]!.statuses.map(s => s.action)).toEqual(['import', 'import']) | ||
| 61 | + expect(rows[1]).toMatchObject({ plugin: 'marketing', origin: 'Plugin · marketing', inCatalog: false }) | ||
| 62 | + expect(rows[1]!.statuses[0]!.action).toBeUndefined() | ||
| 63 | + expect(rows[2]!.label).toBe('PreToolUse · Bash') | ||
| 64 | + }) | ||
| 65 | + | ||
| 66 | + it('offers "Manage" for local items identical to the catalog, except Global MCP servers and hooks', () => { | ||
| 67 | + const items = [inv('s1', 'skill', 'seo'), inv('m1', 'mcp', 'github')] | ||
| 68 | + const statuses = { | ||
| 69 | + s1: { managed: false, state: 'in-sync', catalogRef: { type: 'skill', name: 'seo' } }, | ||
| 70 | + m1: { managed: false, state: 'in-sync', catalogRef: { type: 'mcp', name: 'github' } } | ||
| 71 | + } as any | ||
| 72 | + const project = buildRows({ config: null, presets, catalogItems: [], includeLocal: true, plan: null, inventory: inventory(items), statuses }) | ||
| 73 | + expect(project.map(r => r.statuses[0]!.action)).toEqual(['manage', 'manage']) | ||
| 74 | + const home = buildRows({ config: null, presets, catalogItems: [], includeLocal: true, plan: null, inventory: inventory(items), statuses, home: true }) | ||
| 75 | + expect(home.map(r => r.statuses[0]!.action)).toEqual(['manage', undefined]) | ||
| 76 | + }) | ||
| 77 | + | ||
| 78 | + it('manages catalog items: added one by one, or un-excluded when a preset provides them', () => { | ||
| 79 | + const c = config({ add: {}, exclude: { skill: ['pr-review'] } }) | ||
| 80 | + expect(manageInConfig(c, [{ type: 'skill', name: 'pr-review' }, { type: 'rule', name: 'style' }], presets)).toBe(true) | ||
| 81 | + expect(c.exclude).toEqual({}) | ||
| 82 | + expect(c.add).toEqual({ rule: ['style'] }) | ||
| 83 | + expect(manageInConfig(c, [{ type: 'skill', name: 'seo-audit' }, { type: 'rule', name: 'style' }], presets)).toBe(false) | ||
| 84 | + }) | ||
| 85 | + | ||
| 86 | + it('links managed rows to their files and does not list them twice', () => { | ||
| 87 | + const items = [inv('m1', 'skill', 'extra'), inv('l1', 'rule', 'mine')] | ||
| 88 | + const statuses = { m1: { managed: true, state: 'in-sync', catalogRef: { type: 'skill', name: 'extra' } } } as any | ||
| 89 | + const rows = buildRows({ config: config({ presets: [] }), presets, catalogItems: [], includeLocal: true, plan: null, inventory: inventory(items), statuses }) | ||
| 90 | + expect(rows.map(r => [r.key, r.kind, r.itemIds])).toEqual([['skill/extra', 'entry', ['m1']], ['rule/mine', 'local', ['l1']]]) | ||
| 91 | + }) | ||
| 92 | + | ||
| 38 | it('merges equal statuses and keeps different ones apart', () => { | 93 | it('merges equal statuses and keeps different ones apart', () => { |
| 39 | const a = { agent: 'claude', tone: 'ok', label: 'Up to date', short: '' } as const | 94 | const a = { agent: 'claude', tone: 'ok', label: 'Up to date', short: '' } as const |
| 40 | expect(mergeStatuses([a, { ...a, agent: 'codex' }])).not.toBeNull() | 95 | expect(mergeStatuses([a, { ...a, agent: 'codex' }])).not.toBeNull() |