feat(ui): redesign Home, Projects, Catalog, Presets, Sync, Marketplaces, Settings and onboarding from the Claude Design mockup
Also adds catalog clone and project discovery endpoints, and fixes the flaky catalog git tests (fewer git spawns, no optional locks on status). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
9d90581 parent: eb24262 modified
packages/core/src/api.ts +25 -0 | @@ -13,6 +13,7 @@ | ||
| 13 | 13 | * DELETE /api/projects/:id -> 204 (only forgets the project, never touches its files) |
| 14 | 14 | * GET /api/projects/:id/inventory -> ScopeInventory |
| 15 | 15 | * GET /api/fs/list?path=<abs> -> FsListing (no path: roots, i.e. drives on Windows + home) |
| 16 | + * GET /api/fs/discover?root=<abs>&depth=<1..4> -> FsDiscovery (folders with agent config under root; capped in count and time) | |
| 16 | 17 | * GET /api/files?path=<abs> -> FileContent (read-only; only inside the home agent folders or an added project) |
| 17 | 18 | * |
| 18 | 19 | * Errors: non-2xx responses carry an `ApiError` body. |
| @@ -55,6 +56,21 @@ export interface FsListing { | ||
| 55 | 56 | entries: FsEntry[] |
| 56 | 57 | } |
| 57 | 58 | |
| 59 | +/** A folder found by `GET /api/fs/discover`. */ | |
| 60 | +export interface DiscoveredProject extends FsEntry { | |
| 61 | + /** Agent config files / folders found in it (`.claude`, `AGENTS.md`...). */ | |
| 62 | + markers: string[] | |
| 63 | +} | |
| 64 | + | |
| 65 | +export interface FsDiscovery { | |
| 66 | + root: string | |
| 67 | + /** Effective depth after clamping. */ | |
| 68 | + depth: number | |
| 69 | + entries: DiscoveredProject[] | |
| 70 | + /** True when the result cap or the time limit stopped the search early. */ | |
| 71 | + truncated: boolean | |
| 72 | +} | |
| 73 | + | |
| 58 | 74 | export interface FileContent { |
| 59 | 75 | path: string |
| 60 | 76 | content: string |
| @@ -75,6 +91,8 @@ export interface FileContent { | ||
| 75 | 91 | * Catalog |
| 76 | 92 | * GET /api/catalog -> CatalogResponse |
| 77 | 93 | * POST /api/catalog/init -> CatalogResponse (creates layout + git init if not a repo) |
| 94 | + * POST /api/catalog/clone CloneCatalogBody -> CatalogResponse (git clone into the catalog path, which must be absent or empty; | |
| 95 | + * `path` also becomes settings.catalogPath; 400 with git's message on failure) | |
| 78 | 96 | * GET /api/catalog/items/:type/:name -> CatalogItemDetail |
| 79 | 97 | * PUT /api/catalog/items/:type/:name SaveItemInput -> CatalogItem (create or update; rename via previousName) |
| 80 | 98 | * DELETE /api/catalog/items/:type/:name -> 204 (409 if used by a scope config, unless ?force=1) |
| @@ -130,6 +148,13 @@ export interface Settings { | ||
| 130 | 148 | autoSync: boolean |
| 131 | 149 | } |
| 132 | 150 | |
| 151 | +export interface CloneCatalogBody { | |
| 152 | + /** Git remote URL (SSH or HTTPS) of the catalog repository. */ | |
| 153 | + url: string | |
| 154 | + /** Absolute target folder; defaults to the current catalog path. Saved as `settings.catalogPath` once the clone succeeded. */ | |
| 155 | + path?: string | |
| 156 | +} | |
| 157 | + | |
| 133 | 158 | export interface CatalogResponse { |
| 134 | 159 | snapshot: CatalogSnapshot |
| 135 | 160 | git: GitStatus |
| @@ -13,6 +13,7 @@ | |||
| 13 | * DELETE /api/projects/:id -> 204 (only forgets the project, never touches its files) | 13 | * DELETE /api/projects/:id -> 204 (only forgets the project, never touches its files) |
| 14 | * GET /api/projects/:id/inventory -> ScopeInventory | 14 | * GET /api/projects/:id/inventory -> ScopeInventory |
| 15 | * GET /api/fs/list?path=<abs> -> FsListing (no path: roots, i.e. drives on Windows + home) | 15 | * GET /api/fs/list?path=<abs> -> FsListing (no path: roots, i.e. drives on Windows + home) |
| 16 | + * GET /api/fs/discover?root=<abs>&depth=<1..4> -> FsDiscovery (folders with agent config under root; capped in count and time) | ||
| 16 | * GET /api/files?path=<abs> -> FileContent (read-only; only inside the home agent folders or an added project) | 17 | * GET /api/files?path=<abs> -> FileContent (read-only; only inside the home agent folders or an added project) |
| 17 | * | 18 | * |
| 18 | * Errors: non-2xx responses carry an `ApiError` body. | 19 | * Errors: non-2xx responses carry an `ApiError` body. |
| @@ -55,6 +56,21 @@ export interface FsListing { | |||
| 55 | entries: FsEntry[] | 56 | entries: FsEntry[] |
| 56 | } | 57 | } |
| 57 | 58 | ||
| 59 | +/** A folder found by `GET /api/fs/discover`. */ | ||
| 60 | +export interface DiscoveredProject extends FsEntry { | ||
| 61 | + /** Agent config files / folders found in it (`.claude`, `AGENTS.md`...). */ | ||
| 62 | + markers: string[] | ||
| 63 | +} | ||
| 64 | + | ||
| 65 | +export interface FsDiscovery { | ||
| 66 | + root: string | ||
| 67 | + /** Effective depth after clamping. */ | ||
| 68 | + depth: number | ||
| 69 | + entries: DiscoveredProject[] | ||
| 70 | + /** True when the result cap or the time limit stopped the search early. */ | ||
| 71 | + truncated: boolean | ||
| 72 | +} | ||
| 73 | + | ||
| 58 | export interface FileContent { | 74 | export interface FileContent { |
| 59 | path: string | 75 | path: string |
| 60 | content: string | 76 | content: string |
| @@ -75,6 +91,8 @@ export interface FileContent { | |||
| 75 | * Catalog | 91 | * Catalog |
| 76 | * GET /api/catalog -> CatalogResponse | 92 | * GET /api/catalog -> CatalogResponse |
| 77 | * POST /api/catalog/init -> CatalogResponse (creates layout + git init if not a repo) | 93 | * POST /api/catalog/init -> CatalogResponse (creates layout + git init if not a repo) |
| 94 | + * POST /api/catalog/clone CloneCatalogBody -> CatalogResponse (git clone into the catalog path, which must be absent or empty; | ||
| 95 | + * `path` also becomes settings.catalogPath; 400 with git's message on failure) | ||
| 78 | * GET /api/catalog/items/:type/:name -> CatalogItemDetail | 96 | * GET /api/catalog/items/:type/:name -> CatalogItemDetail |
| 79 | * PUT /api/catalog/items/:type/:name SaveItemInput -> CatalogItem (create or update; rename via previousName) | 97 | * PUT /api/catalog/items/:type/:name SaveItemInput -> CatalogItem (create or update; rename via previousName) |
| 80 | * DELETE /api/catalog/items/:type/:name -> 204 (409 if used by a scope config, unless ?force=1) | 98 | * DELETE /api/catalog/items/:type/:name -> 204 (409 if used by a scope config, unless ?force=1) |
| @@ -130,6 +148,13 @@ export interface Settings { | |||
| 130 | autoSync: boolean | 148 | autoSync: boolean |
| 131 | } | 149 | } |
| 132 | 150 | ||
| 151 | +export interface CloneCatalogBody { | ||
| 152 | + /** Git remote URL (SSH or HTTPS) of the catalog repository. */ | ||
| 153 | + url: string | ||
| 154 | + /** Absolute target folder; defaults to the current catalog path. Saved as `settings.catalogPath` once the clone succeeded. */ | ||
| 155 | + path?: string | ||
| 156 | +} | ||
| 157 | + | ||
| 133 | export interface CatalogResponse { | 158 | export interface CatalogResponse { |
| 134 | snapshot: CatalogSnapshot | 159 | snapshot: CatalogSnapshot |
| 135 | git: GitStatus | 160 | git: GitStatus |
modified
packages/core/src/catalog/git.ts +59 -16 | @@ -1,5 +1,5 @@ | ||
| 1 | 1 | import { execFile } from 'node:child_process' |
| 2 | -import { mkdir, readFile, rm } from 'node:fs/promises' | |
| 2 | +import { mkdir, readFile, readdir, rm } from 'node:fs/promises' | |
| 3 | 3 | import path from 'node:path' |
| 4 | 4 | import type { GitStatus } from '../catalog-model.ts' |
| 5 | 5 | import type { CatalogGit, OpenCatalogGit } from '../contracts.ts' |
| @@ -80,11 +80,16 @@ class GitCatalog implements CatalogGit { | ||
| 80 | 80 | } |
| 81 | 81 | |
| 82 | 82 | private async identityArgs(): Promise<string[]> { |
| 83 | - const email = await this.raw(['config', 'user.email']) | |
| 84 | - const name = await this.raw(['config', 'user.name']) | |
| 83 | + // One process instead of two: every git spawn costs 50-150 ms on Windows. | |
| 84 | + const r = await this.raw(['config', '--get-regexp', '^user\\.(name|email)$']) | |
| 85 | + const keys = new Set<string>() | |
| 86 | + for (const line of r.stdout.split(/\r?\n/)) { | |
| 87 | + const i = line.indexOf(' ') | |
| 88 | + if (i > 0 && line.slice(i + 1).trim()) keys.add(line.slice(0, i).toLowerCase()) | |
| 89 | + } | |
| 85 | 90 | const args: string[] = [] |
| 86 | - if (!name.stdout.trim()) args.push('-c', 'user.name=skilly') | |
| 87 | - if (!email.stdout.trim()) args.push('-c', 'user.email=skilly@localhost') | |
| 91 | + if (!keys.has('user.name')) args.push('-c', 'user.name=skilly') | |
| 92 | + if (!keys.has('user.email')) args.push('-c', 'user.email=skilly@localhost') | |
| 88 | 93 | return args |
| 89 | 94 | } |
| 90 | 95 | |
| @@ -95,6 +100,18 @@ class GitCatalog implements CatalogGit { | ||
| 95 | 100 | return names.includes('origin') ? 'origin' : names[0] |
| 96 | 101 | } |
| 97 | 102 | |
| 103 | + /** URL of the remote used for sync (`origin`, else the first one), in a single git call. */ | |
| 104 | + private async remoteUrl(): Promise<string | undefined> { | |
| 105 | + const r = await this.raw(['remote', '-v']) | |
| 106 | + if (r.code !== 0) return undefined | |
| 107 | + const urls = new Map<string, string>() | |
| 108 | + for (const line of r.stdout.split(/\r?\n/)) { | |
| 109 | + const m = /^(\S+)\s+(.+) \(fetch\)$/.exec(line.trim()) | |
| 110 | + if (m && !urls.has(m[1]!)) urls.set(m[1]!, m[2]!.trim()) | |
| 111 | + } | |
| 112 | + return urls.get('origin') ?? urls.values().next().value | |
| 113 | + } | |
| 114 | + | |
| 98 | 115 | private async hasCommits(): Promise<boolean> { |
| 99 | 116 | return (await this.raw(['rev-parse', '--verify', '-q', 'HEAD'])).code === 0 |
| 100 | 117 | } |
| @@ -102,16 +119,14 @@ class GitCatalog implements CatalogGit { | ||
| 102 | 119 | async status(): Promise<GitStatus> { |
| 103 | 120 | const st: GitStatus = { isRepo: false, ahead: 0, behind: 0, dirty: [], conflicts: [] } |
| 104 | 121 | if (!(await this.isRepo())) return st |
| 105 | - const r = await this.raw(['status', '--porcelain=v2', '--branch', '--untracked-files=all', '-z']) | |
| 122 | + // --no-optional-locks: a read-only status must never take .git/index.lock, or it would make a concurrent commit | |
| 123 | + // (auto-sync) fail with "Unable to create index.lock" while the UI polls the status. | |
| 124 | + const r = await this.raw(['--no-optional-locks', 'status', '--porcelain=v2', '--branch', '--untracked-files=all', '-z']) | |
| 106 | 125 | if (r.code !== 0) throw failure(['status'], r) |
| 107 | 126 | st.isRepo = true |
| 108 | 127 | parseStatus(r.stdout, st) |
| 109 | - const remote = await this.remoteName() | |
| 110 | - if (remote) { | |
| 111 | - const url = await this.raw(['remote', 'get-url', remote]) | |
| 112 | - if (url.code === 0 && url.stdout.trim()) st.remote = url.stdout.trim() | |
| 113 | - } | |
| 114 | - const log = await this.raw(['log', '-1', '--format=%H%x1f%s%x1f%aI']) | |
| 128 | + const [remoteUrl, log] = await Promise.all([this.remoteUrl(), this.raw(['log', '-1', '--format=%H%x1f%s%x1f%aI'])]) | |
| 129 | + if (remoteUrl) st.remote = remoteUrl | |
| 115 | 130 | if (log.code === 0 && log.stdout.trim()) { |
| 116 | 131 | const [hash, message, date] = log.stdout.trim().split('\x1f') |
| 117 | 132 | if (hash) st.lastCommit = { hash, message: message ?? '', date: date ?? '' } |
| @@ -125,6 +140,31 @@ class GitCatalog implements CatalogGit { | ||
| 125 | 140 | await this.git(['init', '--initial-branch=main']) |
| 126 | 141 | } |
| 127 | 142 | |
| 143 | + async clone(url: string): Promise<GitStatus> { | |
| 144 | + const target = url.trim() | |
| 145 | + if (!target) throw new Error('Remote URL is empty') | |
| 146 | + if (target.startsWith('-')) throw new Error('Invalid remote URL') | |
| 147 | + if (await this.isRepo()) throw new Error(`Already a git repository: ${this.root}`) | |
| 148 | + let existed = true | |
| 149 | + try { | |
| 150 | + if ((await readdir(this.root)).length > 0) throw new Error(`The catalog folder is not empty: ${this.root}`) | |
| 151 | + } catch (e) { | |
| 152 | + if ((e as NodeJS.ErrnoException).code !== 'ENOENT') throw e | |
| 153 | + existed = false | |
| 154 | + } | |
| 155 | + await mkdir(this.root, { recursive: true }) | |
| 156 | + const args = ['clone', '--quiet', '--', target, '.'] | |
| 157 | + const r = await this.raw(args) | |
| 158 | + if (r.code !== 0) { | |
| 159 | + // git removes what it created, but leave the folder exactly as we found it. | |
| 160 | + if (existed) { | |
| 161 | + for (const name of await readdir(this.root)) await rm(path.join(this.root, name), { recursive: true, force: true }) | |
| 162 | + } else await rm(this.root, { recursive: true, force: true }) | |
| 163 | + throw failure(args, r) | |
| 164 | + } | |
| 165 | + return this.status() | |
| 166 | + } | |
| 167 | + | |
| 128 | 168 | async setRemote(url: string): Promise<void> { |
| 129 | 169 | await this.requireRepo() |
| 130 | 170 | if (!url.trim()) throw new Error('Remote URL is empty') |
| @@ -136,10 +176,13 @@ class GitCatalog implements CatalogGit { | ||
| 136 | 176 | async commitAll(message: string): Promise<string | undefined> { |
| 137 | 177 | await this.requireRepo() |
| 138 | 178 | await this.git(['add', '-A']) |
| 139 | - const s = await this.git(['status', '--porcelain']) | |
| 140 | - const merging = (await this.raw(['rev-parse', '-q', '--verify', 'MERGE_HEAD'])).code === 0 | |
| 141 | - if (!s.trim() && !merging) return undefined | |
| 142 | - await this.git([...(await this.identityArgs()), 'commit', '-m', message || 'skilly sync']) | |
| 179 | + const [s, merge, identity] = await Promise.all([ | |
| 180 | + this.git(['status', '--porcelain']), | |
| 181 | + this.raw(['rev-parse', '-q', '--verify', 'MERGE_HEAD']), | |
| 182 | + this.identityArgs(), | |
| 183 | + ]) | |
| 184 | + if (!s.trim() && merge.code !== 0) return undefined | |
| 185 | + await this.git([...identity, 'commit', '-m', message || 'skilly sync']) | |
| 143 | 186 | return (await this.currentCommit()) ?? undefined |
| 144 | 187 | } |
| 145 | 188 | |
| @@ -1,5 +1,5 @@ | |||
| 1 | import { execFile } from 'node:child_process' | 1 | import { execFile } from 'node:child_process' |
| 2 | -import { mkdir, readFile, rm } from 'node:fs/promises' | 2 | +import { mkdir, readFile, readdir, rm } from 'node:fs/promises' |
| 3 | import path from 'node:path' | 3 | import path from 'node:path' |
| 4 | import type { GitStatus } from '../catalog-model.ts' | 4 | import type { GitStatus } from '../catalog-model.ts' |
| 5 | import type { CatalogGit, OpenCatalogGit } from '../contracts.ts' | 5 | import type { CatalogGit, OpenCatalogGit } from '../contracts.ts' |
| @@ -80,11 +80,16 @@ class GitCatalog implements CatalogGit { | |||
| 80 | } | 80 | } |
| 81 | 81 | ||
| 82 | private async identityArgs(): Promise<string[]> { | 82 | private async identityArgs(): Promise<string[]> { |
| 83 | - const email = await this.raw(['config', 'user.email']) | 83 | + // One process instead of two: every git spawn costs 50-150 ms on Windows. |
| 84 | - const name = await this.raw(['config', 'user.name']) | 84 | + const r = await this.raw(['config', '--get-regexp', '^user\\.(name|email)$']) |
| 85 | + const keys = new Set<string>() | ||
| 86 | + for (const line of r.stdout.split(/\r?\n/)) { | ||
| 87 | + const i = line.indexOf(' ') | ||
| 88 | + if (i > 0 && line.slice(i + 1).trim()) keys.add(line.slice(0, i).toLowerCase()) | ||
| 89 | + } | ||
| 85 | const args: string[] = [] | 90 | const args: string[] = [] |
| 86 | - if (!name.stdout.trim()) args.push('-c', 'user.name=skilly') | 91 | + if (!keys.has('user.name')) args.push('-c', 'user.name=skilly') |
| 87 | - if (!email.stdout.trim()) args.push('-c', 'user.email=skilly@localhost') | 92 | + if (!keys.has('user.email')) args.push('-c', 'user.email=skilly@localhost') |
| 88 | return args | 93 | return args |
| 89 | } | 94 | } |
| 90 | 95 | ||
| @@ -95,6 +100,18 @@ class GitCatalog implements CatalogGit { | |||
| 95 | return names.includes('origin') ? 'origin' : names[0] | 100 | return names.includes('origin') ? 'origin' : names[0] |
| 96 | } | 101 | } |
| 97 | 102 | ||
| 103 | + /** URL of the remote used for sync (`origin`, else the first one), in a single git call. */ | ||
| 104 | + private async remoteUrl(): Promise<string | undefined> { | ||
| 105 | + const r = await this.raw(['remote', '-v']) | ||
| 106 | + if (r.code !== 0) return undefined | ||
| 107 | + const urls = new Map<string, string>() | ||
| 108 | + for (const line of r.stdout.split(/\r?\n/)) { | ||
| 109 | + const m = /^(\S+)\s+(.+) \(fetch\)$/.exec(line.trim()) | ||
| 110 | + if (m && !urls.has(m[1]!)) urls.set(m[1]!, m[2]!.trim()) | ||
| 111 | + } | ||
| 112 | + return urls.get('origin') ?? urls.values().next().value | ||
| 113 | + } | ||
| 114 | + | ||
| 98 | private async hasCommits(): Promise<boolean> { | 115 | private async hasCommits(): Promise<boolean> { |
| 99 | return (await this.raw(['rev-parse', '--verify', '-q', 'HEAD'])).code === 0 | 116 | return (await this.raw(['rev-parse', '--verify', '-q', 'HEAD'])).code === 0 |
| 100 | } | 117 | } |
| @@ -102,16 +119,14 @@ class GitCatalog implements CatalogGit { | |||
| 102 | async status(): Promise<GitStatus> { | 119 | async status(): Promise<GitStatus> { |
| 103 | const st: GitStatus = { isRepo: false, ahead: 0, behind: 0, dirty: [], conflicts: [] } | 120 | const st: GitStatus = { isRepo: false, ahead: 0, behind: 0, dirty: [], conflicts: [] } |
| 104 | if (!(await this.isRepo())) return st | 121 | if (!(await this.isRepo())) return st |
| 105 | - const r = await this.raw(['status', '--porcelain=v2', '--branch', '--untracked-files=all', '-z']) | 122 | + // --no-optional-locks: a read-only status must never take .git/index.lock, or it would make a concurrent commit |
| 123 | + // (auto-sync) fail with "Unable to create index.lock" while the UI polls the status. | ||
| 124 | + const r = await this.raw(['--no-optional-locks', 'status', '--porcelain=v2', '--branch', '--untracked-files=all', '-z']) | ||
| 106 | if (r.code !== 0) throw failure(['status'], r) | 125 | if (r.code !== 0) throw failure(['status'], r) |
| 107 | st.isRepo = true | 126 | st.isRepo = true |
| 108 | parseStatus(r.stdout, st) | 127 | parseStatus(r.stdout, st) |
| 109 | - const remote = await this.remoteName() | 128 | + const [remoteUrl, log] = await Promise.all([this.remoteUrl(), this.raw(['log', '-1', '--format=%H%x1f%s%x1f%aI'])]) |
| 110 | - if (remote) { | 129 | + if (remoteUrl) st.remote = remoteUrl |
| 111 | - const url = await this.raw(['remote', 'get-url', remote]) | ||
| 112 | - if (url.code === 0 && url.stdout.trim()) st.remote = url.stdout.trim() | ||
| 113 | - } | ||
| 114 | - const log = await this.raw(['log', '-1', '--format=%H%x1f%s%x1f%aI']) | ||
| 115 | if (log.code === 0 && log.stdout.trim()) { | 130 | if (log.code === 0 && log.stdout.trim()) { |
| 116 | const [hash, message, date] = log.stdout.trim().split('\x1f') | 131 | const [hash, message, date] = log.stdout.trim().split('\x1f') |
| 117 | if (hash) st.lastCommit = { hash, message: message ?? '', date: date ?? '' } | 132 | if (hash) st.lastCommit = { hash, message: message ?? '', date: date ?? '' } |
| @@ -125,6 +140,31 @@ class GitCatalog implements CatalogGit { | |||
| 125 | await this.git(['init', '--initial-branch=main']) | 140 | await this.git(['init', '--initial-branch=main']) |
| 126 | } | 141 | } |
| 127 | 142 | ||
| 143 | + async clone(url: string): Promise<GitStatus> { | ||
| 144 | + const target = url.trim() | ||
| 145 | + if (!target) throw new Error('Remote URL is empty') | ||
| 146 | + if (target.startsWith('-')) throw new Error('Invalid remote URL') | ||
| 147 | + if (await this.isRepo()) throw new Error(`Already a git repository: ${this.root}`) | ||
| 148 | + let existed = true | ||
| 149 | + try { | ||
| 150 | + if ((await readdir(this.root)).length > 0) throw new Error(`The catalog folder is not empty: ${this.root}`) | ||
| 151 | + } catch (e) { | ||
| 152 | + if ((e as NodeJS.ErrnoException).code !== 'ENOENT') throw e | ||
| 153 | + existed = false | ||
| 154 | + } | ||
| 155 | + await mkdir(this.root, { recursive: true }) | ||
| 156 | + const args = ['clone', '--quiet', '--', target, '.'] | ||
| 157 | + const r = await this.raw(args) | ||
| 158 | + if (r.code !== 0) { | ||
| 159 | + // git removes what it created, but leave the folder exactly as we found it. | ||
| 160 | + if (existed) { | ||
| 161 | + for (const name of await readdir(this.root)) await rm(path.join(this.root, name), { recursive: true, force: true }) | ||
| 162 | + } else await rm(this.root, { recursive: true, force: true }) | ||
| 163 | + throw failure(args, r) | ||
| 164 | + } | ||
| 165 | + return this.status() | ||
| 166 | + } | ||
| 167 | + | ||
| 128 | async setRemote(url: string): Promise<void> { | 168 | async setRemote(url: string): Promise<void> { |
| 129 | await this.requireRepo() | 169 | await this.requireRepo() |
| 130 | if (!url.trim()) throw new Error('Remote URL is empty') | 170 | if (!url.trim()) throw new Error('Remote URL is empty') |
| @@ -136,10 +176,13 @@ class GitCatalog implements CatalogGit { | |||
| 136 | async commitAll(message: string): Promise<string | undefined> { | 176 | async commitAll(message: string): Promise<string | undefined> { |
| 137 | await this.requireRepo() | 177 | await this.requireRepo() |
| 138 | await this.git(['add', '-A']) | 178 | await this.git(['add', '-A']) |
| 139 | - const s = await this.git(['status', '--porcelain']) | 179 | + const [s, merge, identity] = await Promise.all([ |
| 140 | - const merging = (await this.raw(['rev-parse', '-q', '--verify', 'MERGE_HEAD'])).code === 0 | 180 | + this.git(['status', '--porcelain']), |
| 141 | - if (!s.trim() && !merging) return undefined | 181 | + this.raw(['rev-parse', '-q', '--verify', 'MERGE_HEAD']), |
| 142 | - await this.git([...(await this.identityArgs()), 'commit', '-m', message || 'skilly sync']) | 182 | + this.identityArgs(), |
| 183 | + ]) | ||
| 184 | + if (!s.trim() && merge.code !== 0) return undefined | ||
| 185 | + await this.git([...identity, 'commit', '-m', message || 'skilly sync']) | ||
| 143 | return (await this.currentCommit()) ?? undefined | 186 | return (await this.currentCommit()) ?? undefined |
| 144 | } | 187 | } |
| 145 | 188 | ||
modified
packages/core/src/contracts.ts +5 -0 | @@ -57,6 +57,11 @@ export type OpenCatalog = (root: string) => CatalogStore | ||
| 57 | 57 | export interface CatalogGit { |
| 58 | 58 | status(): Promise<GitStatus> |
| 59 | 59 | init(): Promise<void> |
| 60 | + /** | |
| 61 | + * Clones `url` into the catalog folder, which must be absent or empty (no `git init` first). Returns the status. | |
| 62 | + * Added after the first release: callers that only know the older contract simply never call it. | |
| 63 | + */ | |
| 64 | + clone(url: string): Promise<GitStatus> | |
| 60 | 65 | setRemote(url: string): Promise<void> |
| 61 | 66 | /** Stages everything and commits; no-op when clean. Returns the commit hash, if any. */ |
| 62 | 67 | commitAll(message: string): Promise<string | undefined> |
| @@ -57,6 +57,11 @@ export type OpenCatalog = (root: string) => CatalogStore | |||
| 57 | export interface CatalogGit { | 57 | export interface CatalogGit { |
| 58 | status(): Promise<GitStatus> | 58 | status(): Promise<GitStatus> |
| 59 | init(): Promise<void> | 59 | init(): Promise<void> |
| 60 | + /** | ||
| 61 | + * Clones `url` into the catalog folder, which must be absent or empty (no `git init` first). Returns the status. | ||
| 62 | + * Added after the first release: callers that only know the older contract simply never call it. | ||
| 63 | + */ | ||
| 64 | + clone(url: string): Promise<GitStatus> | ||
| 60 | setRemote(url: string): Promise<void> | 65 | setRemote(url: string): Promise<void> |
| 61 | /** Stages everything and commits; no-op when clean. Returns the commit hash, if any. */ | 66 | /** Stages everything and commits; no-op when clean. Returns the commit hash, if any. */ |
| 62 | commitAll(message: string): Promise<string | undefined> | 67 | commitAll(message: string): Promise<string | undefined> |
modified
packages/core/test/catalog-git.test.ts +30 -0 | @@ -146,4 +146,34 @@ describe('catalog git', { timeout: 60_000 }, () => { | ||
| 146 | 146 | await expect(g.pull()).rejects.toThrow(/does-not-exist|repository/i) |
| 147 | 147 | await g.setRemote(remote) |
| 148 | 148 | }) |
| 149 | + | |
| 150 | + it('clones a remote into an absent or empty folder, keeping line endings', async () => { | |
| 151 | + const absent = path.join(tmp, 'clone-absent', 'catalog') | |
| 152 | + const st = await openCatalogGit(absent).clone(remote) | |
| 153 | + expect(st.isRepo).toBe(true) | |
| 154 | + expect(st.remote).toBe(remote) | |
| 155 | + expect(st.branch).toBe('main') | |
| 156 | + expect(await readFile(path.join(absent, 'skills', 'x', 'SKILL.md'), 'utf8')).toBe('one\n') | |
| 157 | + | |
| 158 | + const empty = path.join(tmp, 'clone-empty') | |
| 159 | + await mkdir(empty) | |
| 160 | + expect((await openCatalogGit(empty).clone(remote)).lastCommit).toBeDefined() | |
| 161 | + }) | |
| 162 | + | |
| 163 | + it('refuses to clone into a non-empty folder or a repo, and cleans up after a failure', async () => { | |
| 164 | + const full = path.join(tmp, 'clone-full') | |
| 165 | + await mkdir(full) | |
| 166 | + await writeFile(path.join(full, 'keep.txt'), 'x') | |
| 167 | + await expect(openCatalogGit(full).clone(remote)).rejects.toThrow(/not empty/) | |
| 168 | + await expect(openCatalogGit(a).clone(remote)).rejects.toThrow(/Already a git repository/) | |
| 169 | + await expect(openCatalogGit(path.join(tmp, 'clone-opt')).clone('--upload-pack=x')).rejects.toThrow(/Invalid/) | |
| 170 | + | |
| 171 | + const failed = path.join(tmp, 'clone-failed') | |
| 172 | + await expect(openCatalogGit(failed).clone(path.join(tmp, 'nope.git'))).rejects.toThrow() | |
| 173 | + await expect(readFile(failed)).rejects.toThrow() // folder removed again | |
| 174 | + const failedEmpty = path.join(tmp, 'clone-failed-empty') | |
| 175 | + await mkdir(failedEmpty) | |
| 176 | + await expect(openCatalogGit(failedEmpty).clone(path.join(tmp, 'nope.git'))).rejects.toThrow() | |
| 177 | + await expect(readFile(path.join(failedEmpty, '.git', 'HEAD'))).rejects.toThrow() | |
| 178 | + }) | |
| 149 | 179 | }) |
| @@ -146,4 +146,34 @@ describe('catalog git', { timeout: 60_000 }, () => { | |||
| 146 | await expect(g.pull()).rejects.toThrow(/does-not-exist|repository/i) | 146 | await expect(g.pull()).rejects.toThrow(/does-not-exist|repository/i) |
| 147 | await g.setRemote(remote) | 147 | await g.setRemote(remote) |
| 148 | }) | 148 | }) |
| 149 | + | ||
| 150 | + it('clones a remote into an absent or empty folder, keeping line endings', async () => { | ||
| 151 | + const absent = path.join(tmp, 'clone-absent', 'catalog') | ||
| 152 | + const st = await openCatalogGit(absent).clone(remote) | ||
| 153 | + expect(st.isRepo).toBe(true) | ||
| 154 | + expect(st.remote).toBe(remote) | ||
| 155 | + expect(st.branch).toBe('main') | ||
| 156 | + expect(await readFile(path.join(absent, 'skills', 'x', 'SKILL.md'), 'utf8')).toBe('one\n') | ||
| 157 | + | ||
| 158 | + const empty = path.join(tmp, 'clone-empty') | ||
| 159 | + await mkdir(empty) | ||
| 160 | + expect((await openCatalogGit(empty).clone(remote)).lastCommit).toBeDefined() | ||
| 161 | + }) | ||
| 162 | + | ||
| 163 | + it('refuses to clone into a non-empty folder or a repo, and cleans up after a failure', async () => { | ||
| 164 | + const full = path.join(tmp, 'clone-full') | ||
| 165 | + await mkdir(full) | ||
| 166 | + await writeFile(path.join(full, 'keep.txt'), 'x') | ||
| 167 | + await expect(openCatalogGit(full).clone(remote)).rejects.toThrow(/not empty/) | ||
| 168 | + await expect(openCatalogGit(a).clone(remote)).rejects.toThrow(/Already a git repository/) | ||
| 169 | + await expect(openCatalogGit(path.join(tmp, 'clone-opt')).clone('--upload-pack=x')).rejects.toThrow(/Invalid/) | ||
| 170 | + | ||
| 171 | + const failed = path.join(tmp, 'clone-failed') | ||
| 172 | + await expect(openCatalogGit(failed).clone(path.join(tmp, 'nope.git'))).rejects.toThrow() | ||
| 173 | + await expect(readFile(failed)).rejects.toThrow() // folder removed again | ||
| 174 | + const failedEmpty = path.join(tmp, 'clone-failed-empty') | ||
| 175 | + await mkdir(failedEmpty) | ||
| 176 | + await expect(openCatalogGit(failedEmpty).clone(path.join(tmp, 'nope.git'))).rejects.toThrow() | ||
| 177 | + await expect(readFile(path.join(failedEmpty, '.git', 'HEAD'))).rejects.toThrow() | ||
| 178 | + }) | ||
| 149 | }) | 179 | }) |
modified
packages/server/src/catalog-routes.ts +20 -0 | @@ -164,6 +164,26 @@ export function registerCatalogRoutes(app: Hono, ctx: AppContext): void { | ||
| 164 | 164 | return c.json(await catalogResponse()) |
| 165 | 165 | }) |
| 166 | 166 | |
| 167 | + app.post('/api/catalog/clone', async (c) => { | |
| 168 | + const body = await readJsonBody(c) | |
| 169 | + const url = requireUrlLike(body.url, 'url') | |
| 170 | + const current = await ctx.settings() | |
| 171 | + let target = current.catalogPath | |
| 172 | + if (body.path !== undefined) { | |
| 173 | + if (typeof body.path !== 'string' || !path.isAbsolute(body.path)) { | |
| 174 | + throw new HttpError(400, '"path" must be an absolute path') | |
| 175 | + } | |
| 176 | + target = path.resolve(body.path) | |
| 177 | + } | |
| 178 | + await ctx.openCatalogGit(target).clone(url) | |
| 179 | + if (target !== current.catalogPath) await ctx.store.setSettings({ ...current, catalogPath: target }) | |
| 180 | + // A fresh, empty remote has no catalog layout yet: create it so the UI can start working right away. | |
| 181 | + const catalog = await ctx.catalog() | |
| 182 | + if (!(await catalog.exists())) await catalog.init() | |
| 183 | + ctx.catalogChanged() | |
| 184 | + return c.json(await catalogResponse()) | |
| 185 | + }) | |
| 186 | + | |
| 167 | 187 | app.get('/api/catalog/items/:type/:name', async (c) => { |
| 168 | 188 | const ref: CatalogRef = { type: parseType(c.req.param('type')), name: c.req.param('name') } |
| 169 | 189 | const detail = await (await ctx.catalog()).getItem(ref) |
| @@ -164,6 +164,26 @@ export function registerCatalogRoutes(app: Hono, ctx: AppContext): void { | |||
| 164 | return c.json(await catalogResponse()) | 164 | return c.json(await catalogResponse()) |
| 165 | }) | 165 | }) |
| 166 | 166 | ||
| 167 | + app.post('/api/catalog/clone', async (c) => { | ||
| 168 | + const body = await readJsonBody(c) | ||
| 169 | + const url = requireUrlLike(body.url, 'url') | ||
| 170 | + const current = await ctx.settings() | ||
| 171 | + let target = current.catalogPath | ||
| 172 | + if (body.path !== undefined) { | ||
| 173 | + if (typeof body.path !== 'string' || !path.isAbsolute(body.path)) { | ||
| 174 | + throw new HttpError(400, '"path" must be an absolute path') | ||
| 175 | + } | ||
| 176 | + target = path.resolve(body.path) | ||
| 177 | + } | ||
| 178 | + await ctx.openCatalogGit(target).clone(url) | ||
| 179 | + if (target !== current.catalogPath) await ctx.store.setSettings({ ...current, catalogPath: target }) | ||
| 180 | + // A fresh, empty remote has no catalog layout yet: create it so the UI can start working right away. | ||
| 181 | + const catalog = await ctx.catalog() | ||
| 182 | + if (!(await catalog.exists())) await catalog.init() | ||
| 183 | + ctx.catalogChanged() | ||
| 184 | + return c.json(await catalogResponse()) | ||
| 185 | + }) | ||
| 186 | + | ||
| 167 | app.get('/api/catalog/items/:type/:name', async (c) => { | 187 | app.get('/api/catalog/items/:type/:name', async (c) => { |
| 168 | const ref: CatalogRef = { type: parseType(c.req.param('type')), name: c.req.param('name') } | 188 | const ref: CatalogRef = { type: parseType(c.req.param('type')), name: c.req.param('name') } |
| 169 | const detail = await (await ctx.catalog()).getItem(ref) | 189 | const detail = await (await ctx.catalog()).getItem(ref) |
modified
packages/server/src/fslist.ts +75 -1 | @@ -2,7 +2,7 @@ import { existsSync } from 'node:fs' | ||
| 2 | 2 | import { readdir, stat } from 'node:fs/promises' |
| 3 | 3 | import os from 'node:os' |
| 4 | 4 | import path from 'node:path' |
| 5 | -import type { FsEntry, FsListing } from '@skilly/core' | |
| 5 | +import type { DiscoveredProject, FsDiscovery, FsEntry, FsListing } from '@skilly/core' | |
| 6 | 6 | |
| 7 | 7 | const EXCLUDED = new Set(['node_modules', '$recycle.bin', 'system volume information']) |
| 8 | 8 | const AGENT_MARKERS = ['.claude', '.agents', '.codex', 'AGENTS.md', 'CLAUDE.md', '.mcp.json'] |
| @@ -57,3 +57,77 @@ export async function listDirectory(dir: string): Promise<FsListing> { | ||
| 57 | 57 | const parentDir = path.dirname(abs) |
| 58 | 58 | return { path: abs, parent: parentDir === abs ? null : parentDir, entries } |
| 59 | 59 | } |
| 60 | + | |
| 61 | +/** Folder names never descended into while discovering projects (build output, system and package folders). */ | |
| 62 | +const DISCOVER_SKIP = new Set([ | |
| 63 | + ...EXCLUDED, | |
| 64 | + 'windows', | |
| 65 | + 'program files', | |
| 66 | + 'program files (x86)', | |
| 67 | + 'programdata', | |
| 68 | + 'appdata', | |
| 69 | + 'library', | |
| 70 | + 'system', | |
| 71 | + 'proc', | |
| 72 | + 'sys', | |
| 73 | + 'dist', | |
| 74 | + 'build', | |
| 75 | + 'target', | |
| 76 | + 'vendor', | |
| 77 | +]) | |
| 78 | + | |
| 79 | +export interface DiscoverOptions { | |
| 80 | + /** Levels below the root to inspect (1 = direct children). Clamped to 1..4. */ | |
| 81 | + depth?: number | |
| 82 | + /** Stops after this many projects (default 100). */ | |
| 83 | + maxResults?: number | |
| 84 | + /** Stops after this delay (default 4000 ms). */ | |
| 85 | + timeoutMs?: number | |
| 86 | +} | |
| 87 | + | |
| 88 | +/** | |
| 89 | + * Breadth-first search of folders with agent configuration under `root`. Hidden folders, symlinks and system folders | |
| 90 | + * are skipped, a folder with agent config is not searched further, and the search is capped in results and time. | |
| 91 | + * The root itself is never reported. | |
| 92 | + */ | |
| 93 | +export async function discoverProjects(root: string, options: DiscoverOptions = {}): Promise<FsDiscovery> { | |
| 94 | + const abs = path.resolve(root) | |
| 95 | + const st = await stat(abs) | |
| 96 | + if (!st.isDirectory()) throw Object.assign(new Error('Not a directory'), { code: 'ENOTDIR' }) | |
| 97 | + const depth = Math.min(4, Math.max(1, Math.trunc(options.depth ?? 2) || 2)) | |
| 98 | + const maxResults = options.maxResults ?? 100 | |
| 99 | + const deadline = Date.now() + (options.timeoutMs ?? 4000) | |
| 100 | + const entries: DiscoveredProject[] = [] | |
| 101 | + let truncated = false | |
| 102 | + let level = [abs] | |
| 103 | + for (let d = 1; d <= depth && level.length > 0 && !truncated; d++) { | |
| 104 | + const next: string[] = [] | |
| 105 | + for (const dir of level) { | |
| 106 | + if (Date.now() > deadline || entries.length >= maxResults) { | |
| 107 | + truncated = true | |
| 108 | + break | |
| 109 | + } | |
| 110 | + let dirents | |
| 111 | + try { | |
| 112 | + dirents = await readdir(dir, { withFileTypes: true }) | |
| 113 | + } catch { | |
| 114 | + continue // unreadable | |
| 115 | + } | |
| 116 | + dirents.sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase())) | |
| 117 | + for (const e of dirents) { | |
| 118 | + if (!e.isDirectory() || e.name.startsWith('.') || DISCOVER_SKIP.has(e.name.toLowerCase())) continue | |
| 119 | + const full = path.join(dir, e.name) | |
| 120 | + const markers = AGENT_MARKERS.filter((m) => existsSync(path.join(full, m))) | |
| 121 | + if (markers.length > 0) { | |
| 122 | + if (entries.length >= maxResults) { | |
| 123 | + truncated = true | |
| 124 | + break | |
| 125 | + } | |
| 126 | + entries.push({ name: e.name, path: full, hasAgentConfig: true, markers }) | |
| 127 | + } else next.push(full) | |
| 128 | + } | |
| 129 | + } | |
| 130 | + level = next | |
| 131 | + } | |
| 132 | + return { root: abs, depth, entries, truncated } | |
| 133 | +} | |
| @@ -2,7 +2,7 @@ import { existsSync } from 'node:fs' | |||
| 2 | import { readdir, stat } from 'node:fs/promises' | 2 | import { readdir, stat } from 'node:fs/promises' |
| 3 | import os from 'node:os' | 3 | import os from 'node:os' |
| 4 | import path from 'node:path' | 4 | import path from 'node:path' |
| 5 | -import type { FsEntry, FsListing } from '@skilly/core' | 5 | +import type { DiscoveredProject, FsDiscovery, FsEntry, FsListing } from '@skilly/core' |
| 6 | 6 | ||
| 7 | const EXCLUDED = new Set(['node_modules', '$recycle.bin', 'system volume information']) | 7 | const EXCLUDED = new Set(['node_modules', '$recycle.bin', 'system volume information']) |
| 8 | const AGENT_MARKERS = ['.claude', '.agents', '.codex', 'AGENTS.md', 'CLAUDE.md', '.mcp.json'] | 8 | const AGENT_MARKERS = ['.claude', '.agents', '.codex', 'AGENTS.md', 'CLAUDE.md', '.mcp.json'] |
| @@ -57,3 +57,77 @@ export async function listDirectory(dir: string): Promise<FsListing> { | |||
| 57 | const parentDir = path.dirname(abs) | 57 | const parentDir = path.dirname(abs) |
| 58 | return { path: abs, parent: parentDir === abs ? null : parentDir, entries } | 58 | return { path: abs, parent: parentDir === abs ? null : parentDir, entries } |
| 59 | } | 59 | } |
| 60 | + | ||
| 61 | +/** Folder names never descended into while discovering projects (build output, system and package folders). */ | ||
| 62 | +const DISCOVER_SKIP = new Set([ | ||
| 63 | + ...EXCLUDED, | ||
| 64 | + 'windows', | ||
| 65 | + 'program files', | ||
| 66 | + 'program files (x86)', | ||
| 67 | + 'programdata', | ||
| 68 | + 'appdata', | ||
| 69 | + 'library', | ||
| 70 | + 'system', | ||
| 71 | + 'proc', | ||
| 72 | + 'sys', | ||
| 73 | + 'dist', | ||
| 74 | + 'build', | ||
| 75 | + 'target', | ||
| 76 | + 'vendor', | ||
| 77 | +]) | ||
| 78 | + | ||
| 79 | +export interface DiscoverOptions { | ||
| 80 | + /** Levels below the root to inspect (1 = direct children). Clamped to 1..4. */ | ||
| 81 | + depth?: number | ||
| 82 | + /** Stops after this many projects (default 100). */ | ||
| 83 | + maxResults?: number | ||
| 84 | + /** Stops after this delay (default 4000 ms). */ | ||
| 85 | + timeoutMs?: number | ||
| 86 | +} | ||
| 87 | + | ||
| 88 | +/** | ||
| 89 | + * Breadth-first search of folders with agent configuration under `root`. Hidden folders, symlinks and system folders | ||
| 90 | + * are skipped, a folder with agent config is not searched further, and the search is capped in results and time. | ||
| 91 | + * The root itself is never reported. | ||
| 92 | + */ | ||
| 93 | +export async function discoverProjects(root: string, options: DiscoverOptions = {}): Promise<FsDiscovery> { | ||
| 94 | + const abs = path.resolve(root) | ||
| 95 | + const st = await stat(abs) | ||
| 96 | + if (!st.isDirectory()) throw Object.assign(new Error('Not a directory'), { code: 'ENOTDIR' }) | ||
| 97 | + const depth = Math.min(4, Math.max(1, Math.trunc(options.depth ?? 2) || 2)) | ||
| 98 | + const maxResults = options.maxResults ?? 100 | ||
| 99 | + const deadline = Date.now() + (options.timeoutMs ?? 4000) | ||
| 100 | + const entries: DiscoveredProject[] = [] | ||
| 101 | + let truncated = false | ||
| 102 | + let level = [abs] | ||
| 103 | + for (let d = 1; d <= depth && level.length > 0 && !truncated; d++) { | ||
| 104 | + const next: string[] = [] | ||
| 105 | + for (const dir of level) { | ||
| 106 | + if (Date.now() > deadline || entries.length >= maxResults) { | ||
| 107 | + truncated = true | ||
| 108 | + break | ||
| 109 | + } | ||
| 110 | + let dirents | ||
| 111 | + try { | ||
| 112 | + dirents = await readdir(dir, { withFileTypes: true }) | ||
| 113 | + } catch { | ||
| 114 | + continue // unreadable | ||
| 115 | + } | ||
| 116 | + dirents.sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase())) | ||
| 117 | + for (const e of dirents) { | ||
| 118 | + if (!e.isDirectory() || e.name.startsWith('.') || DISCOVER_SKIP.has(e.name.toLowerCase())) continue | ||
| 119 | + const full = path.join(dir, e.name) | ||
| 120 | + const markers = AGENT_MARKERS.filter((m) => existsSync(path.join(full, m))) | ||
| 121 | + if (markers.length > 0) { | ||
| 122 | + if (entries.length >= maxResults) { | ||
| 123 | + truncated = true | ||
| 124 | + break | ||
| 125 | + } | ||
| 126 | + entries.push({ name: e.name, path: full, hasAgentConfig: true, markers }) | ||
| 127 | + } else next.push(full) | ||
| 128 | + } | ||
| 129 | + } | ||
| 130 | + level = next | ||
| 131 | + } | ||
| 132 | + return { root: abs, depth, entries, truncated } | ||
| 133 | +} | ||
modified
packages/server/src/index.ts +17 -1 | @@ -31,7 +31,7 @@ import { HttpError, guardGitConflicts, isDirectory, makeScopeRef, type AppContex | ||
| 31 | 31 | import { registerMarketplaceRoutes } from './marketplace-routes.ts' |
| 32 | 32 | import { registerScopeRoutes } from './scope-routes.ts' |
| 33 | 33 | import { FileAccessError, isInside, readAllowedFile } from './files.ts' |
| 34 | -import { listDirectory, listRoots } from './fslist.ts' | |
| 34 | +import { discoverProjects, listDirectory, listRoots } from './fslist.ts' | |
| 35 | 35 | import { ProjectStore, type StoredProject } from './store.ts' |
| 36 | 36 | |
| 37 | 37 | export const SERVER_VERSION = '0.0.0' |
| @@ -313,6 +313,22 @@ export function createAppHandle(options: AppOptions): AppHandle { | ||
| 313 | 313 | } |
| 314 | 314 | }) |
| 315 | 315 | |
| 316 | + app.get('/api/fs/discover', async (c) => { | |
| 317 | + const root = c.req.query('root') | |
| 318 | + if (!root) return c.json(err('Missing "root"'), 400) | |
| 319 | + if (!path.isAbsolute(root)) return c.json(err('Path must be absolute'), 400) | |
| 320 | + const depthRaw = c.req.query('depth') | |
| 321 | + const depth = depthRaw === undefined ? 2 : Number(depthRaw) | |
| 322 | + if (!Number.isInteger(depth) || depth < 1 || depth > 4) return c.json(err('"depth" must be an integer between 1 and 4'), 400) | |
| 323 | + try { | |
| 324 | + return c.json(await discoverProjects(root, { depth })) | |
| 325 | + } catch (e) { | |
| 326 | + const code = (e as NodeJS.ErrnoException).code | |
| 327 | + if (code === 'ENOENT' || code === 'ENOTDIR') return c.json(err('Not a directory'), 404) | |
| 328 | + return c.json(err('Cannot read directory'), 403) | |
| 329 | + } | |
| 330 | + }) | |
| 331 | + | |
| 316 | 332 | app.get('/api/files', async (c) => { |
| 317 | 333 | const p = c.req.query('path') |
| 318 | 334 | if (!p) return c.json(err('Missing "path"'), 400) |
| @@ -31,7 +31,7 @@ import { HttpError, guardGitConflicts, isDirectory, makeScopeRef, type AppContex | |||
| 31 | import { registerMarketplaceRoutes } from './marketplace-routes.ts' | 31 | import { registerMarketplaceRoutes } from './marketplace-routes.ts' |
| 32 | import { registerScopeRoutes } from './scope-routes.ts' | 32 | import { registerScopeRoutes } from './scope-routes.ts' |
| 33 | import { FileAccessError, isInside, readAllowedFile } from './files.ts' | 33 | import { FileAccessError, isInside, readAllowedFile } from './files.ts' |
| 34 | -import { listDirectory, listRoots } from './fslist.ts' | 34 | +import { discoverProjects, listDirectory, listRoots } from './fslist.ts' |
| 35 | import { ProjectStore, type StoredProject } from './store.ts' | 35 | import { ProjectStore, type StoredProject } from './store.ts' |
| 36 | 36 | ||
| 37 | export const SERVER_VERSION = '0.0.0' | 37 | export const SERVER_VERSION = '0.0.0' |
| @@ -313,6 +313,22 @@ export function createAppHandle(options: AppOptions): AppHandle { | |||
| 313 | } | 313 | } |
| 314 | }) | 314 | }) |
| 315 | 315 | ||
| 316 | + app.get('/api/fs/discover', async (c) => { | ||
| 317 | + const root = c.req.query('root') | ||
| 318 | + if (!root) return c.json(err('Missing "root"'), 400) | ||
| 319 | + if (!path.isAbsolute(root)) return c.json(err('Path must be absolute'), 400) | ||
| 320 | + const depthRaw = c.req.query('depth') | ||
| 321 | + const depth = depthRaw === undefined ? 2 : Number(depthRaw) | ||
| 322 | + if (!Number.isInteger(depth) || depth < 1 || depth > 4) return c.json(err('"depth" must be an integer between 1 and 4'), 400) | ||
| 323 | + try { | ||
| 324 | + return c.json(await discoverProjects(root, { depth })) | ||
| 325 | + } catch (e) { | ||
| 326 | + const code = (e as NodeJS.ErrnoException).code | ||
| 327 | + if (code === 'ENOENT' || code === 'ENOTDIR') return c.json(err('Not a directory'), 404) | ||
| 328 | + return c.json(err('Cannot read directory'), 403) | ||
| 329 | + } | ||
| 330 | + }) | ||
| 331 | + | ||
| 316 | app.get('/api/files', async (c) => { | 332 | app.get('/api/files', async (c) => { |
| 317 | const p = c.req.query('path') | 333 | const p = c.req.query('path') |
| 318 | if (!p) return c.json(err('Missing "path"'), 400) | 334 | if (!p) return c.json(err('Missing "path"'), 400) |
modified
packages/server/test/catalog.test.ts +4 -3 | @@ -132,7 +132,8 @@ describe('catalog', () => { | ||
| 132 | 132 | }) |
| 133 | 133 | }) |
| 134 | 134 | |
| 135 | -describe('catalog git', () => { | |
| 135 | +// Each request spawns several git processes (50-150 ms each on Windows): allow for a loaded machine. | |
| 136 | +describe('catalog git', { timeout: 30_000 }, () => { | |
| 136 | 137 | let remote: string |
| 137 | 138 | |
| 138 | 139 | beforeEach(async () => { |
| @@ -179,7 +180,7 @@ describe('catalog git', () => { | ||
| 179 | 180 | it('auto-syncs after writes when enabled (push with a remote, commit without)', async () => { |
| 180 | 181 | await env.send('PUT', '/api/settings', { autoSync: true }) |
| 181 | 182 | await env.send('PUT', '/api/catalog/items/skill/auto1', { content: skillMd('auto1') }) |
| 182 | - await waitFor(() => git(env.catalogDir, 'status', '--porcelain').trim() === '') | |
| 183 | + await waitFor(() => git(env.catalogDir, '--no-optional-locks', 'status', '--porcelain').trim() === '') | |
| 183 | 184 | expect(git(env.catalogDir, 'log', '--oneline')).toContain('skilly') |
| 184 | 185 | |
| 185 | 186 | await env.send('POST', '/api/catalog/git/remote', { url: remote }) |
| @@ -191,7 +192,7 @@ describe('catalog git', () => { | ||
| 191 | 192 | it('does not commit when autoSync is off', async () => { |
| 192 | 193 | await env.send('PUT', '/api/catalog/items/skill/manual', { content: skillMd('manual') }) |
| 193 | 194 | await new Promise((r) => setTimeout(r, 300)) |
| 194 | - expect(git(env.catalogDir, 'status', '--porcelain').trim()).not.toBe('') | |
| 195 | + expect(git(env.catalogDir, '--no-optional-locks', 'status', '--porcelain').trim()).not.toBe('') | |
| 195 | 196 | }) |
| 196 | 197 | }) |
| 197 | 198 | |
| @@ -132,7 +132,8 @@ describe('catalog', () => { | |||
| 132 | }) | 132 | }) |
| 133 | }) | 133 | }) |
| 134 | 134 | ||
| 135 | -describe('catalog git', () => { | 135 | +// Each request spawns several git processes (50-150 ms each on Windows): allow for a loaded machine. |
| 136 | +describe('catalog git', { timeout: 30_000 }, () => { | ||
| 136 | let remote: string | 137 | let remote: string |
| 137 | 138 | ||
| 138 | beforeEach(async () => { | 139 | beforeEach(async () => { |
| @@ -179,7 +180,7 @@ describe('catalog git', () => { | |||
| 179 | it('auto-syncs after writes when enabled (push with a remote, commit without)', async () => { | 180 | it('auto-syncs after writes when enabled (push with a remote, commit without)', async () => { |
| 180 | await env.send('PUT', '/api/settings', { autoSync: true }) | 181 | await env.send('PUT', '/api/settings', { autoSync: true }) |
| 181 | await env.send('PUT', '/api/catalog/items/skill/auto1', { content: skillMd('auto1') }) | 182 | await env.send('PUT', '/api/catalog/items/skill/auto1', { content: skillMd('auto1') }) |
| 182 | - await waitFor(() => git(env.catalogDir, 'status', '--porcelain').trim() === '') | 183 | + await waitFor(() => git(env.catalogDir, '--no-optional-locks', 'status', '--porcelain').trim() === '') |
| 183 | expect(git(env.catalogDir, 'log', '--oneline')).toContain('skilly') | 184 | expect(git(env.catalogDir, 'log', '--oneline')).toContain('skilly') |
| 184 | 185 | ||
| 185 | await env.send('POST', '/api/catalog/git/remote', { url: remote }) | 186 | await env.send('POST', '/api/catalog/git/remote', { url: remote }) |
| @@ -191,7 +192,7 @@ describe('catalog git', () => { | |||
| 191 | it('does not commit when autoSync is off', async () => { | 192 | it('does not commit when autoSync is off', async () => { |
| 192 | await env.send('PUT', '/api/catalog/items/skill/manual', { content: skillMd('manual') }) | 193 | await env.send('PUT', '/api/catalog/items/skill/manual', { content: skillMd('manual') }) |
| 193 | await new Promise((r) => setTimeout(r, 300)) | 194 | await new Promise((r) => setTimeout(r, 300)) |
| 194 | - expect(git(env.catalogDir, 'status', '--porcelain').trim()).not.toBe('') | 195 | + expect(git(env.catalogDir, '--no-optional-locks', 'status', '--porcelain').trim()).not.toBe('') |
| 195 | }) | 196 | }) |
| 196 | }) | 197 | }) |
| 197 | 198 | ||
added
packages/server/test/onboarding.test.ts +122 -0 | new file mode 100644 | ||
| @@ -0,0 +1,122 @@ | ||
| 1 | +import { execFileSync } from 'node:child_process' | |
| 2 | +import { mkdir, readFile } from 'node:fs/promises' | |
| 3 | +import path from 'node:path' | |
| 4 | +import type { CatalogResponse, FsDiscovery, Settings } from '@skilly/core' | |
| 5 | +import { afterEach, beforeEach, describe, expect, it } from 'vitest' | |
| 6 | +import { fakeEngine, git, makeEnv, put, skillMd, type Env } from './helpers.ts' | |
| 7 | + | |
| 8 | +let env: Env | |
| 9 | + | |
| 10 | +beforeEach(async () => { | |
| 11 | + env = await makeEnv({ syncEngine: fakeEngine() }) | |
| 12 | +}) | |
| 13 | + | |
| 14 | +afterEach(() => env.cleanup()) | |
| 15 | + | |
| 16 | +describe('POST /api/catalog/clone', { timeout: 60_000 }, () => { | |
| 17 | + let remote: string | |
| 18 | + | |
| 19 | + /** A bare remote holding one skill, pushed from a scratch clone. */ | |
| 20 | + async function populatedRemote(): Promise<string> { | |
| 21 | + const bare = path.join(env.tmp, 'remote.git') | |
| 22 | + await mkdir(bare, { recursive: true }) | |
| 23 | + execFileSync('git', ['init', '--bare', '-q', '-b', 'main', bare], { stdio: 'ignore' }) | |
| 24 | + const seed = path.join(env.tmp, 'seed') | |
| 25 | + await mkdir(seed, { recursive: true }) | |
| 26 | + git(seed, 'init', '-q', '-b', 'main') | |
| 27 | + await put(path.join(seed, 'skills', 'alpha', 'SKILL.md'), skillMd('alpha')) | |
| 28 | + git(seed, 'add', '-A') | |
| 29 | + git(seed, 'commit', '-q', '-m', 'seed') | |
| 30 | + git(seed, 'push', '-q', bare, 'main') | |
| 31 | + return bare | |
| 32 | + } | |
| 33 | + | |
| 34 | + beforeEach(async () => { | |
| 35 | + remote = await populatedRemote() | |
| 36 | + }) | |
| 37 | + | |
| 38 | + it('clones into the current catalog path and returns the catalog', async () => { | |
| 39 | + const res = await env.send('POST', '/api/catalog/clone', { url: remote }) | |
| 40 | + expect(res.status).toBe(200) | |
| 41 | + const cat = (await res.json()) as CatalogResponse | |
| 42 | + expect(cat.git.isRepo).toBe(true) | |
| 43 | + expect(cat.git.remote).toBeTruthy() | |
| 44 | + expect(cat.git.lastCommit?.message).toBe('seed') | |
| 45 | + expect(cat.snapshot.exists).toBe(true) | |
| 46 | + expect(cat.snapshot.items.map((i) => i.name)).toContain('alpha') | |
| 47 | + expect(await readFile(path.join(env.catalogDir, 'skills', 'alpha', 'SKILL.md'), 'utf8')).toContain('Body of alpha') | |
| 48 | + }) | |
| 49 | + | |
| 50 | + it('clones into a given path and saves it as the catalog path', async () => { | |
| 51 | + const target = path.join(env.tmp, 'elsewhere', 'catalog') | |
| 52 | + const cat = (await (await env.send('POST', '/api/catalog/clone', { url: remote, path: target })).json()) as CatalogResponse | |
| 53 | + expect(cat.snapshot.root).toBe(target) | |
| 54 | + expect(((await (await env.req('/api/settings')).json()) as Settings).catalogPath).toBe(target) | |
| 55 | + }) | |
| 56 | + | |
| 57 | + it('accepts an empty remote and creates the catalog layout', async () => { | |
| 58 | + const empty = path.join(env.tmp, 'empty.git') | |
| 59 | + execFileSync('git', ['init', '--bare', '-q', '-b', 'main', empty], { stdio: 'ignore' }) | |
| 60 | + const cat = (await (await env.send('POST', '/api/catalog/clone', { url: empty })).json()) as CatalogResponse | |
| 61 | + expect(cat.git.isRepo).toBe(true) | |
| 62 | + expect(cat.snapshot.exists).toBe(true) | |
| 63 | + }) | |
| 64 | + | |
| 65 | + it('validates the body and refuses a non-empty target', async () => { | |
| 66 | + expect((await env.send('POST', '/api/catalog/clone', {})).status).toBe(400) | |
| 67 | + expect((await env.send('POST', '/api/catalog/clone', { url: '--upload-pack=x' })).status).toBe(400) | |
| 68 | + expect((await env.send('POST', '/api/catalog/clone', { url: remote, path: 'relative' })).status).toBe(400) | |
| 69 | + await env.send('POST', '/api/catalog/init') | |
| 70 | + const res = await env.send('POST', '/api/catalog/clone', { url: remote }) | |
| 71 | + expect(res.status).toBe(400) | |
| 72 | + expect(((await res.json()) as { error: string }).error).toMatch(/not empty|already/i) | |
| 73 | + }) | |
| 74 | + | |
| 75 | + it('reports git errors and keeps the settings untouched', async () => { | |
| 76 | + const target = path.join(env.tmp, 'never', 'catalog') | |
| 77 | + const res = await env.send('POST', '/api/catalog/clone', { url: path.join(env.tmp, 'missing.git'), path: target }) | |
| 78 | + expect(res.status).toBe(400) | |
| 79 | + expect(((await res.json()) as { error: string }).error).toBeTruthy() | |
| 80 | + expect(((await (await env.req('/api/settings')).json()) as Settings).catalogPath).toBe(env.catalogDir) | |
| 81 | + }) | |
| 82 | +}) | |
| 83 | + | |
| 84 | +describe('GET /api/fs/discover', () => { | |
| 85 | + async function discover(root: string, depth?: number): Promise<Response> { | |
| 86 | + const q = `root=${encodeURIComponent(root)}${depth === undefined ? '' : `&depth=${depth}`}` | |
| 87 | + return env.req(`/api/fs/discover?${q}`) | |
| 88 | + } | |
| 89 | + | |
| 90 | + it('finds folders with agent config, skipping hidden, node_modules and nested projects', async () => { | |
| 91 | + const root = path.join(env.tmp, 'code') | |
| 92 | + await put(path.join(root, 'app-a', 'CLAUDE.md'), '# a') | |
| 93 | + await put(path.join(root, 'app-a', 'packages', 'inner', 'AGENTS.md'), '# not reported: parent is a project') | |
| 94 | + await put(path.join(root, 'group', 'app-b', '.claude', 'settings.json'), '{}') | |
| 95 | + await put(path.join(root, 'group', 'app-b', 'AGENTS.md'), '# b') | |
| 96 | + await put(path.join(root, 'plain', 'readme.md'), 'x') | |
| 97 | + await put(path.join(root, 'node_modules', 'dep', 'CLAUDE.md'), 'x') | |
| 98 | + await put(path.join(root, '.hidden', 'CLAUDE.md'), 'x') | |
| 99 | + const res = await discover(root, 3) | |
| 100 | + expect(res.status).toBe(200) | |
| 101 | + const body = (await res.json()) as FsDiscovery | |
| 102 | + expect(body.truncated).toBe(false) | |
| 103 | + expect(body.entries.map((e) => e.name)).toEqual(['app-a', 'app-b']) | |
| 104 | + expect(body.entries[1]!.markers).toEqual(['.claude', 'AGENTS.md']) | |
| 105 | + expect(body.entries.every((e) => e.hasAgentConfig)).toBe(true) | |
| 106 | + }) | |
| 107 | + | |
| 108 | + it('honours depth and clamps nothing silently', async () => { | |
| 109 | + const root = path.join(env.tmp, 'code') | |
| 110 | + await put(path.join(root, 'group', 'deep', 'CLAUDE.md'), 'x') | |
| 111 | + expect(((await (await discover(root, 1)).json()) as FsDiscovery).entries).toEqual([]) | |
| 112 | + expect(((await (await discover(root, 2)).json()) as FsDiscovery).entries.map((e) => e.name)).toEqual(['deep']) | |
| 113 | + expect((await discover(root, 5)).status).toBe(400) | |
| 114 | + expect((await discover(root, 0)).status).toBe(400) | |
| 115 | + }) | |
| 116 | + | |
| 117 | + it('validates the root', async () => { | |
| 118 | + expect((await env.req('/api/fs/discover')).status).toBe(400) | |
| 119 | + expect((await discover('relative/path')).status).toBe(400) | |
| 120 | + expect((await discover(path.join(env.tmp, 'missing'))).status).toBe(404) | |
| 121 | + }) | |
| 122 | +}) | |
| new file mode 100644 | |||
| @@ -0,0 +1,122 @@ | |||
| 1 | +import { execFileSync } from 'node:child_process' | ||
| 2 | +import { mkdir, readFile } from 'node:fs/promises' | ||
| 3 | +import path from 'node:path' | ||
| 4 | +import type { CatalogResponse, FsDiscovery, Settings } from '@skilly/core' | ||
| 5 | +import { afterEach, beforeEach, describe, expect, it } from 'vitest' | ||
| 6 | +import { fakeEngine, git, makeEnv, put, skillMd, type Env } from './helpers.ts' | ||
| 7 | + | ||
| 8 | +let env: Env | ||
| 9 | + | ||
| 10 | +beforeEach(async () => { | ||
| 11 | + env = await makeEnv({ syncEngine: fakeEngine() }) | ||
| 12 | +}) | ||
| 13 | + | ||
| 14 | +afterEach(() => env.cleanup()) | ||
| 15 | + | ||
| 16 | +describe('POST /api/catalog/clone', { timeout: 60_000 }, () => { | ||
| 17 | + let remote: string | ||
| 18 | + | ||
| 19 | + /** A bare remote holding one skill, pushed from a scratch clone. */ | ||
| 20 | + async function populatedRemote(): Promise<string> { | ||
| 21 | + const bare = path.join(env.tmp, 'remote.git') | ||
| 22 | + await mkdir(bare, { recursive: true }) | ||
| 23 | + execFileSync('git', ['init', '--bare', '-q', '-b', 'main', bare], { stdio: 'ignore' }) | ||
| 24 | + const seed = path.join(env.tmp, 'seed') | ||
| 25 | + await mkdir(seed, { recursive: true }) | ||
| 26 | + git(seed, 'init', '-q', '-b', 'main') | ||
| 27 | + await put(path.join(seed, 'skills', 'alpha', 'SKILL.md'), skillMd('alpha')) | ||
| 28 | + git(seed, 'add', '-A') | ||
| 29 | + git(seed, 'commit', '-q', '-m', 'seed') | ||
| 30 | + git(seed, 'push', '-q', bare, 'main') | ||
| 31 | + return bare | ||
| 32 | + } | ||
| 33 | + | ||
| 34 | + beforeEach(async () => { | ||
| 35 | + remote = await populatedRemote() | ||
| 36 | + }) | ||
| 37 | + | ||
| 38 | + it('clones into the current catalog path and returns the catalog', async () => { | ||
| 39 | + const res = await env.send('POST', '/api/catalog/clone', { url: remote }) | ||
| 40 | + expect(res.status).toBe(200) | ||
| 41 | + const cat = (await res.json()) as CatalogResponse | ||
| 42 | + expect(cat.git.isRepo).toBe(true) | ||
| 43 | + expect(cat.git.remote).toBeTruthy() | ||
| 44 | + expect(cat.git.lastCommit?.message).toBe('seed') | ||
| 45 | + expect(cat.snapshot.exists).toBe(true) | ||
| 46 | + expect(cat.snapshot.items.map((i) => i.name)).toContain('alpha') | ||
| 47 | + expect(await readFile(path.join(env.catalogDir, 'skills', 'alpha', 'SKILL.md'), 'utf8')).toContain('Body of alpha') | ||
| 48 | + }) | ||
| 49 | + | ||
| 50 | + it('clones into a given path and saves it as the catalog path', async () => { | ||
| 51 | + const target = path.join(env.tmp, 'elsewhere', 'catalog') | ||
| 52 | + const cat = (await (await env.send('POST', '/api/catalog/clone', { url: remote, path: target })).json()) as CatalogResponse | ||
| 53 | + expect(cat.snapshot.root).toBe(target) | ||
| 54 | + expect(((await (await env.req('/api/settings')).json()) as Settings).catalogPath).toBe(target) | ||
| 55 | + }) | ||
| 56 | + | ||
| 57 | + it('accepts an empty remote and creates the catalog layout', async () => { | ||
| 58 | + const empty = path.join(env.tmp, 'empty.git') | ||
| 59 | + execFileSync('git', ['init', '--bare', '-q', '-b', 'main', empty], { stdio: 'ignore' }) | ||
| 60 | + const cat = (await (await env.send('POST', '/api/catalog/clone', { url: empty })).json()) as CatalogResponse | ||
| 61 | + expect(cat.git.isRepo).toBe(true) | ||
| 62 | + expect(cat.snapshot.exists).toBe(true) | ||
| 63 | + }) | ||
| 64 | + | ||
| 65 | + it('validates the body and refuses a non-empty target', async () => { | ||
| 66 | + expect((await env.send('POST', '/api/catalog/clone', {})).status).toBe(400) | ||
| 67 | + expect((await env.send('POST', '/api/catalog/clone', { url: '--upload-pack=x' })).status).toBe(400) | ||
| 68 | + expect((await env.send('POST', '/api/catalog/clone', { url: remote, path: 'relative' })).status).toBe(400) | ||
| 69 | + await env.send('POST', '/api/catalog/init') | ||
| 70 | + const res = await env.send('POST', '/api/catalog/clone', { url: remote }) | ||
| 71 | + expect(res.status).toBe(400) | ||
| 72 | + expect(((await res.json()) as { error: string }).error).toMatch(/not empty|already/i) | ||
| 73 | + }) | ||
| 74 | + | ||
| 75 | + it('reports git errors and keeps the settings untouched', async () => { | ||
| 76 | + const target = path.join(env.tmp, 'never', 'catalog') | ||
| 77 | + const res = await env.send('POST', '/api/catalog/clone', { url: path.join(env.tmp, 'missing.git'), path: target }) | ||
| 78 | + expect(res.status).toBe(400) | ||
| 79 | + expect(((await res.json()) as { error: string }).error).toBeTruthy() | ||
| 80 | + expect(((await (await env.req('/api/settings')).json()) as Settings).catalogPath).toBe(env.catalogDir) | ||
| 81 | + }) | ||
| 82 | +}) | ||
| 83 | + | ||
| 84 | +describe('GET /api/fs/discover', () => { | ||
| 85 | + async function discover(root: string, depth?: number): Promise<Response> { | ||
| 86 | + const q = `root=${encodeURIComponent(root)}${depth === undefined ? '' : `&depth=${depth}`}` | ||
| 87 | + return env.req(`/api/fs/discover?${q}`) | ||
| 88 | + } | ||
| 89 | + | ||
| 90 | + it('finds folders with agent config, skipping hidden, node_modules and nested projects', async () => { | ||
| 91 | + const root = path.join(env.tmp, 'code') | ||
| 92 | + await put(path.join(root, 'app-a', 'CLAUDE.md'), '# a') | ||
| 93 | + await put(path.join(root, 'app-a', 'packages', 'inner', 'AGENTS.md'), '# not reported: parent is a project') | ||
| 94 | + await put(path.join(root, 'group', 'app-b', '.claude', 'settings.json'), '{}') | ||
| 95 | + await put(path.join(root, 'group', 'app-b', 'AGENTS.md'), '# b') | ||
| 96 | + await put(path.join(root, 'plain', 'readme.md'), 'x') | ||
| 97 | + await put(path.join(root, 'node_modules', 'dep', 'CLAUDE.md'), 'x') | ||
| 98 | + await put(path.join(root, '.hidden', 'CLAUDE.md'), 'x') | ||
| 99 | + const res = await discover(root, 3) | ||
| 100 | + expect(res.status).toBe(200) | ||
| 101 | + const body = (await res.json()) as FsDiscovery | ||
| 102 | + expect(body.truncated).toBe(false) | ||
| 103 | + expect(body.entries.map((e) => e.name)).toEqual(['app-a', 'app-b']) | ||
| 104 | + expect(body.entries[1]!.markers).toEqual(['.claude', 'AGENTS.md']) | ||
| 105 | + expect(body.entries.every((e) => e.hasAgentConfig)).toBe(true) | ||
| 106 | + }) | ||
| 107 | + | ||
| 108 | + it('honours depth and clamps nothing silently', async () => { | ||
| 109 | + const root = path.join(env.tmp, 'code') | ||
| 110 | + await put(path.join(root, 'group', 'deep', 'CLAUDE.md'), 'x') | ||
| 111 | + expect(((await (await discover(root, 1)).json()) as FsDiscovery).entries).toEqual([]) | ||
| 112 | + expect(((await (await discover(root, 2)).json()) as FsDiscovery).entries.map((e) => e.name)).toEqual(['deep']) | ||
| 113 | + expect((await discover(root, 5)).status).toBe(400) | ||
| 114 | + expect((await discover(root, 0)).status).toBe(400) | ||
| 115 | + }) | ||
| 116 | + | ||
| 117 | + it('validates the root', async () => { | ||
| 118 | + expect((await env.req('/api/fs/discover')).status).toBe(400) | ||
| 119 | + expect((await discover('relative/path')).status).toBe(400) | ||
| 120 | + expect((await discover(path.join(env.tmp, 'missing'))).status).toBe(404) | ||
| 121 | + }) | ||
| 122 | +}) | ||
modified
packages/ui/app/assets/css/main.css +0 -18 | @@ -210,21 +210,3 @@ body { | ||
| 210 | 210 | a { |
| 211 | 211 | text-underline-offset: 2px; |
| 212 | 212 | } |
| 213 | - | |
| 214 | -/* ------------------------------------------------------------------ markdown (legacy; owned by WP2 once MarkdownView is rebuilt) */ | |
| 215 | -.md-body { font-size: 13px; line-height: 1.6; overflow-wrap: anywhere; } | |
| 216 | -.md-body h1 { font-size: 1.25rem; font-weight: 600; margin: 1rem 0 0.5rem; } | |
| 217 | -.md-body h2 { font-size: 1.05rem; font-weight: 600; margin: 1rem 0 0.5rem; } | |
| 218 | -.md-body h3, .md-body h4 { font-size: 0.95rem; font-weight: 600; margin: 0.75rem 0 0.4rem; } | |
| 219 | -.md-body p { margin: 0.5rem 0; } | |
| 220 | -.md-body ul { list-style: disc; padding-left: 1.25rem; margin: 0.5rem 0; } | |
| 221 | -.md-body ol { list-style: decimal; padding-left: 1.25rem; margin: 0.5rem 0; } | |
| 222 | -.md-body a { color: var(--sk-accent-text); text-decoration: underline; } | |
| 223 | -.md-body code { font-family: var(--font-mono); background: var(--sk-line2); padding: 0.1rem 0.3rem; border-radius: 0.25rem; font-size: 0.78rem; } | |
| 224 | -.md-body pre { background: var(--sk-hover); box-shadow: 0 0 0 1px var(--sk-ring); padding: 0.75rem; border-radius: 10px; overflow-x: auto; margin: 0.5rem 0; } | |
| 225 | -.md-body pre code { background: transparent; padding: 0; } | |
| 226 | -.md-body blockquote { border-left: 3px solid var(--sk-line); padding-left: 0.75rem; color: var(--sk-muted); margin: 0.5rem 0; } | |
| 227 | -.md-body table { display: block; max-width: 100%; overflow-x: auto; border-collapse: collapse; margin: 0.5rem 0; } | |
| 228 | -.md-body th, .md-body td { border: 1px solid var(--sk-line); padding: 0.25rem 0.5rem; text-align: left; vertical-align: top; } | |
| 229 | -.md-body th { background: var(--sk-hover); font-weight: 600; } | |
| 230 | -.md-body hr { border-color: var(--sk-line); margin: 1rem 0; } | |
| @@ -210,21 +210,3 @@ body { | |||
| 210 | a { | 210 | a { |
| 211 | text-underline-offset: 2px; | 211 | text-underline-offset: 2px; |
| 212 | } | 212 | } |
| 213 | - | ||
| 214 | -/* ------------------------------------------------------------------ markdown (legacy; owned by WP2 once MarkdownView is rebuilt) */ | ||
| 215 | -.md-body { font-size: 13px; line-height: 1.6; overflow-wrap: anywhere; } | ||
| 216 | -.md-body h1 { font-size: 1.25rem; font-weight: 600; margin: 1rem 0 0.5rem; } | ||
| 217 | -.md-body h2 { font-size: 1.05rem; font-weight: 600; margin: 1rem 0 0.5rem; } | ||
| 218 | -.md-body h3, .md-body h4 { font-size: 0.95rem; font-weight: 600; margin: 0.75rem 0 0.4rem; } | ||
| 219 | -.md-body p { margin: 0.5rem 0; } | ||
| 220 | -.md-body ul { list-style: disc; padding-left: 1.25rem; margin: 0.5rem 0; } | ||
| 221 | -.md-body ol { list-style: decimal; padding-left: 1.25rem; margin: 0.5rem 0; } | ||
| 222 | -.md-body a { color: var(--sk-accent-text); text-decoration: underline; } | ||
| 223 | -.md-body code { font-family: var(--font-mono); background: var(--sk-line2); padding: 0.1rem 0.3rem; border-radius: 0.25rem; font-size: 0.78rem; } | ||
| 224 | -.md-body pre { background: var(--sk-hover); box-shadow: 0 0 0 1px var(--sk-ring); padding: 0.75rem; border-radius: 10px; overflow-x: auto; margin: 0.5rem 0; } | ||
| 225 | -.md-body pre code { background: transparent; padding: 0; } | ||
| 226 | -.md-body blockquote { border-left: 3px solid var(--sk-line); padding-left: 0.75rem; color: var(--sk-muted); margin: 0.5rem 0; } | ||
| 227 | -.md-body table { display: block; max-width: 100%; overflow-x: auto; border-collapse: collapse; margin: 0.5rem 0; } | ||
| 228 | -.md-body th, .md-body td { border: 1px solid var(--sk-line); padding: 0.25rem 0.5rem; text-align: left; vertical-align: top; } | ||
| 229 | -.md-body th { background: var(--sk-hover); font-weight: 600; } | ||
| 230 | -.md-body hr { border-color: var(--sk-line); margin: 1rem 0; } | ||
modified
packages/ui/app/components/AddProjectModal.vue +22 -41 | @@ -1,4 +1,5 @@ | ||
| 1 | 1 | <script setup lang="ts"> |
| 2 | +/** "Add project" modal: paste a folder path or browse to it. Mounted once in the default layout. */ | |
| 2 | 3 | const open = defineModel<boolean>('open', { default: false }) |
| 3 | 4 | |
| 4 | 5 | const { add } = useProjects() |
| @@ -8,7 +9,7 @@ const toast = useToast() | ||
| 8 | 9 | const pathInput = ref('') |
| 9 | 10 | const submitting = ref(false) |
| 10 | 11 | const submitError = ref<string | null>(null) |
| 11 | -// The highlighted entry wins over the folder being browsed, so "Select" adds what the user clicked. | |
| 12 | +// The highlighted entry wins over the folder being browsed, so "Select this folder" adds what the user clicked. | |
| 12 | 13 | const selectedPath = ref<string | null>(null) |
| 13 | 14 | |
| 14 | 15 | async function submit(path: string) { |
| @@ -39,46 +40,26 @@ watch(open, (v) => { | ||
| 39 | 40 | </script> |
| 40 | 41 | |
| 41 | 42 | <template> |
| 42 | - <UModal | |
| 43 | - v-model:open="open" | |
| 44 | - title="Add project" | |
| 45 | - description="Browse to a project folder, or paste its path." | |
| 46 | - :ui="{ content: 'max-w-2xl' }" | |
| 47 | - > | |
| 48 | - <template #body> | |
| 49 | - <div class="space-y-4"> | |
| 50 | - <form class="flex gap-2" @submit.prevent="submit(pathInput)"> | |
| 51 | - <UInput | |
| 52 | - v-model="pathInput" | |
| 53 | - icon="i-lucide-folder-input" | |
| 54 | - placeholder="Paste a folder path, e.g. G:\Dev\my-project" | |
| 55 | - class="flex-1" | |
| 56 | - autofocus | |
| 57 | - @update:model-value="submitError = null" | |
| 58 | - /> | |
| 59 | - <UButton type="submit" label="Add" :loading="submitting" :disabled="!pathInput.trim()" /> | |
| 60 | - </form> | |
| 61 | - <UAlert v-if="submitError" color="error" variant="subtle" icon="i-lucide-circle-alert" :title="submitError" /> | |
| 62 | - | |
| 63 | - <FolderBrowser v-if="open" v-model:selected="selectedPath" /> | |
| 64 | - </div> | |
| 65 | - </template> | |
| 66 | - | |
| 43 | + <SkModal v-model:open="open" title="Add project" description="Browse to a project folder, or paste its path." width="lg"> | |
| 44 | + <div class="flex flex-col gap-3.5"> | |
| 45 | + <form class="flex flex-col gap-1.5" @submit.prevent="submit(pathInput)"> | |
| 46 | + <UInput | |
| 47 | + v-model="pathInput" | |
| 48 | + placeholder="Paste a folder path, e.g. G:\Dev\my-project" | |
| 49 | + class="w-full font-mono" | |
| 50 | + :class="submitError && '[&_input]:ring-sk-accent'" | |
| 51 | + autofocus | |
| 52 | + aria-label="Project folder path" | |
| 53 | + @update:model-value="submitError = null" | |
| 54 | + /> | |
| 55 | + <p v-if="submitError" class="m-0 text-xs text-sk-accent-text">{{ submitError }}</p> | |
| 56 | + </form> | |
| 57 | + <FolderBrowser v-if="open" v-model:selected="selectedPath" /> | |
| 58 | + </div> | |
| 67 | 59 | <template #footer> |
| 68 | - <div class="flex w-full items-center justify-between gap-2"> | |
| 69 | - <span v-if="selectedPath" class="truncate font-mono text-xs text-muted" :title="selectedPath">{{ selectedPath }}</span> | |
| 70 | - <span v-else class="truncate text-xs text-muted">Select a drive or folder</span> | |
| 71 | - <div class="flex shrink-0 gap-2"> | |
| 72 | - <UButton label="Cancel" color="neutral" variant="outline" @click="open = false" /> | |
| 73 | - <UButton | |
| 74 | - label="Select this folder" | |
| 75 | - icon="i-lucide-check" | |
| 76 | - :disabled="!selectedPath" | |
| 77 | - :loading="submitting" | |
| 78 | - @click="submit(selectedPath!)" | |
| 79 | - /> | |
| 80 | - </div> | |
| 81 | - </div> | |
| 60 | + <span class="mr-auto min-w-0 truncate font-mono text-[11px] text-sk-faint" :title="selectedPath ?? ''">{{ selectedPath ?? 'Select a drive or folder' }}</span> | |
| 61 | + <SkButton label="Cancel" @click="open = false" /> | |
| 62 | + <SkButton variant="primary" label="Select this folder" :disabled="!(pathInput.trim() || selectedPath)" :loading="submitting" @click="submit(pathInput.trim() || selectedPath!)" /> | |
| 82 | 63 | </template> |
| 83 | - </UModal> | |
| 64 | + </SkModal> | |
| 84 | 65 | </template> |
| @@ -1,4 +1,5 @@ | |||
| 1 | <script setup lang="ts"> | 1 | <script setup lang="ts"> |
| 2 | +/** "Add project" modal: paste a folder path or browse to it. Mounted once in the default layout. */ | ||
| 2 | const open = defineModel<boolean>('open', { default: false }) | 3 | const open = defineModel<boolean>('open', { default: false }) |
| 3 | 4 | ||
| 4 | const { add } = useProjects() | 5 | const { add } = useProjects() |
| @@ -8,7 +9,7 @@ const toast = useToast() | |||
| 8 | const pathInput = ref('') | 9 | const pathInput = ref('') |
| 9 | const submitting = ref(false) | 10 | const submitting = ref(false) |
| 10 | const submitError = ref<string | null>(null) | 11 | const submitError = ref<string | null>(null) |
| 11 | -// The highlighted entry wins over the folder being browsed, so "Select" adds what the user clicked. | 12 | +// The highlighted entry wins over the folder being browsed, so "Select this folder" adds what the user clicked. |
| 12 | const selectedPath = ref<string | null>(null) | 13 | const selectedPath = ref<string | null>(null) |
| 13 | 14 | ||
| 14 | async function submit(path: string) { | 15 | async function submit(path: string) { |
| @@ -39,46 +40,26 @@ watch(open, (v) => { | |||
| 39 | </script> | 40 | </script> |
| 40 | 41 | ||
| 41 | <template> | 42 | <template> |
| 42 | - <UModal | 43 | + <SkModal v-model:open="open" title="Add project" description="Browse to a project folder, or paste its path." width="lg"> |
| 43 | - v-model:open="open" | 44 | + <div class="flex flex-col gap-3.5"> |
| 44 | - title="Add project" | 45 | + <form class="flex flex-col gap-1.5" @submit.prevent="submit(pathInput)"> |
| 45 | - description="Browse to a project folder, or paste its path." | 46 | + <UInput |
| 46 | - :ui="{ content: 'max-w-2xl' }" | 47 | + v-model="pathInput" |
| 47 | - > | 48 | + placeholder="Paste a folder path, e.g. G:\Dev\my-project" |
| 48 | - <template #body> | 49 | + class="w-full font-mono" |
| 49 | - <div class="space-y-4"> | 50 | + :class="submitError && '[&_input]:ring-sk-accent'" |
| 50 | - <form class="flex gap-2" @submit.prevent="submit(pathInput)"> | 51 | + autofocus |
| 51 | - <UInput | 52 | + aria-label="Project folder path" |
| 52 | - v-model="pathInput" | 53 | + @update:model-value="submitError = null" |
| 53 | - icon="i-lucide-folder-input" | 54 | + /> |
| 54 | - placeholder="Paste a folder path, e.g. G:\Dev\my-project" | 55 | + <p v-if="submitError" class="m-0 text-xs text-sk-accent-text">{{ submitError }}</p> |
| 55 | - class="flex-1" | 56 | + </form> |
| 56 | - autofocus | 57 | + <FolderBrowser v-if="open" v-model:selected="selectedPath" /> |
| 57 | - @update:model-value="submitError = null" | 58 | + </div> |
| 58 | - /> | ||
| 59 | - <UButton type="submit" label="Add" :loading="submitting" :disabled="!pathInput.trim()" /> | ||
| 60 | - </form> | ||
| 61 | - <UAlert v-if="submitError" color="error" variant="subtle" icon="i-lucide-circle-alert" :title="submitError" /> | ||
| 62 | - | ||
| 63 | - <FolderBrowser v-if="open" v-model:selected="selectedPath" /> | ||
| 64 | - </div> | ||
| 65 | - </template> | ||
| 66 | - | ||
| 67 | <template #footer> | 59 | <template #footer> |
| 68 | - <div class="flex w-full items-center justify-between gap-2"> | 60 | + <span class="mr-auto min-w-0 truncate font-mono text-[11px] text-sk-faint" :title="selectedPath ?? ''">{{ selectedPath ?? 'Select a drive or folder' }}</span> |
| 69 | - <span v-if="selectedPath" class="truncate font-mono text-xs text-muted" :title="selectedPath">{{ selectedPath }}</span> | 61 | + <SkButton label="Cancel" @click="open = false" /> |
| 70 | - <span v-else class="truncate text-xs text-muted">Select a drive or folder</span> | 62 | + <SkButton variant="primary" label="Select this folder" :disabled="!(pathInput.trim() || selectedPath)" :loading="submitting" @click="submit(pathInput.trim() || selectedPath!)" /> |
| 71 | - <div class="flex shrink-0 gap-2"> | ||
| 72 | - <UButton label="Cancel" color="neutral" variant="outline" @click="open = false" /> | ||
| 73 | - <UButton | ||
| 74 | - label="Select this folder" | ||
| 75 | - icon="i-lucide-check" | ||
| 76 | - :disabled="!selectedPath" | ||
| 77 | - :loading="submitting" | ||
| 78 | - @click="submit(selectedPath!)" | ||
| 79 | - /> | ||
| 80 | - </div> | ||
| 81 | - </div> | ||
| 82 | </template> | 63 | </template> |
| 83 | - </UModal> | 64 | + </SkModal> |
| 84 | </template> | 65 | </template> |
modified
packages/ui/app/components/AddToCatalogModal.vue +32 -38 | @@ -1,6 +1,7 @@ | ||
| 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 | const props = defineProps<{ scope: string, items: InventoryItem[], statuses: Record<string, ItemStatus> }>() |
| 5 | 6 | const open = defineModel<boolean>('open', { default: false }) |
| 6 | 7 | const emit = defineEmits<{ done: [] }>() |
| @@ -46,45 +47,38 @@ async function submit() { | ||
| 46 | 47 | </script> |
| 47 | 48 | |
| 48 | 49 | <template> |
| 49 | - <UModal v-model:open="open" title="Add to catalog" description="Copy these items into the catalog so they can be reused in other projects." :ui="{ content: 'max-w-xl' }"> | |
| 50 | - <template #body> | |
| 51 | - <div v-if="result" class="space-y-3 text-sm"> | |
| 52 | - <UAlert v-if="result.imported.length" color="success" variant="subtle" icon="i-lucide-check" :title="`${result.imported.length} item(s) added`"> | |
| 53 | - <template #description> | |
| 54 | - <ul class="list-disc pl-4"><li v-for="i in result.imported" :key="`${i.type}/${i.name}`">{{ CAT_META[i.type].singular }} {{ i.name }}</li></ul> | |
| 55 | - </template> | |
| 56 | - </UAlert> | |
| 57 | - <UAlert v-if="result.skipped.length" color="warning" variant="subtle" icon="i-lucide-triangle-alert" :title="`${result.skipped.length} skipped`"> | |
| 58 | - <template #description> | |
| 59 | - <ul class="list-disc pl-4"><li v-for="s in result.skipped" :key="s.id"><span class="font-mono text-xs">{{ s.id }}</span>: {{ s.reason }}</li></ul> | |
| 60 | - </template> | |
| 61 | - </UAlert> | |
| 62 | - </div> | |
| 63 | - <div v-else class="space-y-4"> | |
| 64 | - <UAlert v-if="error" color="error" variant="subtle" icon="i-lucide-circle-alert" title="Import failed" :description="error" /> | |
| 65 | - <ul class="max-h-48 divide-y divide-default overflow-auto rounded-lg border border-default text-sm"> | |
| 66 | - <li v-for="i in items" :key="i.id" class="flex items-center gap-2 px-3 py-1.5"> | |
| 67 | - <AgentBadge :agent="i.agent" compact /> | |
| 68 | - <span class="truncate font-medium">{{ i.name }}</span> | |
| 69 | - <span class="text-xs text-muted">{{ i.type }}</span> | |
| 70 | - <UBadge v-if="statuses[i.id]?.state === 'differs-from-catalog'" label="differs from catalog" size="sm" color="warning" variant="subtle" class="ml-auto" /> | |
| 71 | - </li> | |
| 72 | - </ul> | |
| 73 | - <UAlert v-if="differing.length" color="warning" variant="subtle" icon="i-lucide-git-compare" :title="`${differing.length} item(s) already exist in the catalog with different content`"> | |
| 74 | - <template #description> | |
| 75 | - <UCheckbox v-model="overwrite" label="Overwrite the catalog version" description="Unchecked, these items are left out." class="mt-1" /> | |
| 76 | - </template> | |
| 77 | - </UAlert> | |
| 78 | - <UFormField label="Tags" description="Optional, applied to the imported items."> | |
| 79 | - <UInputTags v-model="tags" add-on-blur class="w-full" placeholder="Add a tag, press Enter" /> | |
| 80 | - </UFormField> | |
| 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."> | |
| 51 | + <div v-if="result" class="flex flex-col gap-3"> | |
| 52 | + <SkNote v-if="result.imported.length" tone="ok"> | |
| 53 | + <span class="font-medium">{{ result.imported.length }} added</span> | |
| 54 | + <span class="block font-mono text-xs text-sk-muted">{{ result.imported.map(i => `${i.type}/${i.name}`).join(', ') }}</span> | |
| 55 | + </SkNote> | |
| 56 | + <SkNote v-for="s in result.skipped" :key="s.id" tone="accent"><span class="font-mono text-xs">{{ s.id }}</span>: {{ s.reason }}</SkNote> | |
| 57 | + </div> | |
| 58 | + | |
| 59 | + <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> | |
| 61 | + <ul class="m-0 max-h-48 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"> | |
| 63 | + <span class="truncate font-medium">{{ itemDisplayName(i) }}</span> | |
| 64 | + <SkTypeTag :type="i.type" /> | |
| 65 | + <SkAgentBadge :agent="i.agent" class="text-xs" /> | |
| 66 | + <SkStatus v-if="statuses[i.id]?.state === 'differs-from-catalog'" tone="accent" label="Differs from catalog" small class="ml-auto text-xs text-sk-muted" /> | |
| 67 | + </li> | |
| 68 | + </ul> | |
| 69 | + <SkNote v-if="differing.length" tone="accent"> | |
| 70 | + <p class="m-0">{{ differing.length }} item{{ differing.length === 1 ? ' already exists' : 's already exist' }} in the catalog with different content.</p> | |
| 71 | + <UCheckbox v-model="overwrite" label="Overwrite the catalog version" description="Unchecked, these items are left out." class="mt-2 text-sk-text" /> | |
| 72 | + </SkNote> | |
| 73 | + <div class="flex flex-col gap-1.5"> | |
| 74 | + <SkSectionLabel>Tags</SkSectionLabel> | |
| 75 | + <UInputTags v-model="tags" add-on-blur class="w-full" placeholder="Add a tag, press Enter (optional)" /> | |
| 81 | 76 | </div> |
| 82 | - </template> | |
| 77 | + </div> | |
| 78 | + | |
| 83 | 79 | <template #footer> |
| 84 | - <div class="flex w-full justify-end gap-2"> | |
| 85 | - <UButton :label="result ? 'Close' : 'Cancel'" color="neutral" variant="outline" @click="open = false" /> | |
| 86 | - <UButton v-if="!result" :label="`Add ${toSend.length} to catalog`" icon="i-lucide-library" :loading="busy" :disabled="!toSend.length" @click="submit" /> | |
| 87 | - </div> | |
| 80 | + <SkButton :label="result ? 'Close' : 'Cancel'" @click="open = false" /> | |
| 81 | + <SkButton v-if="!result" variant="primary" :label="`Add ${toSend.length} to catalog`" :loading="busy" :disabled="!toSend.length" @click="submit" /> | |
| 88 | 82 | </template> |
| 89 | - </UModal> | |
| 83 | + </SkModal> | |
| 90 | 84 | </template> |
| @@ -1,6 +1,7 @@ | |||
| 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 | const props = defineProps<{ scope: string, items: InventoryItem[], statuses: Record<string, ItemStatus> }>() | 5 | const props = defineProps<{ scope: string, items: InventoryItem[], statuses: Record<string, ItemStatus> }>() |
| 5 | const open = defineModel<boolean>('open', { default: false }) | 6 | const open = defineModel<boolean>('open', { default: false }) |
| 6 | const emit = defineEmits<{ done: [] }>() | 7 | const emit = defineEmits<{ done: [] }>() |
| @@ -46,45 +47,38 @@ async function submit() { | |||
| 46 | </script> | 47 | </script> |
| 47 | 48 | ||
| 48 | <template> | 49 | <template> |
| 49 | - <UModal v-model:open="open" title="Add to catalog" description="Copy these items into the catalog so they can be reused in other projects." :ui="{ content: 'max-w-xl' }"> | 50 | + <SkModal v-model:open="open" title="Add to catalog" description="Copy these items into the catalog so they can be reused in other projects."> |
| 50 | - <template #body> | 51 | + <div v-if="result" class="flex flex-col gap-3"> |
| 51 | - <div v-if="result" class="space-y-3 text-sm"> | 52 | + <SkNote v-if="result.imported.length" tone="ok"> |
| 52 | - <UAlert v-if="result.imported.length" color="success" variant="subtle" icon="i-lucide-check" :title="`${result.imported.length} item(s) added`"> | 53 | + <span class="font-medium">{{ result.imported.length }} added</span> |
| 53 | - <template #description> | 54 | + <span class="block font-mono text-xs text-sk-muted">{{ result.imported.map(i => `${i.type}/${i.name}`).join(', ') }}</span> |
| 54 | - <ul class="list-disc pl-4"><li v-for="i in result.imported" :key="`${i.type}/${i.name}`">{{ CAT_META[i.type].singular }} {{ i.name }}</li></ul> | 55 | + </SkNote> |
| 55 | - </template> | 56 | + <SkNote v-for="s in result.skipped" :key="s.id" tone="accent"><span class="font-mono text-xs">{{ s.id }}</span>: {{ s.reason }}</SkNote> |
| 56 | - </UAlert> | 57 | + </div> |
| 57 | - <UAlert v-if="result.skipped.length" color="warning" variant="subtle" icon="i-lucide-triangle-alert" :title="`${result.skipped.length} skipped`"> | 58 | + |
| 58 | - <template #description> | 59 | + <div v-else class="flex flex-col gap-4"> |
| 59 | - <ul class="list-disc pl-4"><li v-for="s in result.skipped" :key="s.id"><span class="font-mono text-xs">{{ s.id }}</span>: {{ s.reason }}</li></ul> | 60 | + <SkNote v-if="error" tone="accent"><span class="font-medium">Import failed.</span> {{ error }}</SkNote> |
| 60 | - </template> | 61 | + <ul class="m-0 max-h-48 list-none overflow-auto rounded-[10px] border border-sk-line p-0"> |
| 61 | - </UAlert> | 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"> |
| 62 | - </div> | 63 | + <span class="truncate font-medium">{{ itemDisplayName(i) }}</span> |
| 63 | - <div v-else class="space-y-4"> | 64 | + <SkTypeTag :type="i.type" /> |
| 64 | - <UAlert v-if="error" color="error" variant="subtle" icon="i-lucide-circle-alert" title="Import failed" :description="error" /> | 65 | + <SkAgentBadge :agent="i.agent" class="text-xs" /> |
| 65 | - <ul class="max-h-48 divide-y divide-default overflow-auto rounded-lg border border-default text-sm"> | 66 | + <SkStatus v-if="statuses[i.id]?.state === 'differs-from-catalog'" tone="accent" label="Differs from catalog" small class="ml-auto text-xs text-sk-muted" /> |
| 66 | - <li v-for="i in items" :key="i.id" class="flex items-center gap-2 px-3 py-1.5"> | 67 | + </li> |
| 67 | - <AgentBadge :agent="i.agent" compact /> | 68 | + </ul> |
| 68 | - <span class="truncate font-medium">{{ i.name }}</span> | 69 | + <SkNote v-if="differing.length" tone="accent"> |
| 69 | - <span class="text-xs text-muted">{{ i.type }}</span> | 70 | + <p class="m-0">{{ differing.length }} item{{ differing.length === 1 ? ' already exists' : 's already exist' }} in the catalog with different content.</p> |
| 70 | - <UBadge v-if="statuses[i.id]?.state === 'differs-from-catalog'" label="differs from catalog" size="sm" color="warning" variant="subtle" class="ml-auto" /> | 71 | + <UCheckbox v-model="overwrite" label="Overwrite the catalog version" description="Unchecked, these items are left out." class="mt-2 text-sk-text" /> |
| 71 | - </li> | 72 | + </SkNote> |
| 72 | - </ul> | 73 | + <div class="flex flex-col gap-1.5"> |
| 73 | - <UAlert v-if="differing.length" color="warning" variant="subtle" icon="i-lucide-git-compare" :title="`${differing.length} item(s) already exist in the catalog with different content`"> | 74 | + <SkSectionLabel>Tags</SkSectionLabel> |
| 74 | - <template #description> | 75 | + <UInputTags v-model="tags" add-on-blur class="w-full" placeholder="Add a tag, press Enter (optional)" /> |
| 75 | - <UCheckbox v-model="overwrite" label="Overwrite the catalog version" description="Unchecked, these items are left out." class="mt-1" /> | ||
| 76 | - </template> | ||
| 77 | - </UAlert> | ||
| 78 | - <UFormField label="Tags" description="Optional, applied to the imported items."> | ||
| 79 | - <UInputTags v-model="tags" add-on-blur class="w-full" placeholder="Add a tag, press Enter" /> | ||
| 80 | - </UFormField> | ||
| 81 | </div> | 76 | </div> |
| 82 | - </template> | 77 | + </div> |
| 78 | + | ||
| 83 | <template #footer> | 79 | <template #footer> |
| 84 | - <div class="flex w-full justify-end gap-2"> | 80 | + <SkButton :label="result ? 'Close' : 'Cancel'" @click="open = false" /> |
| 85 | - <UButton :label="result ? 'Close' : 'Cancel'" color="neutral" variant="outline" @click="open = false" /> | 81 | + <SkButton v-if="!result" variant="primary" :label="`Add ${toSend.length} to catalog`" :loading="busy" :disabled="!toSend.length" @click="submit" /> |
| 86 | - <UButton v-if="!result" :label="`Add ${toSend.length} to catalog`" icon="i-lucide-library" :loading="busy" :disabled="!toSend.length" @click="submit" /> | ||
| 87 | - </div> | ||
| 88 | </template> | 82 | </template> |
| 89 | - </UModal> | 83 | + </SkModal> |
| 90 | </template> | 84 | </template> |
deleted
packages/ui/app/components/ApplyModal.vue +0 -251 | deleted file mode 100644 | ||
| @@ -1,251 +0,0 @@ | ||
| 1 | -<script setup lang="ts"> | |
| 2 | -import type { ApplyResponse, ConflictResolution, FileContent, PlanAction, PlanActionKind, ScopeState, SyncPlan } from '@skilly/core' | |
| 3 | - | |
| 4 | -const props = defineProps<{ scope: string, plan: SyncPlan, home: boolean }>() | |
| 5 | -const open = defineModel<boolean>('open', { default: false }) | |
| 6 | -const emit = defineEmits<{ applied: [state: ScopeState] }>() | |
| 7 | - | |
| 8 | -const { request } = useApi() | |
| 9 | -const toast = useToast() | |
| 10 | - | |
| 11 | -const KIND: Record<Exclude<PlanActionKind, 'unchanged'>, { label: string, icon: string, color: string }> = { | |
| 12 | - conflict: { label: 'Conflicts', icon: 'i-lucide-triangle-alert', color: 'text-warning' }, | |
| 13 | - add: { label: 'To add', icon: 'i-lucide-plus', color: 'text-success' }, | |
| 14 | - update: { label: 'To update', icon: 'i-lucide-refresh-cw', color: 'text-info' }, | |
| 15 | - remove: { label: 'To remove', icon: 'i-lucide-trash-2', color: 'text-error' } | |
| 16 | -} | |
| 17 | -const KIND_ORDER = ['conflict', 'add', 'update', 'remove'] as const | |
| 18 | - | |
| 19 | -const actions = computed(() => props.plan.actions.filter(a => a.kind !== 'unchanged')) | |
| 20 | -const groups = computed(() => KIND_ORDER.map(k => ({ kind: k, rows: actions.value.filter(a => a.kind === k) })).filter(g => g.rows.length)) | |
| 21 | -const conflicts = computed(() => actions.value.filter(a => a.kind === 'conflict')) | |
| 22 | - | |
| 23 | -const resolutions = ref<Record<string, ConflictResolution>>({}) | |
| 24 | -const busy = ref(false) | |
| 25 | -const response = ref<ApplyResponse | null>(null) | |
| 26 | -const error = ref<string | null>(null) | |
| 27 | -const expanded = ref<Set<string>>(new Set()) | |
| 28 | - | |
| 29 | -watch(open, (v) => { | |
| 30 | - if (!v) return | |
| 31 | - response.value = null | |
| 32 | - error.value = null | |
| 33 | - expanded.value = new Set() | |
| 34 | - compare.value = {} | |
| 35 | - // Safe default: never overwrite a manual change unless the user chooses to. | |
| 36 | - resolutions.value = Object.fromEntries(conflicts.value.map(a => [a.id, 'keep-local' as ConflictResolution])) | |
| 37 | -}) | |
| 38 | - | |
| 39 | -const CONFLICT_LABEL: Record<string, string> = { | |
| 40 | - 'modified-locally': 'Edited in the scope since the last sync', | |
| 41 | - 'exists-unmanaged': 'A file already exists here and is not managed by skilly', | |
| 42 | - 'missing-in-catalog': 'No longer in the catalog' | |
| 43 | -} | |
| 44 | - | |
| 45 | -const choices = (a: PlanAction) => [ | |
| 46 | - { label: 'Keep local', value: 'keep-local', description: 'Leave the scope version as is' }, | |
| 47 | - { label: 'Take catalog', value: 'take-catalog', description: 'Overwrite with the catalog version', disabled: a.conflict === 'missing-in-catalog' }, | |
| 48 | - { label: 'Push to catalog', value: 'push-to-catalog', description: 'Copy the scope version into the catalog' } | |
| 49 | -] | |
| 50 | - | |
| 51 | -function toggle(id: string) { | |
| 52 | - const s = new Set(expanded.value) | |
| 53 | - if (s.has(id)) s.delete(id) | |
| 54 | - else s.add(id) | |
| 55 | - expanded.value = s | |
| 56 | -} | |
| 57 | - | |
| 58 | -/* ------------------------------------------------ Compare */ | |
| 59 | - | |
| 60 | -interface Compare { loading: boolean, error?: string, local?: string, catalog?: string, note?: string } | |
| 61 | -const compare = ref<Record<string, Compare>>({}) | |
| 62 | - | |
| 63 | -/** The scope file to compare, only when it is the item's own file (not a shared config file). */ | |
| 64 | -function comparableFile(a: PlanAction): string | null { | |
| 65 | - const norm = (f: string) => f.replace(/\\/g, '/') | |
| 66 | - if (a.type === 'skill') return a.files.map(norm).find(f => f.endsWith(`/${a.name}/SKILL.md`)) ?? null | |
| 67 | - if (a.type === 'rule' || a.type === 'agent') return a.files.map(norm).find(f => f.endsWith(`/${a.name}.md`)) ?? null | |
| 68 | - return null | |
| 69 | -} | |
| 70 | - | |
| 71 | -async function loadCompare(a: PlanAction) { | |
| 72 | - const file = comparableFile(a) | |
| 73 | - if (!file) { | |
| 74 | - compare.value = { ...compare.value, [a.id]: { loading: false, note: 'This item lives in a shared configuration file (or an AGENTS.md block): there is no standalone file to compare.' } } | |
| 75 | - return | |
| 76 | - } | |
| 77 | - compare.value = { ...compare.value, [a.id]: { loading: true } } | |
| 78 | - const out: Compare = { loading: false } | |
| 79 | - try { | |
| 80 | - out.local = (await request<FileContent>('/api/files', { query: { path: joinPath(props.plan.root, file) }, silent: true })).content | |
| 81 | - } catch (e) { | |
| 82 | - out.error = `Scope file: ${(e as Error).message}` | |
| 83 | - } | |
| 84 | - if (a.conflict === 'missing-in-catalog') out.note = 'The item no longer exists in the catalog.' | |
| 85 | - else { | |
| 86 | - try { | |
| 87 | - out.catalog = (await request<{ content: string }>(`/api/catalog/items/${a.type}/${encodeURIComponent(a.name)}`, { silent: true })).content | |
| 88 | - } catch (e) { | |
| 89 | - out.error = [out.error, `Catalog: ${(e as Error).message}`].filter(Boolean).join(' / ') | |
| 90 | - } | |
| 91 | - } | |
| 92 | - compare.value = { ...compare.value, [a.id]: out } | |
| 93 | -} | |
| 94 | - | |
| 95 | -function toggleCompare(a: PlanAction) { | |
| 96 | - toggle(a.id) | |
| 97 | - if (expanded.value.has(a.id) && !compare.value[a.id]) loadCompare(a) | |
| 98 | -} | |
| 99 | - | |
| 100 | -/** Lines of `text`, flagged when the other side has no identical line (cheap "diff-ish" highlighting). */ | |
| 101 | -function marked(text: string | undefined, other: string | undefined) { | |
| 102 | - const set = new Set((other ?? '').split(/\r?\n/)) | |
| 103 | - return (text ?? '').split(/\r?\n/).map(line => ({ line, diff: other !== undefined && !set.has(line) })) | |
| 104 | -} | |
| 105 | - | |
| 106 | -/* ------------------------------------------------ Apply */ | |
| 107 | - | |
| 108 | -let appliedActions: PlanAction[] = [] | |
| 109 | -async function apply() { | |
| 110 | - appliedActions = [...props.plan.actions] | |
| 111 | - busy.value = true | |
| 112 | - error.value = null | |
| 113 | - try { | |
| 114 | - response.value = await request<ApplyResponse>(`/api/scopes/${encodeURIComponent(props.scope)}/apply`, { | |
| 115 | - method: 'POST', | |
| 116 | - body: { resolutions: resolutions.value }, | |
| 117 | - silent: true | |
| 118 | - }) | |
| 119 | - emit('applied', response.value.state) | |
| 120 | - if (!response.value.result.errors.length) toast.add({ title: 'Changes applied', icon: 'i-lucide-check', color: 'success' }) | |
| 121 | - } catch (e) { | |
| 122 | - error.value = (e as Error).message | |
| 123 | - } finally { | |
| 124 | - busy.value = false | |
| 125 | - } | |
| 126 | -} | |
| 127 | - | |
| 128 | -const label = (id: string) => { | |
| 129 | - const a = appliedActions.find(x => x.id === id) | |
| 130 | - return a ? `${a.type}/${a.name} (${AGENT_LABELS[a.agent]})` : id | |
| 131 | -} | |
| 132 | -</script> | |
| 133 | - | |
| 134 | -<template> | |
| 135 | - <UModal v-model:open="open" title="Review changes" :description="response ? 'Result of the sync.' : 'Nothing is written until you click Apply.'" :ui="{ content: 'max-w-4xl' }"> | |
| 136 | - <template #body> | |
| 137 | - <!-- Result --> | |
| 138 | - <div v-if="response" class="space-y-3 text-sm"> | |
| 139 | - <UAlert | |
| 140 | - :color="response.result.errors.length ? 'warning' : 'success'" | |
| 141 | - variant="subtle" | |
| 142 | - :icon="response.result.errors.length ? 'i-lucide-triangle-alert' : 'i-lucide-check'" | |
| 143 | - :title="`${response.result.applied.length} applied, ${response.result.skipped.length} skipped, ${response.result.errors.length} error(s)`" | |
| 144 | - /> | |
| 145 | - <UAlert v-if="response.result.errors.length" color="error" variant="subtle" icon="i-lucide-circle-alert" title="Errors"> | |
| 146 | - <template #description> | |
| 147 | - <ul class="list-disc space-y-0.5 pl-4"><li v-for="e in response.result.errors" :key="e.actionId"><span class="font-medium">{{ label(e.actionId) }}</span>: {{ e.message }}</li></ul> | |
| 148 | - </template> | |
| 149 | - </UAlert> | |
| 150 | - <div v-if="response.result.skipped.length"> | |
| 151 | - <p class="mb-1 text-xs font-medium text-muted">Skipped</p> | |
| 152 | - <ul class="list-disc pl-5 text-xs text-muted"><li v-for="s in response.result.skipped" :key="s">{{ label(s) }}</li></ul> | |
| 153 | - </div> | |
| 154 | - <div v-if="response.result.applied.length"> | |
| 155 | - <p class="mb-1 text-xs font-medium text-muted">Applied</p> | |
| 156 | - <ul class="list-disc pl-5 text-xs text-muted"><li v-for="s in response.result.applied" :key="s">{{ label(s) }}</li></ul> | |
| 157 | - </div> | |
| 158 | - </div> | |
| 159 | - | |
| 160 | - <!-- Review --> | |
| 161 | - <div v-else class="space-y-4"> | |
| 162 | - <UAlert v-if="error" color="error" variant="subtle" icon="i-lucide-circle-alert" title="Apply failed" :description="error" /> | |
| 163 | - <UAlert v-if="home" color="warning" variant="subtle" icon="i-lucide-house" title="This writes into your Home agent folders" description="Home is shared by all your projects. Review the files below." /> | |
| 164 | - | |
| 165 | - <UAlert v-if="plan.warnings.length" color="warning" variant="subtle" icon="i-lucide-triangle-alert" :title="plan.warnings.length > 1 ? 'Warnings' : 'Warning'"> | |
| 166 | - <template #description> | |
| 167 | - <ul class="list-disc space-y-0.5 pl-4"><li v-for="w in plan.warnings" :key="w">{{ w }}</li></ul> | |
| 168 | - </template> | |
| 169 | - </UAlert> | |
| 170 | - <UAlert v-if="plan.missing.length" color="warning" variant="subtle" icon="i-lucide-circle-help" :title="`${plan.missing.length} item(s) not found in the catalog`"> | |
| 171 | - <template #description> | |
| 172 | - <p class="mb-1">They are referenced by the configuration and will be skipped.</p> | |
| 173 | - <div class="flex flex-wrap gap-1"><UBadge v-for="m in plan.missing" :key="`${m.type}/${m.name}`" :label="`${m.type}/${m.name}`" size="sm" color="warning" variant="outline" class="font-mono" /></div> | |
| 174 | - </template> | |
| 175 | - </UAlert> | |
| 176 | - | |
| 177 | - <section v-for="g in groups" :key="g.kind" class="space-y-1.5"> | |
| 178 | - <h3 class="flex items-center gap-1.5 text-sm font-semibold"> | |
| 179 | - <UIcon :name="KIND[g.kind].icon" class="size-4" :class="KIND[g.kind].color" />{{ KIND[g.kind].label }} ({{ g.rows.length }}) | |
| 180 | - </h3> | |
| 181 | - <ul class="divide-y divide-default overflow-hidden rounded-lg border border-default text-sm"> | |
| 182 | - <li v-for="a in g.rows" :key="a.id" class="px-3 py-2"> | |
| 183 | - <div class="flex items-start gap-2"> | |
| 184 | - <AgentBadge :agent="a.agent" compact class="mt-0.5" /> | |
| 185 | - <div class="min-w-0 flex-1"> | |
| 186 | - <div class="flex items-center gap-2"> | |
| 187 | - <UIcon :name="CAT_META[a.type].icon" class="size-3.5 shrink-0 text-muted" /> | |
| 188 | - <span class="truncate font-medium">{{ a.name }}</span> | |
| 189 | - <UBadge v-if="a.catalogUpdated" label="catalog updated" size="sm" color="info" variant="subtle" /> | |
| 190 | - <UBadge v-if="a.conflict" :label="a.conflict" size="sm" color="warning" variant="subtle" /> | |
| 191 | - </div> | |
| 192 | - <p class="text-xs text-muted">{{ a.reason || (a.conflict ? CONFLICT_LABEL[a.conflict] : '') }}</p> | |
| 193 | - </div> | |
| 194 | - <UButton | |
| 195 | - v-if="a.files.length || a.configKeys.length" | |
| 196 | - :label="`${a.files.length + a.configKeys.length} file${a.files.length + a.configKeys.length > 1 ? 's' : ''}`" | |
| 197 | - :trailing-icon="expanded.has(a.id) ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'" | |
| 198 | - size="xs" color="neutral" variant="link" class="shrink-0" | |
| 199 | - @click="a.kind === 'conflict' ? toggleCompare(a) : toggle(a.id)" | |
| 200 | - /> | |
| 201 | - </div> | |
| 202 | - | |
| 203 | - <ul v-if="expanded.has(a.id) && (a.files.length || a.configKeys.length)" class="mt-1.5 space-y-0.5 pl-9 font-mono text-[11px] text-muted"> | |
| 204 | - <li v-for="f in a.files" :key="f" class="break-all"><UIcon name="i-lucide-file" class="mr-1 size-3 align-text-bottom" />{{ f }}</li> | |
| 205 | - <li v-for="c in a.configKeys" :key="c.file + c.key" class="break-all"><UIcon name="i-lucide-braces" class="mr-1 size-3 align-text-bottom" />{{ c.file }} : {{ c.key }}</li> | |
| 206 | - </ul> | |
| 207 | - | |
| 208 | - <!-- Conflict resolution --> | |
| 209 | - <div v-if="a.kind === 'conflict'" class="mt-2 space-y-2 pl-9"> | |
| 210 | - <URadioGroup v-model="resolutions[a.id]" :items="choices(a)" orientation="horizontal" size="sm" :aria-label="`Resolution for ${a.name}`" /> | |
| 211 | - <div v-if="expanded.has(a.id)"> | |
| 212 | - <USkeleton v-if="compare[a.id]?.loading" class="h-24 w-full" /> | |
| 213 | - <template v-else-if="compare[a.id]"> | |
| 214 | - <UAlert v-if="compare[a.id]!.note" color="neutral" variant="subtle" icon="i-lucide-info" :title="compare[a.id]!.note" class="mb-2" /> | |
| 215 | - <UAlert v-if="compare[a.id]!.error" color="error" variant="subtle" icon="i-lucide-circle-alert" :title="compare[a.id]!.error" class="mb-2" /> | |
| 216 | - <div v-if="compare[a.id]!.local !== undefined || compare[a.id]!.catalog !== undefined" class="grid gap-2 md:grid-cols-2"> | |
| 217 | - <div v-for="side in ([['Scope (local)', 'local', 'catalog'], ['Catalog', 'catalog', 'local']] as const)" :key="side[0]" class="min-w-0"> | |
| 218 | - <p class="mb-1 text-xs font-medium text-muted">{{ side[0] }}</p> | |
| 219 | - <div class="max-h-64 overflow-auto rounded-lg border border-default bg-elevated/40 py-1 font-mono text-[11px] leading-relaxed"> | |
| 220 | - <div v-if="compare[a.id]![side[1]] === undefined" class="px-2 text-muted">Not available</div> | |
| 221 | - <div | |
| 222 | - v-for="(l, i) in marked(compare[a.id]![side[1]], compare[a.id]![side[2]])" | |
| 223 | - :key="i" | |
| 224 | - class="whitespace-pre-wrap break-all px-2" | |
| 225 | - :class="l.diff ? (side[1] === 'local' ? 'bg-warning/15' : 'bg-info/15') : ''" | |
| 226 | - >{{ l.line || ' ' }}</div> | |
| 227 | - </div> | |
| 228 | - </div> | |
| 229 | - </div> | |
| 230 | - </template> | |
| 231 | - </div> | |
| 232 | - </div> | |
| 233 | - </li> | |
| 234 | - </ul> | |
| 235 | - </section> | |
| 236 | - | |
| 237 | - <p v-if="!groups.length" class="rounded-lg border border-dashed border-default p-6 text-center text-sm text-muted">Nothing to apply.</p> | |
| 238 | - </div> | |
| 239 | - </template> | |
| 240 | - | |
| 241 | - <template #footer> | |
| 242 | - <div class="flex w-full items-center justify-between gap-2"> | |
| 243 | - <span class="text-xs text-muted">{{ response ? '' : `${actions.length} change${actions.length > 1 ? 's' : ''}${conflicts.length ? `, ${conflicts.length} conflict${conflicts.length > 1 ? 's' : ''}` : ''}` }}</span> | |
| 244 | - <div class="flex gap-2"> | |
| 245 | - <UButton :label="response ? 'Close' : 'Cancel'" color="neutral" variant="outline" @click="open = false" /> | |
| 246 | - <UButton v-if="!response" label="Apply" icon="i-lucide-check" :loading="busy" :disabled="!actions.length" @click="apply" /> | |
| 247 | - </div> | |
| 248 | - </div> | |
| 249 | - </template> | |
| 250 | - </UModal> | |
| 251 | -</template> | |
| deleted file mode 100644 | |||
| @@ -1,251 +0,0 @@ | |||
| 1 | -<script setup lang="ts"> | ||
| 2 | -import type { ApplyResponse, ConflictResolution, FileContent, PlanAction, PlanActionKind, ScopeState, SyncPlan } from '@skilly/core' | ||
| 3 | - | ||
| 4 | -const props = defineProps<{ scope: string, plan: SyncPlan, home: boolean }>() | ||
| 5 | -const open = defineModel<boolean>('open', { default: false }) | ||
| 6 | -const emit = defineEmits<{ applied: [state: ScopeState] }>() | ||
| 7 | - | ||
| 8 | -const { request } = useApi() | ||
| 9 | -const toast = useToast() | ||
| 10 | - | ||
| 11 | -const KIND: Record<Exclude<PlanActionKind, 'unchanged'>, { label: string, icon: string, color: string }> = { | ||
| 12 | - conflict: { label: 'Conflicts', icon: 'i-lucide-triangle-alert', color: 'text-warning' }, | ||
| 13 | - add: { label: 'To add', icon: 'i-lucide-plus', color: 'text-success' }, | ||
| 14 | - update: { label: 'To update', icon: 'i-lucide-refresh-cw', color: 'text-info' }, | ||
| 15 | - remove: { label: 'To remove', icon: 'i-lucide-trash-2', color: 'text-error' } | ||
| 16 | -} | ||
| 17 | -const KIND_ORDER = ['conflict', 'add', 'update', 'remove'] as const | ||
| 18 | - | ||
| 19 | -const actions = computed(() => props.plan.actions.filter(a => a.kind !== 'unchanged')) | ||
| 20 | -const groups = computed(() => KIND_ORDER.map(k => ({ kind: k, rows: actions.value.filter(a => a.kind === k) })).filter(g => g.rows.length)) | ||
| 21 | -const conflicts = computed(() => actions.value.filter(a => a.kind === 'conflict')) | ||
| 22 | - | ||
| 23 | -const resolutions = ref<Record<string, ConflictResolution>>({}) | ||
| 24 | -const busy = ref(false) | ||
| 25 | -const response = ref<ApplyResponse | null>(null) | ||
| 26 | -const error = ref<string | null>(null) | ||
| 27 | -const expanded = ref<Set<string>>(new Set()) | ||
| 28 | - | ||
| 29 | -watch(open, (v) => { | ||
| 30 | - if (!v) return | ||
| 31 | - response.value = null | ||
| 32 | - error.value = null | ||
| 33 | - expanded.value = new Set() | ||
| 34 | - compare.value = {} | ||
| 35 | - // Safe default: never overwrite a manual change unless the user chooses to. | ||
| 36 | - resolutions.value = Object.fromEntries(conflicts.value.map(a => [a.id, 'keep-local' as ConflictResolution])) | ||
| 37 | -}) | ||
| 38 | - | ||
| 39 | -const CONFLICT_LABEL: Record<string, string> = { | ||
| 40 | - 'modified-locally': 'Edited in the scope since the last sync', | ||
| 41 | - 'exists-unmanaged': 'A file already exists here and is not managed by skilly', | ||
| 42 | - 'missing-in-catalog': 'No longer in the catalog' | ||
| 43 | -} | ||
| 44 | - | ||
| 45 | -const choices = (a: PlanAction) => [ | ||
| 46 | - { label: 'Keep local', value: 'keep-local', description: 'Leave the scope version as is' }, | ||
| 47 | - { label: 'Take catalog', value: 'take-catalog', description: 'Overwrite with the catalog version', disabled: a.conflict === 'missing-in-catalog' }, | ||
| 48 | - { label: 'Push to catalog', value: 'push-to-catalog', description: 'Copy the scope version into the catalog' } | ||
| 49 | -] | ||
| 50 | - | ||
| 51 | -function toggle(id: string) { | ||
| 52 | - const s = new Set(expanded.value) | ||
| 53 | - if (s.has(id)) s.delete(id) | ||
| 54 | - else s.add(id) | ||
| 55 | - expanded.value = s | ||
| 56 | -} | ||
| 57 | - | ||
| 58 | -/* ------------------------------------------------ Compare */ | ||
| 59 | - | ||
| 60 | -interface Compare { loading: boolean, error?: string, local?: string, catalog?: string, note?: string } | ||
| 61 | -const compare = ref<Record<string, Compare>>({}) | ||
| 62 | - | ||
| 63 | -/** The scope file to compare, only when it is the item's own file (not a shared config file). */ | ||
| 64 | -function comparableFile(a: PlanAction): string | null { | ||
| 65 | - const norm = (f: string) => f.replace(/\\/g, '/') | ||
| 66 | - if (a.type === 'skill') return a.files.map(norm).find(f => f.endsWith(`/${a.name}/SKILL.md`)) ?? null | ||
| 67 | - if (a.type === 'rule' || a.type === 'agent') return a.files.map(norm).find(f => f.endsWith(`/${a.name}.md`)) ?? null | ||
| 68 | - return null | ||
| 69 | -} | ||
| 70 | - | ||
| 71 | -async function loadCompare(a: PlanAction) { | ||
| 72 | - const file = comparableFile(a) | ||
| 73 | - if (!file) { | ||
| 74 | - compare.value = { ...compare.value, [a.id]: { loading: false, note: 'This item lives in a shared configuration file (or an AGENTS.md block): there is no standalone file to compare.' } } | ||
| 75 | - return | ||
| 76 | - } | ||
| 77 | - compare.value = { ...compare.value, [a.id]: { loading: true } } | ||
| 78 | - const out: Compare = { loading: false } | ||
| 79 | - try { | ||
| 80 | - out.local = (await request<FileContent>('/api/files', { query: { path: joinPath(props.plan.root, file) }, silent: true })).content | ||
| 81 | - } catch (e) { | ||
| 82 | - out.error = `Scope file: ${(e as Error).message}` | ||
| 83 | - } | ||
| 84 | - if (a.conflict === 'missing-in-catalog') out.note = 'The item no longer exists in the catalog.' | ||
| 85 | - else { | ||
| 86 | - try { | ||
| 87 | - out.catalog = (await request<{ content: string }>(`/api/catalog/items/${a.type}/${encodeURIComponent(a.name)}`, { silent: true })).content | ||
| 88 | - } catch (e) { | ||
| 89 | - out.error = [out.error, `Catalog: ${(e as Error).message}`].filter(Boolean).join(' / ') | ||
| 90 | - } | ||
| 91 | - } | ||
| 92 | - compare.value = { ...compare.value, [a.id]: out } | ||
| 93 | -} | ||
| 94 | - | ||
| 95 | -function toggleCompare(a: PlanAction) { | ||
| 96 | - toggle(a.id) | ||
| 97 | - if (expanded.value.has(a.id) && !compare.value[a.id]) loadCompare(a) | ||
| 98 | -} | ||
| 99 | - | ||
| 100 | -/** Lines of `text`, flagged when the other side has no identical line (cheap "diff-ish" highlighting). */ | ||
| 101 | -function marked(text: string | undefined, other: string | undefined) { | ||
| 102 | - const set = new Set((other ?? '').split(/\r?\n/)) | ||
| 103 | - return (text ?? '').split(/\r?\n/).map(line => ({ line, diff: other !== undefined && !set.has(line) })) | ||
| 104 | -} | ||
| 105 | - | ||
| 106 | -/* ------------------------------------------------ Apply */ | ||
| 107 | - | ||
| 108 | -let appliedActions: PlanAction[] = [] | ||
| 109 | -async function apply() { | ||
| 110 | - appliedActions = [...props.plan.actions] | ||
| 111 | - busy.value = true | ||
| 112 | - error.value = null | ||
| 113 | - try { | ||
| 114 | - response.value = await request<ApplyResponse>(`/api/scopes/${encodeURIComponent(props.scope)}/apply`, { | ||
| 115 | - method: 'POST', | ||
| 116 | - body: { resolutions: resolutions.value }, | ||
| 117 | - silent: true | ||
| 118 | - }) | ||
| 119 | - emit('applied', response.value.state) | ||
| 120 | - if (!response.value.result.errors.length) toast.add({ title: 'Changes applied', icon: 'i-lucide-check', color: 'success' }) | ||
| 121 | - } catch (e) { | ||
| 122 | - error.value = (e as Error).message | ||
| 123 | - } finally { | ||
| 124 | - busy.value = false | ||
| 125 | - } | ||
| 126 | -} | ||
| 127 | - | ||
| 128 | -const label = (id: string) => { | ||
| 129 | - const a = appliedActions.find(x => x.id === id) | ||
| 130 | - return a ? `${a.type}/${a.name} (${AGENT_LABELS[a.agent]})` : id | ||
| 131 | -} | ||
| 132 | -</script> | ||
| 133 | - | ||
| 134 | -<template> | ||
| 135 | - <UModal v-model:open="open" title="Review changes" :description="response ? 'Result of the sync.' : 'Nothing is written until you click Apply.'" :ui="{ content: 'max-w-4xl' }"> | ||
| 136 | - <template #body> | ||
| 137 | - <!-- Result --> | ||
| 138 | - <div v-if="response" class="space-y-3 text-sm"> | ||
| 139 | - <UAlert | ||
| 140 | - :color="response.result.errors.length ? 'warning' : 'success'" | ||
| 141 | - variant="subtle" | ||
| 142 | - :icon="response.result.errors.length ? 'i-lucide-triangle-alert' : 'i-lucide-check'" | ||
| 143 | - :title="`${response.result.applied.length} applied, ${response.result.skipped.length} skipped, ${response.result.errors.length} error(s)`" | ||
| 144 | - /> | ||
| 145 | - <UAlert v-if="response.result.errors.length" color="error" variant="subtle" icon="i-lucide-circle-alert" title="Errors"> | ||
| 146 | - <template #description> | ||
| 147 | - <ul class="list-disc space-y-0.5 pl-4"><li v-for="e in response.result.errors" :key="e.actionId"><span class="font-medium">{{ label(e.actionId) }}</span>: {{ e.message }}</li></ul> | ||
| 148 | - </template> | ||
| 149 | - </UAlert> | ||
| 150 | - <div v-if="response.result.skipped.length"> | ||
| 151 | - <p class="mb-1 text-xs font-medium text-muted">Skipped</p> | ||
| 152 | - <ul class="list-disc pl-5 text-xs text-muted"><li v-for="s in response.result.skipped" :key="s">{{ label(s) }}</li></ul> | ||
| 153 | - </div> | ||
| 154 | - <div v-if="response.result.applied.length"> | ||
| 155 | - <p class="mb-1 text-xs font-medium text-muted">Applied</p> | ||
| 156 | - <ul class="list-disc pl-5 text-xs text-muted"><li v-for="s in response.result.applied" :key="s">{{ label(s) }}</li></ul> | ||
| 157 | - </div> | ||
| 158 | - </div> | ||
| 159 | - | ||
| 160 | - <!-- Review --> | ||
| 161 | - <div v-else class="space-y-4"> | ||
| 162 | - <UAlert v-if="error" color="error" variant="subtle" icon="i-lucide-circle-alert" title="Apply failed" :description="error" /> | ||
| 163 | - <UAlert v-if="home" color="warning" variant="subtle" icon="i-lucide-house" title="This writes into your Home agent folders" description="Home is shared by all your projects. Review the files below." /> | ||
| 164 | - | ||
| 165 | - <UAlert v-if="plan.warnings.length" color="warning" variant="subtle" icon="i-lucide-triangle-alert" :title="plan.warnings.length > 1 ? 'Warnings' : 'Warning'"> | ||
| 166 | - <template #description> | ||
| 167 | - <ul class="list-disc space-y-0.5 pl-4"><li v-for="w in plan.warnings" :key="w">{{ w }}</li></ul> | ||
| 168 | - </template> | ||
| 169 | - </UAlert> | ||
| 170 | - <UAlert v-if="plan.missing.length" color="warning" variant="subtle" icon="i-lucide-circle-help" :title="`${plan.missing.length} item(s) not found in the catalog`"> | ||
| 171 | - <template #description> | ||
| 172 | - <p class="mb-1">They are referenced by the configuration and will be skipped.</p> | ||
| 173 | - <div class="flex flex-wrap gap-1"><UBadge v-for="m in plan.missing" :key="`${m.type}/${m.name}`" :label="`${m.type}/${m.name}`" size="sm" color="warning" variant="outline" class="font-mono" /></div> | ||
| 174 | - </template> | ||
| 175 | - </UAlert> | ||
| 176 | - | ||
| 177 | - <section v-for="g in groups" :key="g.kind" class="space-y-1.5"> | ||
| 178 | - <h3 class="flex items-center gap-1.5 text-sm font-semibold"> | ||
| 179 | - <UIcon :name="KIND[g.kind].icon" class="size-4" :class="KIND[g.kind].color" />{{ KIND[g.kind].label }} ({{ g.rows.length }}) | ||
| 180 | - </h3> | ||
| 181 | - <ul class="divide-y divide-default overflow-hidden rounded-lg border border-default text-sm"> | ||
| 182 | - <li v-for="a in g.rows" :key="a.id" class="px-3 py-2"> | ||
| 183 | - <div class="flex items-start gap-2"> | ||
| 184 | - <AgentBadge :agent="a.agent" compact class="mt-0.5" /> | ||
| 185 | - <div class="min-w-0 flex-1"> | ||
| 186 | - <div class="flex items-center gap-2"> | ||
| 187 | - <UIcon :name="CAT_META[a.type].icon" class="size-3.5 shrink-0 text-muted" /> | ||
| 188 | - <span class="truncate font-medium">{{ a.name }}</span> | ||
| 189 | - <UBadge v-if="a.catalogUpdated" label="catalog updated" size="sm" color="info" variant="subtle" /> | ||
| 190 | - <UBadge v-if="a.conflict" :label="a.conflict" size="sm" color="warning" variant="subtle" /> | ||
| 191 | - </div> | ||
| 192 | - <p class="text-xs text-muted">{{ a.reason || (a.conflict ? CONFLICT_LABEL[a.conflict] : '') }}</p> | ||
| 193 | - </div> | ||
| 194 | - <UButton | ||
| 195 | - v-if="a.files.length || a.configKeys.length" | ||
| 196 | - :label="`${a.files.length + a.configKeys.length} file${a.files.length + a.configKeys.length > 1 ? 's' : ''}`" | ||
| 197 | - :trailing-icon="expanded.has(a.id) ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'" | ||
| 198 | - size="xs" color="neutral" variant="link" class="shrink-0" | ||
| 199 | - @click="a.kind === 'conflict' ? toggleCompare(a) : toggle(a.id)" | ||
| 200 | - /> | ||
| 201 | - </div> | ||
| 202 | - | ||
| 203 | - <ul v-if="expanded.has(a.id) && (a.files.length || a.configKeys.length)" class="mt-1.5 space-y-0.5 pl-9 font-mono text-[11px] text-muted"> | ||
| 204 | - <li v-for="f in a.files" :key="f" class="break-all"><UIcon name="i-lucide-file" class="mr-1 size-3 align-text-bottom" />{{ f }}</li> | ||
| 205 | - <li v-for="c in a.configKeys" :key="c.file + c.key" class="break-all"><UIcon name="i-lucide-braces" class="mr-1 size-3 align-text-bottom" />{{ c.file }} : {{ c.key }}</li> | ||
| 206 | - </ul> | ||
| 207 | - | ||
| 208 | - <!-- Conflict resolution --> | ||
| 209 | - <div v-if="a.kind === 'conflict'" class="mt-2 space-y-2 pl-9"> | ||
| 210 | - <URadioGroup v-model="resolutions[a.id]" :items="choices(a)" orientation="horizontal" size="sm" :aria-label="`Resolution for ${a.name}`" /> | ||
| 211 | - <div v-if="expanded.has(a.id)"> | ||
| 212 | - <USkeleton v-if="compare[a.id]?.loading" class="h-24 w-full" /> | ||
| 213 | - <template v-else-if="compare[a.id]"> | ||
| 214 | - <UAlert v-if="compare[a.id]!.note" color="neutral" variant="subtle" icon="i-lucide-info" :title="compare[a.id]!.note" class="mb-2" /> | ||
| 215 | - <UAlert v-if="compare[a.id]!.error" color="error" variant="subtle" icon="i-lucide-circle-alert" :title="compare[a.id]!.error" class="mb-2" /> | ||
| 216 | - <div v-if="compare[a.id]!.local !== undefined || compare[a.id]!.catalog !== undefined" class="grid gap-2 md:grid-cols-2"> | ||
| 217 | - <div v-for="side in ([['Scope (local)', 'local', 'catalog'], ['Catalog', 'catalog', 'local']] as const)" :key="side[0]" class="min-w-0"> | ||
| 218 | - <p class="mb-1 text-xs font-medium text-muted">{{ side[0] }}</p> | ||
| 219 | - <div class="max-h-64 overflow-auto rounded-lg border border-default bg-elevated/40 py-1 font-mono text-[11px] leading-relaxed"> | ||
| 220 | - <div v-if="compare[a.id]![side[1]] === undefined" class="px-2 text-muted">Not available</div> | ||
| 221 | - <div | ||
| 222 | - v-for="(l, i) in marked(compare[a.id]![side[1]], compare[a.id]![side[2]])" | ||
| 223 | - :key="i" | ||
| 224 | - class="whitespace-pre-wrap break-all px-2" | ||
| 225 | - :class="l.diff ? (side[1] === 'local' ? 'bg-warning/15' : 'bg-info/15') : ''" | ||
| 226 | - >{{ l.line || ' ' }}</div> | ||
| 227 | - </div> | ||
| 228 | - </div> | ||
| 229 | - </div> | ||
| 230 | - </template> | ||
| 231 | - </div> | ||
| 232 | - </div> | ||
| 233 | - </li> | ||
| 234 | - </ul> | ||
| 235 | - </section> | ||
| 236 | - | ||
| 237 | - <p v-if="!groups.length" class="rounded-lg border border-dashed border-default p-6 text-center text-sm text-muted">Nothing to apply.</p> | ||
| 238 | - </div> | ||
| 239 | - </template> | ||
| 240 | - | ||
| 241 | - <template #footer> | ||
| 242 | - <div class="flex w-full items-center justify-between gap-2"> | ||
| 243 | - <span class="text-xs text-muted">{{ response ? '' : `${actions.length} change${actions.length > 1 ? 's' : ''}${conflicts.length ? `, ${conflicts.length} conflict${conflicts.length > 1 ? 's' : ''}` : ''}` }}</span> | ||
| 244 | - <div class="flex gap-2"> | ||
| 245 | - <UButton :label="response ? 'Close' : 'Cancel'" color="neutral" variant="outline" @click="open = false" /> | ||
| 246 | - <UButton v-if="!response" label="Apply" icon="i-lucide-check" :loading="busy" :disabled="!actions.length" @click="apply" /> | ||
| 247 | - </div> | ||
| 248 | - </div> | ||
| 249 | - </template> | ||
| 250 | - </UModal> | ||
| 251 | -</template> | ||
added
packages/ui/app/components/CatalogBrowser.vue +139 -0 | new file mode 100644 | ||
| @@ -0,0 +1,139 @@ | ||
| 1 | +<script setup lang="ts"> | |
| 2 | +import type { CatalogItem, CatalogItemType } from '@skilly/core' | |
| 3 | + | |
| 4 | +/** | |
| 5 | + * Catalog items screen: header, left column (search, type chips, tag chips, list) and the detail panel. | |
| 6 | + * `selected` = item shown (route `/catalog/[type]/[name]`), `creating` = type of a new draft (`/catalog/new`). | |
| 7 | + * Filters live in shared state so they survive navigating between items. | |
| 8 | + */ | |
| 9 | +const props = defineProps<{ selected?: { type: CatalogItemType, name: string }, creating?: CatalogItemType }>() | |
| 10 | + | |
| 11 | +const { data, loading, error, refresh, exists, items, usage } = useCatalog() | |
| 12 | +const names = useScopeNames() | |
| 13 | + | |
| 14 | +const filters = useState('catalog-filters', () => ({ type: 'all' as 'all' | CatalogItemType, search: '', tags: [] as string[] })) | |
| 15 | +const route = useRoute() | |
| 16 | +if (!props.selected && !props.creating && CAT_TYPES.includes(route.query.type as CatalogItemType)) filters.value.type = route.query.type as CatalogItemType | |
| 17 | + | |
| 18 | +onMounted(refresh) | |
| 19 | + | |
| 20 | +const byType = computed(() => { | |
| 21 | + const m = Object.fromEntries(CAT_TYPES.map(t => [t, [] as CatalogItem[]])) as Record<CatalogItemType, CatalogItem[]> | |
| 22 | + for (const i of items.value) m[i.type]?.push(i) | |
| 23 | + return m | |
| 24 | +}) | |
| 25 | + | |
| 26 | +const pool = computed(() => (filters.value.type === 'all' ? items.value : byType.value[filters.value.type])) | |
| 27 | +const allTags = computed(() => [...new Set(pool.value.flatMap(i => i.tags))].sort()) | |
| 28 | +watch(() => filters.value.type, () => { filters.value.tags = [] }) | |
| 29 | + | |
| 30 | +const rows = computed(() => { | |
| 31 | + const q = filters.value.search.trim().toLowerCase() | |
| 32 | + return pool.value | |
| 33 | + .filter(i => (!q || `${i.name} ${i.description ?? ''} ${i.tags.join(' ')}`.toLowerCase().includes(q)) | |
| 34 | + && filters.value.tags.every(t => i.tags.includes(t))) | |
| 35 | + .sort((a, b) => a.name.localeCompare(b.name) || a.type.localeCompare(b.type)) | |
| 36 | +}) | |
| 37 | +const filtered = computed(() => !!filters.value.search.trim() || filters.value.tags.length > 0) | |
| 38 | + | |
| 39 | +const typeChips = computed(() => [ | |
| 40 | + { value: 'all' as const, label: 'All' }, | |
| 41 | + ...CAT_ORDER.map(t => ({ value: t, label: CAT_META[t].label })) | |
| 42 | +]) | |
| 43 | + | |
| 44 | +function toggleTag(t: string) { | |
| 45 | + const cur = filters.value.tags | |
| 46 | + filters.value.tags = cur.includes(t) ? cur.filter(x => x !== t) : [...cur, t] | |
| 47 | +} | |
| 48 | + | |
| 49 | +const usedBy = (i: CatalogItem) => usage.value[itemKey(i.type, i.name)] ?? [] | |
| 50 | +const isSel = (i: CatalogItem) => props.selected?.type === i.type && props.selected.name === i.name | |
| 51 | +const href = (i: CatalogItem) => `/catalog/${i.type}/${encodeURIComponent(i.name)}` | |
| 52 | +const ORIGIN_TAG = { import: 'imported', marketplace: 'marketplace' } as const | |
| 53 | + | |
| 54 | +const newType = computed<CatalogItemType>(() => props.selected?.type ?? props.creating ?? (filters.value.type === 'all' ? 'skill' : filters.value.type)) | |
| 55 | +const errors = computed(() => data.value?.snapshot.errors ?? []) | |
| 56 | +const errorsOpen = ref(false) | |
| 57 | +</script> | |
| 58 | + | |
| 59 | +<template> | |
| 60 | + <SkPage wide> | |
| 61 | + <CatalogHeader tab="items" :new-type="newType" /> | |
| 62 | + | |
| 63 | + <div v-if="loading && !data" class="grid gap-4 md:grid-cols-[minmax(200px,280px)_minmax(0,1fr)]"> | |
| 64 | + <USkeleton class="h-96 rounded-[20px]" /> | |
| 65 | + <USkeleton class="h-96 rounded-[20px]" /> | |
| 66 | + </div> | |
| 67 | + <SkNote v-else-if="error && !data" tone="accent"> | |
| 68 | + <p class="m-0 font-medium">Could not load the catalog</p> | |
| 69 | + <p class="mt-1 mb-0 text-xs">{{ error }}</p> | |
| 70 | + <template #aside><SkButton size="sm" label="Retry" @click="refresh" /></template> | |
| 71 | + </SkNote> | |
| 72 | + <CatalogSetupCard v-else-if="data && !exists" /> | |
| 73 | + | |
| 74 | + <template v-else-if="data"> | |
| 75 | + <SkNote v-if="errors.length" tone="accent"> | |
| 76 | + <button type="button" class="cursor-pointer border-0 bg-transparent p-0 text-left font-medium text-inherit" @click="errorsOpen = !errorsOpen"> | |
| 77 | + {{ errors.length }} item{{ errors.length > 1 ? 's' : '' }} could not be read | |
| 78 | + <span class="ml-1 text-xs font-normal underline">{{ errorsOpen ? 'Hide' : 'Details' }}</span> | |
| 79 | + </button> | |
| 80 | + <ul v-if="errorsOpen" class="mt-1.5 mb-0 list-none p-0 text-xs"> | |
| 81 | + <li v-for="e in errors" :key="e.path"><span class="font-mono">{{ e.path }}</span>: {{ e.message }}</li> | |
| 82 | + </ul> | |
| 83 | + </SkNote> | |
| 84 | + | |
| 85 | + <div class="grid items-start gap-4 md:grid-cols-[minmax(200px,280px)_minmax(0,1fr)]"> | |
| 86 | + <!-- Left column --> | |
| 87 | + <div class="flex min-w-0 flex-col gap-2.5 md:sticky md:top-[76px]"> | |
| 88 | + <UInput v-model="filters.search" placeholder="Search name, tag or description" aria-label="Search the catalog" size="lg" class="w-full" :ui="{ base: 'bg-sk-panel' }"> | |
| 89 | + <template v-if="filters.search" #trailing> | |
| 90 | + <SkButton size="xs" variant="ghost" icon="i-lucide-x" aria-label="Clear search" @click="filters.search = ''" /> | |
| 91 | + </template> | |
| 92 | + </UInput> | |
| 93 | + <div class="flex flex-wrap gap-1" role="group" aria-label="Filter by type"> | |
| 94 | + <SkChip v-for="c in typeChips" :key="c.value" :active="filters.type === c.value" @click="filters.type = c.value">{{ c.label }}</SkChip> | |
| 95 | + </div> | |
| 96 | + <div v-if="allTags.length" class="flex flex-wrap items-center gap-1" role="group" aria-label="Filter by tag"> | |
| 97 | + <SkSectionLabel class="mr-1">Tags</SkSectionLabel> | |
| 98 | + <SkChip v-for="t in allTags" :key="t" :active="filters.tags.includes(t)" @click="toggleTag(t)">{{ t }}</SkChip> | |
| 99 | + </div> | |
| 100 | + | |
| 101 | + <SkPanel class="max-h-[calc(100vh-340px)] min-h-40 overflow-y-auto"> | |
| 102 | + <NuxtLink | |
| 103 | + v-for="i in rows" | |
| 104 | + :key="`${i.type}/${i.name}`" | |
| 105 | + :to="href(i)" | |
| 106 | + class="flex flex-col gap-0.5 border-t border-sk-line2 py-2.5 pr-[18px] pl-3.5 text-sk-text first:border-t-0 hover:bg-sk-hover" | |
| 107 | + :class="isSel(i) && 'bg-sk-sel hover:bg-sk-sel'" | |
| 108 | + :aria-current="isSel(i) ? 'page' : undefined" | |
| 109 | + > | |
| 110 | + <span class="flex items-baseline gap-2"> | |
| 111 | + <span class="min-w-0 flex-1 truncate font-medium">{{ i.name }}</span> | |
| 112 | + <SkTypeTag :type="i.type" /> | |
| 113 | + </span> | |
| 114 | + <span class="truncate text-xs text-sk-muted">{{ i.description || 'No description' }}</span> | |
| 115 | + <span class="flex gap-2 font-mono text-[11px] text-sk-faint"> | |
| 116 | + <span v-if="i.origin && i.origin.kind !== 'created'">{{ ORIGIN_TAG[i.origin.kind] }}</span> | |
| 117 | + <span v-if="usedBy(i).length" :title="usedBy(i).map(s => scopeLabel(s, names)).join(', ')">used in {{ usedBy(i).length }}</span> | |
| 118 | + <span v-else>unused</span> | |
| 119 | + </span> | |
| 120 | + </NuxtLink> | |
| 121 | + <div v-if="!rows.length" class="px-6 py-7 text-center text-sk-faint"> | |
| 122 | + <p class="m-0">{{ filtered ? 'No items match.' : `No ${filters.type === 'all' ? 'items' : CAT_META[filters.type].label.toLowerCase()} in the catalog yet.` }}</p> | |
| 123 | + <SkButton v-if="filtered" class="mt-2" size="xs" variant="ghost" label="Clear filters" @click="filters.search = ''; filters.tags = []" /> | |
| 124 | + </div> | |
| 125 | + </SkPanel> | |
| 126 | + </div> | |
| 127 | + | |
| 128 | + <!-- Detail --> | |
| 129 | + <CatalogItemEditor v-if="creating" :key="`new/${creating}`" :type="creating" /> | |
| 130 | + <CatalogItemEditor v-else-if="selected" :key="`${selected.type}/${selected.name}`" :type="selected.type" :name="selected.name" /> | |
| 131 | + <SkPanel v-else> | |
| 132 | + <SkEmpty :title="items.length ? 'Pick an item' : 'Your catalog is empty'"> | |
| 133 | + {{ items.length ? 'Select an item on the left to read or edit it.' : 'Create an item, add one from a project with "Add to catalog", or import from Marketplaces.' }} | |
| 134 | + </SkEmpty> | |
| 135 | + </SkPanel> | |
| 136 | + </div> | |
| 137 | + </template> | |
| 138 | + </SkPage> | |
| 139 | +</template> | |
| new file mode 100644 | |||
| @@ -0,0 +1,139 @@ | |||
| 1 | +<script setup lang="ts"> | ||
| 2 | +import type { CatalogItem, CatalogItemType } from '@skilly/core' | ||
| 3 | + | ||
| 4 | +/** | ||
| 5 | + * Catalog items screen: header, left column (search, type chips, tag chips, list) and the detail panel. | ||
| 6 | + * `selected` = item shown (route `/catalog/[type]/[name]`), `creating` = type of a new draft (`/catalog/new`). | ||
| 7 | + * Filters live in shared state so they survive navigating between items. | ||
| 8 | + */ | ||
| 9 | +const props = defineProps<{ selected?: { type: CatalogItemType, name: string }, creating?: CatalogItemType }>() | ||
| 10 | + | ||
| 11 | +const { data, loading, error, refresh, exists, items, usage } = useCatalog() | ||
| 12 | +const names = useScopeNames() | ||
| 13 | + | ||
| 14 | +const filters = useState('catalog-filters', () => ({ type: 'all' as 'all' | CatalogItemType, search: '', tags: [] as string[] })) | ||
| 15 | +const route = useRoute() | ||
| 16 | +if (!props.selected && !props.creating && CAT_TYPES.includes(route.query.type as CatalogItemType)) filters.value.type = route.query.type as CatalogItemType | ||
| 17 | + | ||
| 18 | +onMounted(refresh) | ||
| 19 | + | ||
| 20 | +const byType = computed(() => { | ||
| 21 | + const m = Object.fromEntries(CAT_TYPES.map(t => [t, [] as CatalogItem[]])) as Record<CatalogItemType, CatalogItem[]> | ||
| 22 | + for (const i of items.value) m[i.type]?.push(i) | ||
| 23 | + return m | ||
| 24 | +}) | ||
| 25 | + | ||
| 26 | +const pool = computed(() => (filters.value.type === 'all' ? items.value : byType.value[filters.value.type])) | ||
| 27 | +const allTags = computed(() => [...new Set(pool.value.flatMap(i => i.tags))].sort()) | ||
| 28 | +watch(() => filters.value.type, () => { filters.value.tags = [] }) | ||
| 29 | + | ||
| 30 | +const rows = computed(() => { | ||
| 31 | + const q = filters.value.search.trim().toLowerCase() | ||
| 32 | + return pool.value | ||
| 33 | + .filter(i => (!q || `${i.name} ${i.description ?? ''} ${i.tags.join(' ')}`.toLowerCase().includes(q)) | ||
| 34 | + && filters.value.tags.every(t => i.tags.includes(t))) | ||
| 35 | + .sort((a, b) => a.name.localeCompare(b.name) || a.type.localeCompare(b.type)) | ||
| 36 | +}) | ||
| 37 | +const filtered = computed(() => !!filters.value.search.trim() || filters.value.tags.length > 0) | ||
| 38 | + | ||
| 39 | +const typeChips = computed(() => [ | ||
| 40 | + { value: 'all' as const, label: 'All' }, | ||
| 41 | + ...CAT_ORDER.map(t => ({ value: t, label: CAT_META[t].label })) | ||
| 42 | +]) | ||
| 43 | + | ||
| 44 | +function toggleTag(t: string) { | ||
| 45 | + const cur = filters.value.tags | ||
| 46 | + filters.value.tags = cur.includes(t) ? cur.filter(x => x !== t) : [...cur, t] | ||
| 47 | +} | ||
| 48 | + | ||
| 49 | +const usedBy = (i: CatalogItem) => usage.value[itemKey(i.type, i.name)] ?? [] | ||
| 50 | +const isSel = (i: CatalogItem) => props.selected?.type === i.type && props.selected.name === i.name | ||
| 51 | +const href = (i: CatalogItem) => `/catalog/${i.type}/${encodeURIComponent(i.name)}` | ||
| 52 | +const ORIGIN_TAG = { import: 'imported', marketplace: 'marketplace' } as const | ||
| 53 | + | ||
| 54 | +const newType = computed<CatalogItemType>(() => props.selected?.type ?? props.creating ?? (filters.value.type === 'all' ? 'skill' : filters.value.type)) | ||
| 55 | +const errors = computed(() => data.value?.snapshot.errors ?? []) | ||
| 56 | +const errorsOpen = ref(false) | ||
| 57 | +</script> | ||
| 58 | + | ||
| 59 | +<template> | ||
| 60 | + <SkPage wide> | ||
| 61 | + <CatalogHeader tab="items" :new-type="newType" /> | ||
| 62 | + | ||
| 63 | + <div v-if="loading && !data" class="grid gap-4 md:grid-cols-[minmax(200px,280px)_minmax(0,1fr)]"> | ||
| 64 | + <USkeleton class="h-96 rounded-[20px]" /> | ||
| 65 | + <USkeleton class="h-96 rounded-[20px]" /> | ||
| 66 | + </div> | ||
| 67 | + <SkNote v-else-if="error && !data" tone="accent"> | ||
| 68 | + <p class="m-0 font-medium">Could not load the catalog</p> | ||
| 69 | + <p class="mt-1 mb-0 text-xs">{{ error }}</p> | ||
| 70 | + <template #aside><SkButton size="sm" label="Retry" @click="refresh" /></template> | ||
| 71 | + </SkNote> | ||
| 72 | + <CatalogSetupCard v-else-if="data && !exists" /> | ||
| 73 | + | ||
| 74 | + <template v-else-if="data"> | ||
| 75 | + <SkNote v-if="errors.length" tone="accent"> | ||
| 76 | + <button type="button" class="cursor-pointer border-0 bg-transparent p-0 text-left font-medium text-inherit" @click="errorsOpen = !errorsOpen"> | ||
| 77 | + {{ errors.length }} item{{ errors.length > 1 ? 's' : '' }} could not be read | ||
| 78 | + <span class="ml-1 text-xs font-normal underline">{{ errorsOpen ? 'Hide' : 'Details' }}</span> | ||
| 79 | + </button> | ||
| 80 | + <ul v-if="errorsOpen" class="mt-1.5 mb-0 list-none p-0 text-xs"> | ||
| 81 | + <li v-for="e in errors" :key="e.path"><span class="font-mono">{{ e.path }}</span>: {{ e.message }}</li> | ||
| 82 | + </ul> | ||
| 83 | + </SkNote> | ||
| 84 | + | ||
| 85 | + <div class="grid items-start gap-4 md:grid-cols-[minmax(200px,280px)_minmax(0,1fr)]"> | ||
| 86 | + <!-- Left column --> | ||
| 87 | + <div class="flex min-w-0 flex-col gap-2.5 md:sticky md:top-[76px]"> | ||
| 88 | + <UInput v-model="filters.search" placeholder="Search name, tag or description" aria-label="Search the catalog" size="lg" class="w-full" :ui="{ base: 'bg-sk-panel' }"> | ||
| 89 | + <template v-if="filters.search" #trailing> | ||
| 90 | + <SkButton size="xs" variant="ghost" icon="i-lucide-x" aria-label="Clear search" @click="filters.search = ''" /> | ||
| 91 | + </template> | ||
| 92 | + </UInput> | ||
| 93 | + <div class="flex flex-wrap gap-1" role="group" aria-label="Filter by type"> | ||
| 94 | + <SkChip v-for="c in typeChips" :key="c.value" :active="filters.type === c.value" @click="filters.type = c.value">{{ c.label }}</SkChip> | ||
| 95 | + </div> | ||
| 96 | + <div v-if="allTags.length" class="flex flex-wrap items-center gap-1" role="group" aria-label="Filter by tag"> | ||
| 97 | + <SkSectionLabel class="mr-1">Tags</SkSectionLabel> | ||
| 98 | + <SkChip v-for="t in allTags" :key="t" :active="filters.tags.includes(t)" @click="toggleTag(t)">{{ t }}</SkChip> | ||
| 99 | + </div> | ||
| 100 | + | ||
| 101 | + <SkPanel class="max-h-[calc(100vh-340px)] min-h-40 overflow-y-auto"> | ||
| 102 | + <NuxtLink | ||
| 103 | + v-for="i in rows" | ||
| 104 | + :key="`${i.type}/${i.name}`" | ||
| 105 | + :to="href(i)" | ||
| 106 | + class="flex flex-col gap-0.5 border-t border-sk-line2 py-2.5 pr-[18px] pl-3.5 text-sk-text first:border-t-0 hover:bg-sk-hover" | ||
| 107 | + :class="isSel(i) && 'bg-sk-sel hover:bg-sk-sel'" | ||
| 108 | + :aria-current="isSel(i) ? 'page' : undefined" | ||
| 109 | + > | ||
| 110 | + <span class="flex items-baseline gap-2"> | ||
| 111 | + <span class="min-w-0 flex-1 truncate font-medium">{{ i.name }}</span> | ||
| 112 | + <SkTypeTag :type="i.type" /> | ||
| 113 | + </span> | ||
| 114 | + <span class="truncate text-xs text-sk-muted">{{ i.description || 'No description' }}</span> | ||
| 115 | + <span class="flex gap-2 font-mono text-[11px] text-sk-faint"> | ||
| 116 | + <span v-if="i.origin && i.origin.kind !== 'created'">{{ ORIGIN_TAG[i.origin.kind] }}</span> | ||
| 117 | + <span v-if="usedBy(i).length" :title="usedBy(i).map(s => scopeLabel(s, names)).join(', ')">used in {{ usedBy(i).length }}</span> | ||
| 118 | + <span v-else>unused</span> | ||
| 119 | + </span> | ||
| 120 | + </NuxtLink> | ||
| 121 | + <div v-if="!rows.length" class="px-6 py-7 text-center text-sk-faint"> | ||
| 122 | + <p class="m-0">{{ filtered ? 'No items match.' : `No ${filters.type === 'all' ? 'items' : CAT_META[filters.type].label.toLowerCase()} in the catalog yet.` }}</p> | ||
| 123 | + <SkButton v-if="filtered" class="mt-2" size="xs" variant="ghost" label="Clear filters" @click="filters.search = ''; filters.tags = []" /> | ||
| 124 | + </div> | ||
| 125 | + </SkPanel> | ||
| 126 | + </div> | ||
| 127 | + | ||
| 128 | + <!-- Detail --> | ||
| 129 | + <CatalogItemEditor v-if="creating" :key="`new/${creating}`" :type="creating" /> | ||
| 130 | + <CatalogItemEditor v-else-if="selected" :key="`${selected.type}/${selected.name}`" :type="selected.type" :name="selected.name" /> | ||
| 131 | + <SkPanel v-else> | ||
| 132 | + <SkEmpty :title="items.length ? 'Pick an item' : 'Your catalog is empty'"> | ||
| 133 | + {{ items.length ? 'Select an item on the left to read or edit it.' : 'Create an item, add one from a project with "Add to catalog", or import from Marketplaces.' }} | ||
| 134 | + </SkEmpty> | ||
| 135 | + </SkPanel> | ||
| 136 | + </div> | ||
| 137 | + </template> | ||
| 138 | + </SkPage> | ||
| 139 | +</template> | ||
added
packages/ui/app/components/CatalogDeleteModal.vue +44 -0 | new file mode 100644 | ||
| @@ -0,0 +1,44 @@ | ||
| 1 | +<script setup lang="ts"> | |
| 2 | +/** | |
| 3 | + * Delete confirmation for a catalog item or preset. `usedBy` lists the scopes referencing it (known up front); | |
| 4 | + * `conflict` is set after the server answered 409, which turns the confirm button into "Delete anyway". | |
| 5 | + */ | |
| 6 | +const props = defineProps<{ | |
| 7 | + kind: 'item' | 'preset' | |
| 8 | + name: string | |
| 9 | + usedBy: string[] | |
| 10 | + conflict: string | null | |
| 11 | + deleting?: boolean | |
| 12 | +}>() | |
| 13 | +const open = defineModel<boolean>('open', { default: false }) | |
| 14 | +const emit = defineEmits<{ confirm: [force: boolean] }>() | |
| 15 | + | |
| 16 | +const names = useScopeNames() | |
| 17 | +const effect = computed(() => props.kind === 'preset' | |
| 18 | + ? 'The preset is removed from the catalog. Its items stay in the catalog.' | |
| 19 | + : 'The item is removed from the catalog folder. Scopes where it was already written keep their files.') | |
| 20 | +</script> | |
| 21 | + | |
| 22 | +<template> | |
| 23 | + <SkModal v-model:open="open" :title="`Delete ${name}`" width="sm"> | |
| 24 | + <div class="flex flex-col gap-3"> | |
| 25 | + <p class="m-0 text-sk-muted">{{ effect }}</p> | |
| 26 | + <SkNote v-if="conflict || usedBy.length" tone="accent"> | |
| 27 | + <p class="m-0 font-medium">{{ conflict ? `This ${kind} is still used` : 'Used in' }}</p> | |
| 28 | + <p class="mt-1 mb-0 text-xs"> | |
| 29 | + {{ kind === 'preset' ? 'These scopes enable it' : 'These scopes reference it' }}. | |
| 30 | + If you delete it anyway, they report it as missing from the catalog. | |
| 31 | + </p> | |
| 32 | + <ul class="mt-1.5 mb-0 list-none p-0 font-mono text-xs"> | |
| 33 | + <li v-for="s in usedBy" :key="s">{{ scopeLabel(s, names) }}</li> | |
| 34 | + </ul> | |
| 35 | + </SkNote> | |
| 36 | + </div> | |
| 37 | + <template #footer> | |
| 38 | + <div class="flex w-full justify-end gap-2"> | |
| 39 | + <SkButton label="Cancel" @click="open = false" /> | |
| 40 | + <SkButton variant="danger" :label="conflict ? 'Delete anyway' : 'Delete'" :loading="deleting" @click="emit('confirm', !!conflict)" /> | |
| 41 | + </div> | |
| 42 | + </template> | |
| 43 | + </SkModal> | |
| 44 | +</template> | |
| new file mode 100644 | |||
| @@ -0,0 +1,44 @@ | |||
| 1 | +<script setup lang="ts"> | ||
| 2 | +/** | ||
| 3 | + * Delete confirmation for a catalog item or preset. `usedBy` lists the scopes referencing it (known up front); | ||
| 4 | + * `conflict` is set after the server answered 409, which turns the confirm button into "Delete anyway". | ||
| 5 | + */ | ||
| 6 | +const props = defineProps<{ | ||
| 7 | + kind: 'item' | 'preset' | ||
| 8 | + name: string | ||
| 9 | + usedBy: string[] | ||
| 10 | + conflict: string | null | ||
| 11 | + deleting?: boolean | ||
| 12 | +}>() | ||
| 13 | +const open = defineModel<boolean>('open', { default: false }) | ||
| 14 | +const emit = defineEmits<{ confirm: [force: boolean] }>() | ||
| 15 | + | ||
| 16 | +const names = useScopeNames() | ||
| 17 | +const effect = computed(() => props.kind === 'preset' | ||
| 18 | + ? 'The preset is removed from the catalog. Its items stay in the catalog.' | ||
| 19 | + : 'The item is removed from the catalog folder. Scopes where it was already written keep their files.') | ||
| 20 | +</script> | ||
| 21 | + | ||
| 22 | +<template> | ||
| 23 | + <SkModal v-model:open="open" :title="`Delete ${name}`" width="sm"> | ||
| 24 | + <div class="flex flex-col gap-3"> | ||
| 25 | + <p class="m-0 text-sk-muted">{{ effect }}</p> | ||
| 26 | + <SkNote v-if="conflict || usedBy.length" tone="accent"> | ||
| 27 | + <p class="m-0 font-medium">{{ conflict ? `This ${kind} is still used` : 'Used in' }}</p> | ||
| 28 | + <p class="mt-1 mb-0 text-xs"> | ||
| 29 | + {{ kind === 'preset' ? 'These scopes enable it' : 'These scopes reference it' }}. | ||
| 30 | + If you delete it anyway, they report it as missing from the catalog. | ||
| 31 | + </p> | ||
| 32 | + <ul class="mt-1.5 mb-0 list-none p-0 font-mono text-xs"> | ||
| 33 | + <li v-for="s in usedBy" :key="s">{{ scopeLabel(s, names) }}</li> | ||
| 34 | + </ul> | ||
| 35 | + </SkNote> | ||
| 36 | + </div> | ||
| 37 | + <template #footer> | ||
| 38 | + <div class="flex w-full justify-end gap-2"> | ||
| 39 | + <SkButton label="Cancel" @click="open = false" /> | ||
| 40 | + <SkButton variant="danger" :label="conflict ? 'Delete anyway' : 'Delete'" :loading="deleting" @click="emit('confirm', !!conflict)" /> | ||
| 41 | + </div> | ||
| 42 | + </template> | ||
| 43 | + </SkModal> | ||
| 44 | +</template> | ||
added
packages/ui/app/components/CatalogField.vue +17 -0 | new file mode 100644 | ||
| @@ -0,0 +1,17 @@ | ||
| 1 | +<script setup lang="ts"> | |
| 2 | +/** Form field wrapper for the catalog editors: label (500), optional hint, inline error in accent text. */ | |
| 3 | +defineProps<{ label: string, hint?: string, error?: string | null, required?: boolean }>() | |
| 4 | +</script> | |
| 5 | + | |
| 6 | +<template> | |
| 7 | + <div class="flex min-w-0 flex-col gap-1.5"> | |
| 8 | + <label class="flex flex-col gap-1.5"> | |
| 9 | + <span class="flex flex-wrap items-baseline gap-x-2"> | |
| 10 | + <span class="font-medium">{{ label }}<span v-if="required" class="ml-0.5 text-sk-accent-text" aria-hidden="true">*</span></span> | |
| 11 | + <span v-if="hint" class="text-xs font-normal text-sk-faint">{{ hint }}</span> | |
| 12 | + </span> | |
| 13 | + <slot /> | |
| 14 | + </label> | |
| 15 | + <span v-if="error" class="text-xs text-sk-accent-text">{{ error }}</span> | |
| 16 | + </div> | |
| 17 | +</template> | |
| new file mode 100644 | |||
| @@ -0,0 +1,17 @@ | |||
| 1 | +<script setup lang="ts"> | ||
| 2 | +/** Form field wrapper for the catalog editors: label (500), optional hint, inline error in accent text. */ | ||
| 3 | +defineProps<{ label: string, hint?: string, error?: string | null, required?: boolean }>() | ||
| 4 | +</script> | ||
| 5 | + | ||
| 6 | +<template> | ||
| 7 | + <div class="flex min-w-0 flex-col gap-1.5"> | ||
| 8 | + <label class="flex flex-col gap-1.5"> | ||
| 9 | + <span class="flex flex-wrap items-baseline gap-x-2"> | ||
| 10 | + <span class="font-medium">{{ label }}<span v-if="required" class="ml-0.5 text-sk-accent-text" aria-hidden="true">*</span></span> | ||
| 11 | + <span v-if="hint" class="text-xs font-normal text-sk-faint">{{ hint }}</span> | ||
| 12 | + </span> | ||
| 13 | + <slot /> | ||
| 14 | + </label> | ||
| 15 | + <span v-if="error" class="text-xs text-sk-accent-text">{{ error }}</span> | ||
| 16 | + </div> | ||
| 17 | +</template> | ||
deleted
packages/ui/app/components/CatalogGitDialog.vue +0 -96 | deleted file mode 100644 | ||
| @@ -1,96 +0,0 @@ | ||
| 1 | -<script setup lang="ts"> | |
| 2 | -const { git, refresh } = useCatalog() | |
| 3 | -const { open, busy, error, run } = useCatalogGit() | |
| 4 | - | |
| 5 | -const remote = ref('') | |
| 6 | -const message = ref('') | |
| 7 | - | |
| 8 | -watch(open, (v) => { | |
| 9 | - if (!v) return | |
| 10 | - error.value = null | |
| 11 | - refresh() | |
| 12 | -}) | |
| 13 | -watch(() => git.value?.remote, (r) => { remote.value = r ?? '' }, { immediate: true }) | |
| 14 | - | |
| 15 | -const dirty = computed(() => git.value?.dirty ?? []) | |
| 16 | -const conflicts = computed(() => git.value?.conflicts ?? []) | |
| 17 | -const ready = computed(() => !!git.value?.isRepo) | |
| 18 | - | |
| 19 | -async function commit() { | |
| 20 | - if (await run('commit', { message: message.value.trim() }, 'Committed')) message.value = '' | |
| 21 | -} | |
| 22 | -</script> | |
| 23 | - | |
| 24 | -<template> | |
| 25 | - <UModal v-model:open="open" title="Catalog git" description="Commit, pull and push the catalog repository." :ui="{ content: 'max-w-xl' }"> | |
| 26 | - <template #body> | |
| 27 | - <div v-if="!git" class="text-sm text-muted">The catalog is not available yet.</div> | |
| 28 | - <div v-else-if="!git.isRepo" class="text-sm text-muted">The catalog folder is not a git repository.</div> | |
| 29 | - <div v-else class="space-y-5"> | |
| 30 | - <UAlert v-if="error" color="error" variant="subtle" icon="i-lucide-circle-alert" title="Git error" :description="error" class="whitespace-pre-wrap" /> | |
| 31 | - | |
| 32 | - <!-- Status --> | |
| 33 | - <div class="grid grid-cols-2 gap-x-4 gap-y-1 text-sm"> | |
| 34 | - <div><span class="text-muted">Branch </span><span class="font-mono">{{ git.branch ?? '-' }}</span></div> | |
| 35 | - <div class="truncate"><span class="text-muted">Remote </span><span class="font-mono" :title="git.remote">{{ git.remote ?? 'none' }}</span></div> | |
| 36 | - <div class="flex gap-3"> | |
| 37 | - <span :class="git.ahead ? 'text-info' : 'text-muted'">{{ git.ahead }} ahead</span> | |
| 38 | - <span :class="git.behind ? 'text-info' : 'text-muted'">{{ git.behind }} behind</span> | |
| 39 | - </div> | |
| 40 | - <div class="truncate" :title="git.lastCommit?.message"> | |
| 41 | - <span class="text-muted">Last commit </span> | |
| 42 | - <span v-if="git.lastCommit"><span class="font-mono">{{ git.lastCommit.hash.slice(0, 7) }}</span> {{ git.lastCommit.message }}</span> | |
| 43 | - <span v-else class="text-muted">none</span> | |
| 44 | - </div> | |
| 45 | - </div> | |
| 46 | - | |
| 47 | - <!-- Remote --> | |
| 48 | - <form class="space-y-1" @submit.prevent="run('remote', { url: remote.trim() }, 'Remote updated')"> | |
| 49 | - <label class="text-xs font-medium text-muted">Remote URL (any git host)</label> | |
| 50 | - <div class="flex gap-2"> | |
| 51 | - <UInput v-model="remote" placeholder="git@github.com:you/agent-kit.git" class="flex-1" :ui="{ base: 'font-mono' }" /> | |
| 52 | - <UButton type="submit" label="Set remote" color="neutral" variant="outline" :loading="busy === 'remote'" :disabled="!remote.trim() || remote.trim() === git.remote" /> | |
| 53 | - </div> | |
| 54 | - </form> | |
| 55 | - | |
| 56 | - <!-- Conflicts --> | |
| 57 | - <div v-if="conflicts.length" class="space-y-2"> | |
| 58 | - <h3 class="flex items-center gap-1.5 text-sm font-semibold text-error"><UIcon name="i-lucide-triangle-alert" class="size-4" />Conflicts ({{ conflicts.length }})</h3> | |
| 59 | - <ul class="divide-y divide-default rounded-lg border border-default text-sm"> | |
| 60 | - <li v-for="c in conflicts" :key="c" class="flex items-center gap-2 px-3 py-2"> | |
| 61 | - <span class="min-w-0 flex-1 truncate font-mono text-xs" :title="c">{{ c }}</span> | |
| 62 | - <UButton label="Keep mine" size="xs" color="neutral" variant="outline" :disabled="!!busy" @click="run('resolve', { path: c, side: 'ours' }, 'Conflict resolved')" /> | |
| 63 | - <UButton label="Use remote" size="xs" color="neutral" variant="outline" :disabled="!!busy" @click="run('resolve', { path: c, side: 'theirs' }, 'Conflict resolved')" /> | |
| 64 | - </li> | |
| 65 | - </ul> | |
| 66 | - </div> | |
| 67 | - | |
| 68 | - <!-- Commit --> | |
| 69 | - <div class="space-y-2"> | |
| 70 | - <h3 class="text-sm font-semibold">Uncommitted changes ({{ dirty.length }})</h3> | |
| 71 | - <ul v-if="dirty.length" class="max-h-32 overflow-auto rounded-lg border border-default text-xs"> | |
| 72 | - <li v-for="d in dirty" :key="d" class="truncate px-3 py-1 font-mono">{{ d }}</li> | |
| 73 | - </ul> | |
| 74 | - <p v-else class="text-xs text-muted">Working tree clean.</p> | |
| 75 | - <form class="flex gap-2" @submit.prevent="commit"> | |
| 76 | - <UInput v-model="message" placeholder="Commit message" class="flex-1" :disabled="!dirty.length" /> | |
| 77 | - <UButton type="submit" label="Commit" icon="i-lucide-git-commit-horizontal" :loading="busy === 'commit'" :disabled="!dirty.length || !message.trim()" /> | |
| 78 | - </form> | |
| 79 | - </div> | |
| 80 | - </div> | |
| 81 | - </template> | |
| 82 | - | |
| 83 | - <template #footer> | |
| 84 | - <div class="flex w-full flex-wrap items-center justify-between gap-2"> | |
| 85 | - <div class="flex gap-2"> | |
| 86 | - <UButton label="Pull" icon="i-lucide-arrow-down" color="neutral" variant="outline" :loading="busy === 'pull'" :disabled="!ready || !git?.remote || !!busy" @click="run('pull', undefined, 'Pulled')" /> | |
| 87 | - <UButton label="Push" icon="i-lucide-arrow-up" color="neutral" variant="outline" :loading="busy === 'push'" :disabled="!ready || !git?.remote || !!busy" @click="run('push', undefined, 'Pushed')" /> | |
| 88 | - </div> | |
| 89 | - <div class="flex gap-2"> | |
| 90 | - <UButton label="Close" color="neutral" variant="ghost" @click="open = false" /> | |
| 91 | - <UButton label="Sync" icon="i-lucide-refresh-cw" :loading="busy === 'sync'" :disabled="!ready || !git?.remote || !!busy" @click="run('sync', undefined, 'Catalog synced')" /> | |
| 92 | - </div> | |
| 93 | - </div> | |
| 94 | - </template> | |
| 95 | - </UModal> | |
| 96 | -</template> | |
| deleted file mode 100644 | |||
| @@ -1,96 +0,0 @@ | |||
| 1 | -<script setup lang="ts"> | ||
| 2 | -const { git, refresh } = useCatalog() | ||
| 3 | -const { open, busy, error, run } = useCatalogGit() | ||
| 4 | - | ||
| 5 | -const remote = ref('') | ||
| 6 | -const message = ref('') | ||
| 7 | - | ||
| 8 | -watch(open, (v) => { | ||
| 9 | - if (!v) return | ||
| 10 | - error.value = null | ||
| 11 | - refresh() | ||
| 12 | -}) | ||
| 13 | -watch(() => git.value?.remote, (r) => { remote.value = r ?? '' }, { immediate: true }) | ||
| 14 | - | ||
| 15 | -const dirty = computed(() => git.value?.dirty ?? []) | ||
| 16 | -const conflicts = computed(() => git.value?.conflicts ?? []) | ||
| 17 | -const ready = computed(() => !!git.value?.isRepo) | ||
| 18 | - | ||
| 19 | -async function commit() { | ||
| 20 | - if (await run('commit', { message: message.value.trim() }, 'Committed')) message.value = '' | ||
| 21 | -} | ||
| 22 | -</script> | ||
| 23 | - | ||
| 24 | -<template> | ||
| 25 | - <UModal v-model:open="open" title="Catalog git" description="Commit, pull and push the catalog repository." :ui="{ content: 'max-w-xl' }"> | ||
| 26 | - <template #body> | ||
| 27 | - <div v-if="!git" class="text-sm text-muted">The catalog is not available yet.</div> | ||
| 28 | - <div v-else-if="!git.isRepo" class="text-sm text-muted">The catalog folder is not a git repository.</div> | ||
| 29 | - <div v-else class="space-y-5"> | ||
| 30 | - <UAlert v-if="error" color="error" variant="subtle" icon="i-lucide-circle-alert" title="Git error" :description="error" class="whitespace-pre-wrap" /> | ||
| 31 | - | ||
| 32 | - <!-- Status --> | ||
| 33 | - <div class="grid grid-cols-2 gap-x-4 gap-y-1 text-sm"> | ||
| 34 | - <div><span class="text-muted">Branch </span><span class="font-mono">{{ git.branch ?? '-' }}</span></div> | ||
| 35 | - <div class="truncate"><span class="text-muted">Remote </span><span class="font-mono" :title="git.remote">{{ git.remote ?? 'none' }}</span></div> | ||
| 36 | - <div class="flex gap-3"> | ||
| 37 | - <span :class="git.ahead ? 'text-info' : 'text-muted'">{{ git.ahead }} ahead</span> | ||
| 38 | - <span :class="git.behind ? 'text-info' : 'text-muted'">{{ git.behind }} behind</span> | ||
| 39 | - </div> | ||
| 40 | - <div class="truncate" :title="git.lastCommit?.message"> | ||
| 41 | - <span class="text-muted">Last commit </span> | ||
| 42 | - <span v-if="git.lastCommit"><span class="font-mono">{{ git.lastCommit.hash.slice(0, 7) }}</span> {{ git.lastCommit.message }}</span> | ||
| 43 | - <span v-else class="text-muted">none</span> | ||
| 44 | - </div> | ||
| 45 | - </div> | ||
| 46 | - | ||
| 47 | - <!-- Remote --> | ||
| 48 | - <form class="space-y-1" @submit.prevent="run('remote', { url: remote.trim() }, 'Remote updated')"> | ||
| 49 | - <label class="text-xs font-medium text-muted">Remote URL (any git host)</label> | ||
| 50 | - <div class="flex gap-2"> | ||
| 51 | - <UInput v-model="remote" placeholder="git@github.com:you/agent-kit.git" class="flex-1" :ui="{ base: 'font-mono' }" /> | ||
| 52 | - <UButton type="submit" label="Set remote" color="neutral" variant="outline" :loading="busy === 'remote'" :disabled="!remote.trim() || remote.trim() === git.remote" /> | ||
| 53 | - </div> | ||
| 54 | - </form> | ||
| 55 | - | ||
| 56 | - <!-- Conflicts --> | ||
| 57 | - <div v-if="conflicts.length" class="space-y-2"> | ||
| 58 | - <h3 class="flex items-center gap-1.5 text-sm font-semibold text-error"><UIcon name="i-lucide-triangle-alert" class="size-4" />Conflicts ({{ conflicts.length }})</h3> | ||
| 59 | - <ul class="divide-y divide-default rounded-lg border border-default text-sm"> | ||
| 60 | - <li v-for="c in conflicts" :key="c" class="flex items-center gap-2 px-3 py-2"> | ||
| 61 | - <span class="min-w-0 flex-1 truncate font-mono text-xs" :title="c">{{ c }}</span> | ||
| 62 | - <UButton label="Keep mine" size="xs" color="neutral" variant="outline" :disabled="!!busy" @click="run('resolve', { path: c, side: 'ours' }, 'Conflict resolved')" /> | ||
| 63 | - <UButton label="Use remote" size="xs" color="neutral" variant="outline" :disabled="!!busy" @click="run('resolve', { path: c, side: 'theirs' }, 'Conflict resolved')" /> | ||
| 64 | - </li> | ||
| 65 | - </ul> | ||
| 66 | - </div> | ||
| 67 | - | ||
| 68 | - <!-- Commit --> | ||
| 69 | - <div class="space-y-2"> | ||
| 70 | - <h3 class="text-sm font-semibold">Uncommitted changes ({{ dirty.length }})</h3> | ||
| 71 | - <ul v-if="dirty.length" class="max-h-32 overflow-auto rounded-lg border border-default text-xs"> | ||
| 72 | - <li v-for="d in dirty" :key="d" class="truncate px-3 py-1 font-mono">{{ d }}</li> | ||
| 73 | - </ul> | ||
| 74 | - <p v-else class="text-xs text-muted">Working tree clean.</p> | ||
| 75 | - <form class="flex gap-2" @submit.prevent="commit"> | ||
| 76 | - <UInput v-model="message" placeholder="Commit message" class="flex-1" :disabled="!dirty.length" /> | ||
| 77 | - <UButton type="submit" label="Commit" icon="i-lucide-git-commit-horizontal" :loading="busy === 'commit'" :disabled="!dirty.length || !message.trim()" /> | ||
| 78 | - </form> | ||
| 79 | - </div> | ||
| 80 | - </div> | ||
| 81 | - </template> | ||
| 82 | - | ||
| 83 | - <template #footer> | ||
| 84 | - <div class="flex w-full flex-wrap items-center justify-between gap-2"> | ||
| 85 | - <div class="flex gap-2"> | ||
| 86 | - <UButton label="Pull" icon="i-lucide-arrow-down" color="neutral" variant="outline" :loading="busy === 'pull'" :disabled="!ready || !git?.remote || !!busy" @click="run('pull', undefined, 'Pulled')" /> | ||
| 87 | - <UButton label="Push" icon="i-lucide-arrow-up" color="neutral" variant="outline" :loading="busy === 'push'" :disabled="!ready || !git?.remote || !!busy" @click="run('push', undefined, 'Pushed')" /> | ||
| 88 | - </div> | ||
| 89 | - <div class="flex gap-2"> | ||
| 90 | - <UButton label="Close" color="neutral" variant="ghost" @click="open = false" /> | ||
| 91 | - <UButton label="Sync" icon="i-lucide-refresh-cw" :loading="busy === 'sync'" :disabled="!ready || !git?.remote || !!busy" @click="run('sync', undefined, 'Catalog synced')" /> | ||
| 92 | - </div> | ||
| 93 | - </div> | ||
| 94 | - </template> | ||
| 95 | - </UModal> | ||
| 96 | -</template> | ||
added
packages/ui/app/components/CatalogHeader.vue +47 -0 | new file mode 100644 | ||
| @@ -0,0 +1,47 @@ | ||
| 1 | +<script setup lang="ts"> | |
| 2 | +import type { CatalogItemType } from '@skilly/core' | |
| 3 | + | |
| 4 | +/** | |
| 5 | + * Header shared by every catalog screen: title, "Your shared library", Items | Presets seg and the New button | |
| 6 | + * (split button on Items: the main action creates the current type, the chevron lists every type). | |
| 7 | + */ | |
| 8 | +const props = defineProps<{ tab: 'items' | 'presets', newType?: CatalogItemType }>() | |
| 9 | + | |
| 10 | +const { exists, items, presets, git, data } = useCatalog() | |
| 11 | + | |
| 12 | +const seg = computed({ | |
| 13 | + get: () => props.tab, | |
| 14 | + set: (v: 'items' | 'presets') => { if (v !== props.tab) navigateTo(v === 'items' ? '/catalog' : '/catalog/presets') } | |
| 15 | +}) | |
| 16 | +const options = computed(() => [ | |
| 17 | + { value: 'items' as const, label: 'Items', count: items.value.length }, | |
| 18 | + { value: 'presets' as const, label: 'Presets', count: presets.value.length } | |
| 19 | +]) | |
| 20 | + | |
| 21 | +const where = computed(() => git.value?.remote ?? data.value?.snapshot.root ?? '') | |
| 22 | +const type = computed(() => props.newType ?? 'skill') | |
| 23 | +const menu = computed(() => [CAT_ORDER.map(t => ({ | |
| 24 | + label: CAT_META[t].newLabel, | |
| 25 | + icon: CAT_META[t].icon, | |
| 26 | + to: `/catalog/new?type=${t}` | |
| 27 | +}))]) | |
| 28 | +</script> | |
| 29 | + | |
| 30 | +<template> | |
| 31 | + <SkPageHeader title="Catalog"> | |
| 32 | + <template #subtitle> | |
| 33 | + Your shared library. | |
| 34 | + <span v-if="where" class="font-mono text-xs" :title="where">{{ where }}</span> | |
| 35 | + </template> | |
| 36 | + <template v-if="exists" #actions> | |
| 37 | + <SkSeg v-model="seg" :options="options" aria-label="Catalog section" /> | |
| 38 | + <SkButton v-if="tab === 'presets'" variant="primary" label="New preset" to="/catalog/presets/new" /> | |
| 39 | + <div v-else class="inline-flex"> | |
| 40 | + <SkButton variant="primary" :label="CAT_META[type].newLabel" :to="`/catalog/new?type=${type}`" class="rounded-r-none" /> | |
| 41 | + <UDropdownMenu :items="menu" :content="{ align: 'end' }"> | |
| 42 | + <SkButton variant="primary" icon="i-lucide-chevron-down" aria-label="New item of another type" class="rounded-l-none border-l border-sk-onink/25" /> | |
| 43 | + </UDropdownMenu> | |
| 44 | + </div> | |
| 45 | + </template> | |
| 46 | + </SkPageHeader> | |
| 47 | +</template> | |
| new file mode 100644 | |||
| @@ -0,0 +1,47 @@ | |||
| 1 | +<script setup lang="ts"> | ||
| 2 | +import type { CatalogItemType } from '@skilly/core' | ||
| 3 | + | ||
| 4 | +/** | ||
| 5 | + * Header shared by every catalog screen: title, "Your shared library", Items | Presets seg and the New button | ||
| 6 | + * (split button on Items: the main action creates the current type, the chevron lists every type). | ||
| 7 | + */ | ||
| 8 | +const props = defineProps<{ tab: 'items' | 'presets', newType?: CatalogItemType }>() | ||
| 9 | + | ||
| 10 | +const { exists, items, presets, git, data } = useCatalog() | ||
| 11 | + | ||
| 12 | +const seg = computed({ | ||
| 13 | + get: () => props.tab, | ||
| 14 | + set: (v: 'items' | 'presets') => { if (v !== props.tab) navigateTo(v === 'items' ? '/catalog' : '/catalog/presets') } | ||
| 15 | +}) | ||
| 16 | +const options = computed(() => [ | ||
| 17 | + { value: 'items' as const, label: 'Items', count: items.value.length }, | ||
| 18 | + { value: 'presets' as const, label: 'Presets', count: presets.value.length } | ||
| 19 | +]) | ||
| 20 | + | ||
| 21 | +const where = computed(() => git.value?.remote ?? data.value?.snapshot.root ?? '') | ||
| 22 | +const type = computed(() => props.newType ?? 'skill') | ||
| 23 | +const menu = computed(() => [CAT_ORDER.map(t => ({ | ||
| 24 | + label: CAT_META[t].newLabel, | ||
| 25 | + icon: CAT_META[t].icon, | ||
| 26 | + to: `/catalog/new?type=${t}` | ||
| 27 | +}))]) | ||
| 28 | +</script> | ||
| 29 | + | ||
| 30 | +<template> | ||
| 31 | + <SkPageHeader title="Catalog"> | ||
| 32 | + <template #subtitle> | ||
| 33 | + Your shared library. | ||
| 34 | + <span v-if="where" class="font-mono text-xs" :title="where">{{ where }}</span> | ||
| 35 | + </template> | ||
| 36 | + <template v-if="exists" #actions> | ||
| 37 | + <SkSeg v-model="seg" :options="options" aria-label="Catalog section" /> | ||
| 38 | + <SkButton v-if="tab === 'presets'" variant="primary" label="New preset" to="/catalog/presets/new" /> | ||
| 39 | + <div v-else class="inline-flex"> | ||
| 40 | + <SkButton variant="primary" :label="CAT_META[type].newLabel" :to="`/catalog/new?type=${type}`" class="rounded-r-none" /> | ||
| 41 | + <UDropdownMenu :items="menu" :content="{ align: 'end' }"> | ||
| 42 | + <SkButton variant="primary" icon="i-lucide-chevron-down" aria-label="New item of another type" class="rounded-l-none border-l border-sk-onink/25" /> | ||
| 43 | + </UDropdownMenu> | ||
| 44 | + </div> | ||
| 45 | + </template> | ||
| 46 | + </SkPageHeader> | ||
| 47 | +</template> | ||
modified
packages/ui/app/components/CatalogItemEditor.vue +312 -202 | @@ -1,12 +1,14 @@ | ||
| 1 | 1 | <script setup lang="ts"> |
| 2 | 2 | import type { CatalogItemDetail, CatalogItemType, HookTemplate, McpTemplate, SaveItemInput } from '@skilly/core' |
| 3 | 3 | |
| 4 | -/** Catalog item editor: Markdown (skill / rule / agent) or form + YAML (mcp / hook). `name` is undefined for a new item. */ | |
| 4 | +/** | |
| 5 | + * Detail panel of a catalog item: read view, then edit mode (Markdown split editor for skill / rule / subagent, | |
| 6 | + * form + YAML for MCP servers and hooks). `name` is undefined for a new item (starts in edit mode). | |
| 7 | + */ | |
| 5 | 8 | const props = defineProps<{ type: CatalogItemType, name?: string }>() |
| 6 | 9 | |
| 7 | 10 | const { request } = useApi() |
| 8 | 11 | const catalog = useCatalog() |
| 9 | -const scopeNames = useScopeNames() | |
| 10 | 12 | const toast = useToast() |
| 11 | 13 | |
| 12 | 14 | const isNew = computed(() => !props.name) |
| @@ -16,6 +18,7 @@ const isMd = computed(() => meta.value.markdown) | ||
| 16 | 18 | const loading = ref(!!props.name) |
| 17 | 19 | const loadError = ref<string | null>(null) |
| 18 | 20 | const detail = ref<CatalogItemDetail | null>(null) |
| 21 | +const editing = ref(!props.name) | |
| 19 | 22 | |
| 20 | 23 | const form = reactive({ name: props.name ?? '', content: '', tags: [] as string[] }) |
| 21 | 24 | const mcp = ref<McpTemplate>({ name: '', transport: 'stdio', command: '', args: [] }) |
| @@ -24,6 +27,7 @@ const extraFiles = ref<Record<string, string>>({}) | ||
| 24 | 27 | const deleteFiles = ref<string[]>([]) |
| 25 | 28 | const original = ref('') |
| 26 | 29 | const view = ref<'edit' | 'split' | 'preview'>('split') |
| 30 | +const formView = ref<'form' | 'yaml'>('form') | |
| 27 | 31 | const saving = ref(false) |
| 28 | 32 | const saveError = ref<string | null>(null) |
| 29 | 33 | |
| @@ -36,6 +40,7 @@ const mainContent = computed(() => (isMd.value ? form.content : yaml.value)) | ||
| 36 | 40 | |
| 37 | 41 | const snapshot = () => JSON.stringify([form.name, mainContent.value, form.tags, extraFiles.value, deleteFiles.value]) |
| 38 | 42 | const dirty = computed(() => snapshot() !== original.value) |
| 43 | +const guarded = computed(() => editing.value && dirty.value) | |
| 39 | 44 | |
| 40 | 45 | function seed(d: CatalogItemDetail | null) { |
| 41 | 46 | detail.value = d |
| @@ -48,7 +53,7 @@ function seed(d: CatalogItemDetail | null) { | ||
| 48 | 53 | if (props.type === 'mcp' && d.template) mcp.value = structuredClone(toRaw(d.template)) as McpTemplate |
| 49 | 54 | if (props.type === 'hook' && d.template) hook.value = structuredClone(toRaw(d.template)) as HookTemplate |
| 50 | 55 | } else if (isMd.value) { |
| 51 | - form.content = newItemContent(props.type, form.name || 'new-item') | |
| 56 | + form.content = newItemContent(props.type, form.name) | |
| 52 | 57 | } |
| 53 | 58 | nextTick(() => { original.value = snapshot() }) |
| 54 | 59 | } |
| @@ -71,29 +76,49 @@ onMounted(() => { | ||
| 71 | 76 | load() |
| 72 | 77 | }) |
| 73 | 78 | |
| 74 | -/* ------------------------------------------------ Markdown fields */ | |
| 79 | +/* ------------------------------------------------ Markdown fields (synced with the frontmatter) */ | |
| 75 | 80 | |
| 81 | +const parsed = computed(() => splitFrontmatter(form.content)) | |
| 76 | 82 | const description = computed({ |
| 77 | 83 | get: () => getFm(form.content, 'description'), |
| 78 | 84 | set: (v: string) => { form.content = setFm(form.content, 'description', v) } |
| 79 | 85 | }) |
| 86 | +const fmName = computed(() => getFm(form.content, 'name')) | |
| 87 | + | |
| 80 | 88 | function onName(v: string) { |
| 89 | + const before = form.name | |
| 81 | 90 | form.name = v |
| 82 | - if (isMd.value) form.content = setFm(form.content, 'name', v) | |
| 91 | + if (!isMd.value) return | |
| 92 | + let c = setFm(form.content, 'name', v) | |
| 93 | + // A new skill's title follows the name until the heading is edited by hand. | |
| 94 | + if (props.type === 'skill' && isNew.value) c = retitle(c, before || PLACEHOLDER_TITLE, v || PLACEHOLDER_TITLE) | |
| 95 | + form.content = c | |
| 96 | +} | |
| 97 | +// The name edited in the frontmatter wins over the field. | |
| 98 | +watch(fmName, (v) => { if (isMd.value && v && v !== form.name) form.name = v }) | |
| 99 | + | |
| 100 | +const pathsText = ref('') | |
| 101 | +const normPaths = (v: string) => v.split('\n').map(x => x.trim()).filter(Boolean).join('\n') | |
| 102 | +watch(() => getFmList(form.content, 'paths').join('\n'), (v) => { if (v !== normPaths(pathsText.value)) pathsText.value = v }, { immediate: true }) | |
| 103 | +function onPaths(v: string) { | |
| 104 | + pathsText.value = v | |
| 105 | + form.content = setFmList(form.content, 'paths', v.split('\n')) | |
| 83 | 106 | } |
| 84 | -// New item: the name typed first also seeds the template when the content is still the untouched one. | |
| 85 | -const fmName = computed(() => getFm(form.content, 'name')) | |
| 86 | 107 | |
| 87 | -const parsed = computed(() => splitFrontmatter(form.content)) | |
| 88 | -const previewHtml = computed(() => renderMarkdown(parsed.value.body)) | |
| 108 | +const bodyHtml = computed(() => renderMarkdown(parsed.value.body)) | |
| 109 | +const rulePaths = computed(() => getFmList(form.content, 'paths')) | |
| 89 | 110 | |
| 90 | 111 | /* ------------------------------------------------ Validation */ |
| 91 | 112 | |
| 113 | +const renaming = computed(() => !isNew.value && form.name !== props.name) | |
| 114 | +const nameError = computed(() => { | |
| 115 | + if (!form.name) return isNew.value && !dirty.value ? null : 'A name is required.' | |
| 116 | + if (!NAME_RE.test(form.name)) return 'Use kebab-case: lowercase letters, digits, "." "_" "-", starting with a letter or digit.' | |
| 117 | + if ((isNew.value || renaming.value) && catalog.findItem(props.type, form.name)) return `A ${meta.value.singular.toLowerCase()} named "${form.name}" already exists.` | |
| 118 | + return null | |
| 119 | +}) | |
| 92 | 120 | const problems = computed(() => { |
| 93 | 121 | const p: string[] = [] |
| 94 | - if (!form.name) p.push('A name is required.') | |
| 95 | - else if (!NAME_RE.test(form.name)) p.push('Name must be kebab-case: lowercase letters, digits, "." "_" "-", starting with a letter or digit.') | |
| 96 | - else if ((isNew.value || form.name !== props.name) && catalog.findItem(props.type, form.name)) p.push(`A ${meta.value.singular.toLowerCase()} named "${form.name}" already exists.`) | |
| 97 | 122 | if (isMd.value) { |
| 98 | 123 | if (!parsed.value.frontmatter) p.push('The file has no frontmatter block (--- ... ---).') |
| 99 | 124 | if (!description.value.trim()) p.push('The frontmatter needs a description.') |
| @@ -105,17 +130,17 @@ const problems = computed(() => { | ||
| 105 | 130 | } |
| 106 | 131 | return p |
| 107 | 132 | }) |
| 108 | -const nameMismatch = computed(() => isMd.value && !!parsed.value.frontmatter && fmName.value !== form.name && !!form.name) | |
| 109 | -const mismatchTitle = computed(() => `Frontmatter name is ${fmName.value ? '"' + fmName.value + '"' : 'missing'}`) | |
| 110 | -const renaming = computed(() => !isNew.value && form.name !== props.name) | |
| 133 | +const canSave = computed(() => dirty.value && !!form.name && !nameError.value && !problems.value.length) | |
| 134 | + | |
| 135 | +/* ------------------------------------------------ Save / cancel */ | |
| 111 | 136 | |
| 112 | -/* ------------------------------------------------ Save */ | |
| 137 | +const usedBy = computed(() => (props.name ? catalog.usedBy(props.type, props.name) : [])) | |
| 113 | 138 | |
| 114 | 139 | async function save() { |
| 115 | - if (problems.value.length) return | |
| 140 | + if (!canSave.value) return | |
| 116 | 141 | saving.value = true |
| 117 | 142 | saveError.value = null |
| 118 | - const content = isMd.value && nameMismatch.value ? setFm(form.content, 'name', form.name) : mainContent.value | |
| 143 | + const content = isMd.value && fmName.value !== form.name ? setFm(form.content, 'name', form.name) : mainContent.value | |
| 119 | 144 | const body: SaveItemInput = { |
| 120 | 145 | type: props.type, |
| 121 | 146 | name: form.name, |
| @@ -126,7 +151,13 @@ async function save() { | ||
| 126 | 151 | } |
| 127 | 152 | try { |
| 128 | 153 | await request(`/api/catalog/items/${props.type}/${encodeURIComponent(form.name)}`, { method: 'PUT', body, silent: true }) |
| 129 | - toast.add({ title: isNew.value ? 'Created' : 'Saved', description: form.name, icon: 'i-lucide-check', color: 'success' }) | |
| 154 | + const n = usedBy.value.length | |
| 155 | + toast.add({ | |
| 156 | + title: isNew.value ? 'Created' : 'Saved to catalog', | |
| 157 | + description: !isNew.value && n ? `${form.name} · ${n} scope${n > 1 ? 's' : ''} will show an update` : form.name, | |
| 158 | + icon: 'i-lucide-check', | |
| 159 | + color: 'success' | |
| 160 | + }) | |
| 130 | 161 | await catalog.refresh() |
| 131 | 162 | if (isNew.value || renaming.value) { |
| 132 | 163 | original.value = snapshot() |
| @@ -134,6 +165,7 @@ async function save() { | ||
| 134 | 165 | } else { |
| 135 | 166 | form.content = content |
| 136 | 167 | await load() |
| 168 | + editing.value = false | |
| 137 | 169 | } |
| 138 | 170 | } catch (e) { |
| 139 | 171 | saveError.value = (e as Error).message |
| @@ -142,14 +174,30 @@ async function save() { | ||
| 142 | 174 | } |
| 143 | 175 | } |
| 144 | 176 | |
| 145 | -onBeforeRouteLeave(() => !dirty.value || window.confirm('Discard unsaved changes?')) | |
| 177 | +function cancel() { | |
| 178 | + if (dirty.value && !window.confirm('Discard unsaved changes?')) return | |
| 179 | + if (isNew.value) { | |
| 180 | + original.value = snapshot() | |
| 181 | + navigateTo(`/catalog?type=${props.type}`) | |
| 182 | + return | |
| 183 | + } | |
| 184 | + saveError.value = null | |
| 185 | + seed(detail.value) | |
| 186 | + editing.value = false | |
| 187 | +} | |
| 188 | + | |
| 189 | +onBeforeRouteLeave(() => !guarded.value || window.confirm('Discard unsaved changes?')) | |
| 190 | +function beforeUnload(e: BeforeUnloadEvent) { | |
| 191 | + if (guarded.value) e.preventDefault() | |
| 192 | +} | |
| 193 | +onMounted(() => window.addEventListener('beforeunload', beforeUnload)) | |
| 194 | +onBeforeUnmount(() => window.removeEventListener('beforeunload', beforeUnload)) | |
| 146 | 195 | |
| 147 | 196 | /* ------------------------------------------------ Delete */ |
| 148 | 197 | |
| 149 | 198 | const delOpen = ref(false) |
| 150 | 199 | const deleting = ref(false) |
| 151 | 200 | const delConflict = ref<string | null>(null) |
| 152 | -const usedBy = computed(() => (props.name ? catalog.usedBy(props.type, props.name) : [])) | |
| 153 | 201 | |
| 154 | 202 | async function doDelete(force = false) { |
| 155 | 203 | deleting.value = true |
| @@ -200,206 +248,268 @@ function toggleDelete(p: string) { | ||
| 200 | 248 | deleteFiles.value = deleteFiles.value.includes(p) ? deleteFiles.value.filter(x => x !== p) : [...deleteFiles.value, p] |
| 201 | 249 | } |
| 202 | 250 | |
| 203 | -const VIEWS = [ | |
| 204 | - { label: 'Edit', value: 'edit' }, | |
| 205 | - { label: 'Split', value: 'split' }, | |
| 206 | - { label: 'Preview', value: 'preview' } | |
| 251 | +/* ------------------------------------------------ View helpers */ | |
| 252 | + | |
| 253 | +const EDIT_VIEWS = [ | |
| 254 | + { label: 'Edit', value: 'edit' as const }, | |
| 255 | + { label: 'Split', value: 'split' as const }, | |
| 256 | + { label: 'Preview', value: 'preview' as const } | |
| 257 | +] | |
| 258 | +const FORM_VIEWS = [ | |
| 259 | + { label: 'Form', value: 'form' as const }, | |
| 260 | + { label: 'YAML', value: 'yaml' as const } | |
| 207 | 261 | ] |
| 208 | -const TA = 'block w-full resize-y rounded-lg border border-default bg-default p-3 font-mono text-xs leading-relaxed outline-none focus:border-primary focus:ring-1 focus:ring-primary' | |
| 262 | + | |
| 263 | +const originLabel = computed(() => { | |
| 264 | + const o = detail.value?.item.origin | |
| 265 | + if (!o || o.kind === 'created') return 'Created in skilly' | |
| 266 | + if (o.kind === 'import') return 'Imported' | |
| 267 | + return 'Marketplace' | |
| 268 | +}) | |
| 269 | +const tpl = computed(() => detail.value?.template) | |
| 270 | +const envNames = computed(() => { | |
| 271 | + if (props.type !== 'mcp') return [] | |
| 272 | + const m = detail.value?.template as McpTemplate | undefined | |
| 273 | + if (!m) return [] | |
| 274 | + const all = { ...(m.env ?? {}), ...(m.headers ?? {}) } | |
| 275 | + const out = Object.values(all).map(v => v.fromEnv).filter((v): v is string => !!v) | |
| 276 | + if (m.auth?.bearerFromEnv) out.push(m.auth.bearerFromEnv) | |
| 277 | + return out | |
| 278 | +}) | |
| 279 | +const hookAgents = computed(() => { | |
| 280 | + const a = (detail.value?.template as HookTemplate | undefined)?.agents | |
| 281 | + return a?.length ? a.map(x => AGENT_LABELS[x]).join(', ') : 'Claude Code, Codex' | |
| 282 | +}) | |
| 283 | +const fileLabel = computed(() => { | |
| 284 | + if (props.type === 'skill') return form.name ? `skills/${form.name}/SKILL.md` : 'SKILL.md' | |
| 285 | + const dir = props.type === 'rule' ? 'rules' : 'agents' | |
| 286 | + return form.name ? `${dir}/${form.name}.md` : '' | |
| 287 | +}) | |
| 288 | +const yamlLabel = computed(() => (form.name ? `${props.type === 'mcp' ? 'mcp' : 'hooks'}/${form.name}.yaml` : 'YAML')) | |
| 289 | +const title = computed(() => form.name || (isNew.value ? meta.value.newLabel : '')) | |
| 290 | + | |
| 291 | +const TA = 'block w-full resize-none border-0 bg-sk-bg px-5 py-[18px] font-mono text-[12.5px] leading-[1.65] text-sk-text outline-none placeholder:text-sk-faint' | |
| 209 | 292 | </script> |
| 210 | 293 | |
| 211 | 294 | <template> |
| 212 | - <UDashboardPanel :id="`catalog-item-${type}`"> | |
| 213 | - <template #header> | |
| 214 | - <UDashboardNavbar icon="i-lucide-library"> | |
| 215 | - <template #title> | |
| 216 | - <NuxtLink :to="`/catalog?type=${type}`" class="text-muted hover:text-default">Catalog</NuxtLink> | |
| 217 | - <UIcon name="i-lucide-chevron-right" class="size-4 text-dimmed" /> | |
| 218 | - <span class="text-muted">{{ meta.label }}</span> | |
| 219 | - <UIcon name="i-lucide-chevron-right" class="size-4 text-dimmed" /> | |
| 220 | - <span class="truncate">{{ isNew ? 'New' : name }}</span> | |
| 221 | - </template> | |
| 222 | - <template #right> | |
| 223 | - <UBadge v-if="dirty" label="Unsaved" color="warning" variant="subtle" /> | |
| 224 | - <UButton v-if="!isNew" label="Delete" icon="i-lucide-trash-2" color="error" variant="soft" @click="askDelete" /> | |
| 225 | - <UButton label="Save" icon="i-lucide-save" :loading="saving" :disabled="!dirty || !!problems.length" @click="save" /> | |
| 226 | - </template> | |
| 227 | - </UDashboardNavbar> | |
| 228 | - </template> | |
| 295 | + <SkPanel class="flex min-w-0 flex-col"> | |
| 296 | + <div v-if="loading" class="flex flex-col gap-3 p-[22px]"> | |
| 297 | + <USkeleton class="h-6 w-1/3" /> | |
| 298 | + <USkeleton class="h-4 w-2/3" /> | |
| 299 | + <USkeleton class="h-64 w-full" /> | |
| 300 | + </div> | |
| 301 | + <div v-else-if="loadError" class="p-[22px]"> | |
| 302 | + <SkNote tone="accent"> | |
| 303 | + <p class="m-0 font-medium">Could not load this item</p> | |
| 304 | + <p class="mt-1 mb-0 text-xs">{{ loadError }}</p> | |
| 305 | + <template #aside><SkButton size="sm" label="Retry" @click="load" /></template> | |
| 306 | + </SkNote> | |
| 307 | + </div> | |
| 308 | + | |
| 309 | + <template v-else> | |
| 310 | + <!-- Header: title, actions, fields or meta --> | |
| 311 | + <div class="flex flex-col gap-3 border-b border-sk-line2 px-[22px] py-5"> | |
| 312 | + <div class="flex items-start justify-between gap-3"> | |
| 313 | + <div class="flex min-w-0 flex-col gap-1"> | |
| 314 | + <div class="flex items-baseline gap-2.5"> | |
| 315 | + <h2 class="m-0 truncate text-lg font-semibold tracking-[-0.01em]" :class="!form.name && 'text-sk-faint'">{{ title }}</h2> | |
| 316 | + <SkTypeTag :type="type" /> | |
| 317 | + <span v-if="editing && dirty" class="font-mono text-[11px] text-sk-accent-text">unsaved</span> | |
| 318 | + </div> | |
| 319 | + <p v-if="!editing && detail?.item.description" class="m-0 text-pretty text-sk-muted">{{ detail.item.description }}</p> | |
| 320 | + </div> | |
| 321 | + <div class="flex shrink-0 gap-1.5"> | |
| 322 | + <template v-if="!editing"> | |
| 323 | + <SkButton size="sm" label="Edit" @click="editing = true" /> | |
| 324 | + </template> | |
| 325 | + <template v-else> | |
| 326 | + <SkButton v-if="!isNew" size="sm" variant="danger" label="Delete" @click="askDelete" /> | |
| 327 | + <SkButton size="sm" label="Cancel" @click="cancel" /> | |
| 328 | + <SkButton size="sm" variant="primary" label="Save" :loading="saving" :disabled="!canSave" @click="save" /> | |
| 329 | + </template> | |
| 330 | + </div> | |
| 331 | + </div> | |
| 229 | 332 | |
| 230 | - <template #body> | |
| 231 | - <div v-if="loading" class="space-y-3"> | |
| 232 | - <USkeleton class="h-10 w-1/2" /> | |
| 233 | - <USkeleton class="h-72 w-full" /> | |
| 234 | - </div> | |
| 235 | - <UAlert v-else-if="loadError" color="error" variant="subtle" icon="i-lucide-circle-alert" title="Could not load this item" :description="loadError" :actions="[{ label: 'Retry', color: 'neutral', variant: 'outline', onClick: load }]" /> | |
| 236 | - | |
| 237 | - <div v-else class="grid items-start gap-6 xl:grid-cols-[minmax(0,1fr)_20rem]"> | |
| 238 | - <!-- Main column --> | |
| 239 | - <div class="min-w-0 space-y-4"> | |
| 240 | - <UAlert v-if="saveError" color="error" variant="subtle" icon="i-lucide-circle-alert" title="Save failed" :description="saveError" class="whitespace-pre-wrap" /> | |
| 241 | - | |
| 242 | - <div class="grid gap-4 sm:grid-cols-2"> | |
| 243 | - <UFormField :label="renaming ? 'Name (renaming)' : 'Name'" required> | |
| 244 | - <UInput :model-value="form.name" class="w-full" placeholder="kebab-case-name" :ui="{ base: 'font-mono' }" @update:model-value="onName" /> | |
| 245 | - </UFormField> | |
| 246 | - <UFormField v-if="isMd" label="Description" required :description="type === 'skill' ? 'Tells the agent when to use the skill.' : undefined"> | |
| 247 | - <UInput v-model="description" class="w-full" /> | |
| 248 | - </UFormField> | |
| 333 | + <!-- Edit: fields --> | |
| 334 | + <template v-if="editing"> | |
| 335 | + <SkNote v-if="saveError" tone="accent"> | |
| 336 | + <p class="m-0 font-medium">Save failed</p> | |
| 337 | + <p class="mt-1 mb-0 text-xs whitespace-pre-wrap">{{ saveError }}</p> | |
| 338 | + </SkNote> | |
| 339 | + | |
| 340 | + <div class="grid gap-x-4 gap-y-3.5" :class="isMd ? 'md:grid-cols-2' : ''"> | |
| 341 | + <CatalogField :label="renaming ? 'Name (renaming)' : 'Name'" required :error="nameError"> | |
| 342 | + <UInput | |
| 343 | + :model-value="form.name" | |
| 344 | + class="w-full" | |
| 345 | + placeholder="kebab-case-name" | |
| 346 | + :color="nameError ? 'error' : undefined" | |
| 347 | + :highlight="!!nameError" | |
| 348 | + :ui="{ base: 'font-mono text-xs' }" | |
| 349 | + @update:model-value="onName" | |
| 350 | + /> | |
| 351 | + </CatalogField> | |
| 352 | + <CatalogField v-if="isMd" label="Description" required :hint="type === 'skill' ? 'Tells the agent when to use the skill.' : undefined"> | |
| 353 | + <UInput v-model="description" class="w-full" :placeholder="type === 'rule' ? 'What the rule enforces' : 'When to use this'" /> | |
| 354 | + </CatalogField> | |
| 355 | + <CatalogField v-if="type === 'rule'" label="Applies to" hint="Globs, one per line. Empty: always loaded." class="md:col-span-2"> | |
| 356 | + <UTextarea | |
| 357 | + :model-value="pathsText" | |
| 358 | + class="w-full" | |
| 359 | + :rows="3" | |
| 360 | + placeholder="src/**/*.ts app/**/*.vue" | |
| 361 | + :ui="{ base: 'font-mono text-xs' }" | |
| 362 | + aria-label="paths globs" | |
| 363 | + @update:model-value="onPaths" | |
| 364 | + /> | |
| 365 | + </CatalogField> | |
| 366 | + <CatalogField label="Tags" hint="Press Enter to add" class="md:col-span-2"> | |
| 367 | + <UInputTags v-model="form.tags" add-on-blur placeholder="Add a tag" class="w-full" /> | |
| 368 | + </CatalogField> | |
| 249 | 369 | </div> |
| 250 | 370 | |
| 251 | - <UAlert v-if="renaming" color="info" variant="subtle" icon="i-lucide-info" title="Renaming" description="The item is renamed in the catalog. Scopes that reference the old name will report it as missing until they are updated." /> | |
| 371 | + <SkNote v-if="renaming"> | |
| 372 | + Renaming <span class="font-mono text-xs">{{ name }}</span> to <span class="font-mono text-xs">{{ form.name }}</span>. | |
| 373 | + Scopes that reference the old name report it as missing until they are updated. | |
| 374 | + </SkNote> | |
| 375 | + <SkNote v-if="problems.length && (dirty || !isNew)" tone="accent"> | |
| 376 | + <p class="m-0 font-medium">Fix before saving</p> | |
| 377 | + <ul class="mt-1 mb-0 list-disc pl-4 text-xs"> | |
| 378 | + <li v-for="p in problems" :key="p">{{ p }}</li> | |
| 379 | + </ul> | |
| 380 | + </SkNote> | |
| 381 | + </template> | |
| 252 | 382 | |
| 253 | - <UAlert v-if="problems.length && (dirty || !isNew)" color="warning" variant="subtle" icon="i-lucide-triangle-alert" title="Fix before saving"> | |
| 254 | - <template #description> | |
| 255 | - <ul class="list-disc pl-4"> | |
| 256 | - <li v-for="p in problems" :key="p">{{ p }}</li> | |
| 257 | - </ul> | |
| 258 | - </template> | |
| 259 | - </UAlert> | |
| 260 | - <UAlert | |
| 261 | - v-else-if="nameMismatch" | |
| 262 | - color="warning" | |
| 263 | - variant="subtle" | |
| 264 | - icon="i-lucide-triangle-alert" | |
| 265 | - :title="mismatchTitle" | |
| 266 | - description="It will be set to the item name when you save." | |
| 267 | - /> | |
| 383 | + <!-- View: meta grid --> | |
| 384 | + <SkMetaGrid v-else label-width="96px" class="text-xs"> | |
| 385 | + <SkMeta label="Tags"> | |
| 386 | + <span v-if="detail?.item.tags.length" class="flex flex-wrap gap-1"><SkTag v-for="t in detail.item.tags" :key="t">{{ t }}</SkTag></span> | |
| 387 | + <span v-else class="text-sk-faint">No tags</span> | |
| 388 | + </SkMeta> | |
| 389 | + <SkMeta v-if="type === 'rule'" label="Applies to" :mono="!!rulePaths.length"> | |
| 390 | + <template v-if="rulePaths.length"><div v-for="p in rulePaths" :key="p">{{ p }}</div></template> | |
| 391 | + <span v-else class="font-sans text-sk-muted">Every file (always loaded)</span> | |
| 392 | + </SkMeta> | |
| 393 | + <SkMeta v-if="type === 'mcp' && tpl" label="Transport"><span class="text-sk-muted">{{ (tpl as McpTemplate).transport }}</span></SkMeta> | |
| 394 | + <SkMeta v-if="type === 'hook' && tpl" label="Event"><span class="font-mono text-sk-muted">{{ (tpl as HookTemplate).event }}<template v-if="(tpl as HookTemplate).matcher"> · {{ (tpl as HookTemplate).matcher }}</template></span></SkMeta> | |
| 395 | + <SkMeta label="Renders for"><span class="text-sk-muted">{{ type === 'hook' ? hookAgents : 'Claude Code, Codex' }}</span></SkMeta> | |
| 396 | + <SkMeta label="Source"> | |
| 397 | + <span class="text-sk-muted">{{ originLabel }}</span> | |
| 398 | + <span v-if="detail?.item.origin?.source" class="ml-1.5 font-mono text-[11px] text-sk-faint">{{ detail.item.origin.source }}</span> | |
| 399 | + </SkMeta> | |
| 400 | + <SkMeta v-if="envNames.length" label="Env vars" mono><span class="text-sk-muted">{{ envNames.join(', ') }}</span></SkMeta> | |
| 401 | + <SkMeta label="Used in"><CatalogUsedBy :scopes="usedBy" /></SkMeta> | |
| 402 | + <SkMeta v-if="detail" label="Updated"><span class="text-sk-muted">{{ formatDate(detail.item.updatedAt) }}</span></SkMeta> | |
| 403 | + <SkMeta v-if="detail" label="Path" mono><span class="break-all text-sk-faint" :title="detail.item.path">{{ detail.item.path }}</span></SkMeta> | |
| 404 | + </SkMetaGrid> | |
| 405 | + </div> | |
| 268 | 406 | |
| 269 | - <!-- Markdown editor --> | |
| 407 | + <!-- View: body --> | |
| 408 | + <template v-if="!editing"> | |
| 409 | + <div class="flex flex-col gap-3 px-[22px] py-5"> | |
| 270 | 410 | <template v-if="isMd"> |
| 271 | - <div class="flex items-center justify-between"> | |
| 272 | - <h2 class="text-sm font-semibold">{{ type === 'skill' ? 'SKILL.md' : `${name ?? form.name}.md` }}</h2> | |
| 273 | - <UTabs v-model="view" :items="VIEWS" :content="false" size="xs" variant="pill" color="neutral" class="w-auto" /> | |
| 274 | - </div> | |
| 275 | - <div class="grid gap-3" :class="view === 'split' ? 'lg:grid-cols-2' : ''"> | |
| 276 | - <textarea v-if="view !== 'preview'" v-model="form.content" :class="TA" class="h-[32rem]" spellcheck="false" aria-label="Markdown content" /> | |
| 277 | - <div v-if="view !== 'edit'" class="h-[32rem] overflow-auto rounded-lg border border-default p-4"> | |
| 278 | - <div v-if="parsed.frontmatter" class="mb-3 overflow-hidden rounded-lg border border-default"> | |
| 279 | - <table class="w-full text-xs"> | |
| 280 | - <tbody class="divide-y divide-default"> | |
| 281 | - <tr v-for="(v, k) in parsed.frontmatter" :key="k"> | |
| 282 | - <th class="w-1/4 bg-elevated/40 px-2 py-1.5 text-left align-top font-medium text-muted">{{ k }}</th> | |
| 283 | - <td class="px-2 py-1.5 break-words">{{ v }}</td> | |
| 284 | - </tr> | |
| 285 | - </tbody> | |
| 286 | - </table> | |
| 287 | - </div> | |
| 288 | - <!-- eslint-disable-next-line vue/no-v-html --> | |
| 289 | - <div class="md-body" v-html="previewHtml" /> | |
| 290 | - </div> | |
| 291 | - </div> | |
| 411 | + <MarkdownBody v-if="parsed.body.trim()" :html="bodyHtml" /> | |
| 412 | + <p v-else class="m-0 text-sk-faint">This item has no content yet.</p> | |
| 292 | 413 | </template> |
| 293 | - | |
| 294 | - <!-- MCP / hook form --> | |
| 295 | 414 | <template v-else> |
| 296 | - <div class="grid items-start gap-6 lg:grid-cols-2"> | |
| 297 | - <McpForm v-if="type === 'mcp'" v-model="mcp" :key="detail?.item.hash ?? 'new'" /> | |
| 298 | - <HookForm v-else v-model="hook" /> | |
| 299 | - <div class="space-y-2"> | |
| 300 | - <h2 class="text-sm font-semibold">{{ type === 'mcp' ? `mcp/${form.name || 'name'}.yaml` : `hooks/${form.name || 'name'}.yaml` }} <span class="font-normal text-muted">(read-only)</span></h2> | |
| 301 | - <pre class="overflow-auto rounded-lg border border-default bg-elevated p-3 font-mono text-xs">{{ yaml }}</pre> | |
| 302 | - <p v-if="type === 'hook'" class="text-xs text-muted">Hooks are stored in the catalog; enabling them per project is not available yet.</p> | |
| 303 | - </div> | |
| 304 | - </div> | |
| 415 | + <SkSectionLabel>{{ yamlLabel }}</SkSectionLabel> | |
| 416 | + <SkCode :text="yaml" /> | |
| 417 | + <p v-if="envNames.length" class="m-0 text-pretty text-sk-muted"> | |
| 418 | + The value of {{ envNames.join(', ') }} is read from your environment. It is never stored in the catalog. | |
| 419 | + </p> | |
| 420 | + <p v-if="type === 'hook'" class="m-0 text-xs text-sk-faint">skilly never runs the command: the agent does.</p> | |
| 305 | 421 | </template> |
| 306 | - </div> | |
| 307 | 422 | |
| 308 | - <!-- Side column --> | |
| 309 | - <aside class="space-y-5"> | |
| 310 | - <section class="space-y-1.5"> | |
| 311 | - <h3 class="text-sm font-semibold">Tags</h3> | |
| 312 | - <UInputTags v-model="form.tags" add-on-blur placeholder="Add a tag, press Enter" class="w-full" /> | |
| 313 | - </section> | |
| 314 | - | |
| 315 | - <section v-if="!isNew" class="space-y-1.5"> | |
| 316 | - <h3 class="text-sm font-semibold">Used by</h3> | |
| 317 | - <ul v-if="usedBy.length" class="space-y-1 text-sm"> | |
| 318 | - <li v-for="s in usedBy" :key="s"> | |
| 319 | - <NuxtLink :to="scopeLink(s)" class="flex items-center gap-1.5 text-primary hover:underline"> | |
| 320 | - <UIcon :name="s === 'home' ? 'i-lucide-house' : 'i-lucide-folder'" class="size-4" /> | |
| 321 | - {{ scopeLabel(s, scopeNames) }} | |
| 322 | - </NuxtLink> | |
| 423 | + <div v-if="type === 'skill' && existingFiles.length" class="flex flex-col gap-2 pt-1"> | |
| 424 | + <SkSectionLabel :count="existingFiles.length">Extra files</SkSectionLabel> | |
| 425 | + <ul class="m-0 flex list-none flex-col p-0"> | |
| 426 | + <li v-for="f in existingFiles" :key="f.path" class="flex items-center gap-3 border-t border-sk-line2 py-1.5 first:border-t-0"> | |
| 427 | + <button type="button" class="min-w-0 flex-1 cursor-pointer truncate border-0 bg-transparent p-0 text-left font-mono text-xs text-sk-text hover:text-sk-accent-text" @click="viewFile = f.path">{{ f.path }}</button> | |
| 428 | + <span class="font-mono text-[11px] text-sk-faint">{{ formatBytes(f.sizeBytes) }}</span> | |
| 323 | 429 | </li> |
| 324 | 430 | </ul> |
| 325 | - <p v-else class="text-xs text-muted">Not used by any scope.</p> | |
| 326 | - </section> | |
| 327 | - | |
| 328 | - <section v-if="detail" class="space-y-1 text-xs text-muted"> | |
| 329 | - <h3 class="text-sm font-semibold text-default">Details</h3> | |
| 330 | - <p>Origin: <span class="text-default">{{ detail.item.origin?.kind ?? 'created' }}</span><span v-if="detail.item.origin?.source"> ({{ detail.item.origin.source }})</span></p> | |
| 331 | - <p>Updated: <span class="text-default">{{ formatDate(detail.item.updatedAt) }}</span></p> | |
| 332 | - <p class="break-all font-mono" :title="detail.item.path">{{ detail.item.path }}</p> | |
| 333 | - </section> | |
| 334 | - | |
| 335 | - <!-- Skill files --> | |
| 336 | - <section v-if="type === 'skill'" class="space-y-2"> | |
| 337 | - <h3 class="text-sm font-semibold">Extra files</h3> | |
| 338 | - <ul v-if="existingFiles.length || Object.keys(extraFiles).length" class="divide-y divide-default rounded-lg border border-default text-xs"> | |
| 339 | - <li v-for="f in existingFiles" :key="f.path" class="flex items-center gap-1 px-2 py-1" :class="deleteFiles.includes(f.path) ? 'opacity-50' : ''"> | |
| 340 | - <button type="button" class="flex min-w-0 flex-1 items-center gap-1.5 text-left font-mono hover:text-highlighted" :class="deleteFiles.includes(f.path) ? 'line-through' : ''" @click="viewFile = f.path"> | |
| 341 | - <UIcon name="i-lucide-file" class="size-3.5 shrink-0 text-muted" /><span class="truncate">{{ f.path }}</span> | |
| 342 | - </button> | |
| 343 | - <span class="shrink-0 text-dimmed">{{ formatBytes(f.sizeBytes) }}</span> | |
| 344 | - <UButton :icon="deleteFiles.includes(f.path) ? 'i-lucide-undo-2' : 'i-lucide-trash-2'" size="xs" color="neutral" variant="ghost" :aria-label="deleteFiles.includes(f.path) ? 'Keep file' : 'Delete file'" @click="toggleDelete(f.path)" /> | |
| 345 | - </li> | |
| 346 | - <li v-for="(_c, p) in extraFiles" :key="p" class="flex items-center gap-1 px-2 py-1"> | |
| 347 | - <UIcon name="i-lucide-file-plus" class="size-3.5 shrink-0 text-success" /> | |
| 348 | - <span class="min-w-0 flex-1 truncate font-mono">{{ p }}</span> | |
| 349 | - <UBadge label="new" size="sm" color="success" variant="subtle" /> | |
| 350 | - <UButton icon="i-lucide-x" size="xs" color="neutral" variant="ghost" aria-label="Discard new file" @click="dropNew(p as string)" /> | |
| 351 | - </li> | |
| 352 | - </ul> | |
| 353 | - <p v-else class="text-xs text-muted">SKILL.md only.</p> | |
| 431 | + </div> | |
| 432 | + </div> | |
| 433 | + </template> | |
| 354 | 434 | |
| 355 | - <div class="space-y-1.5 rounded-lg border border-dashed border-default p-2"> | |
| 356 | - <UInput v-model="newFile.path" size="sm" placeholder="references/notes.md" class="w-full" :ui="{ base: 'font-mono' }" /> | |
| 357 | - <p v-if="newFileError" class="text-xs text-error">{{ newFileError }}</p> | |
| 358 | - <textarea v-model="newFile.content" :class="TA" class="h-24" placeholder="Text content" spellcheck="false" aria-label="New file content" /> | |
| 359 | - <UButton label="Add file" icon="i-lucide-plus" size="xs" color="neutral" variant="outline" :disabled="!newFile.path.trim() || !!newFileError" @click="addFile" /> | |
| 360 | - </div> | |
| 361 | - </section> | |
| 362 | - </aside> | |
| 363 | - </div> | |
| 435 | + <!-- Edit: Markdown split editor --> | |
| 436 | + <template v-else-if="isMd"> | |
| 437 | + <div class="flex items-center justify-between gap-3 px-[22px] py-2.5"> | |
| 438 | + <span class="truncate font-mono text-[11px] text-sk-faint">{{ fileLabel || 'content' }}</span> | |
| 439 | + <SkSeg v-model="view" :options="EDIT_VIEWS" size="xs" aria-label="Editor view" /> | |
| 440 | + </div> | |
| 441 | + <div class="grid min-h-[420px] border-t border-sk-line2" :class="view === 'split' ? 'md:grid-cols-2' : ''"> | |
| 442 | + <textarea | |
| 443 | + v-if="view !== 'preview'" | |
| 444 | + v-model="form.content" | |
| 445 | + :class="[TA, view === 'split' && 'md:border-r md:border-sk-line2']" | |
| 446 | + class="h-[520px] rounded-bl-[20px]" | |
| 447 | + spellcheck="false" | |
| 448 | + aria-label="Markdown content" | |
| 449 | + /> | |
| 450 | + <div v-if="view !== 'edit'" class="flex h-[520px] flex-col gap-2.5 overflow-auto px-5 py-[18px]"> | |
| 451 | + <SkSectionLabel>Preview</SkSectionLabel> | |
| 452 | + <MarkdownBody :html="bodyHtml" /> | |
| 453 | + </div> | |
| 454 | + </div> | |
| 364 | 455 | |
| 365 | - <!-- Skill file viewer --> | |
| 366 | - <USlideover :open="!!viewFile" :title="viewFile ?? ''" :ui="{ content: 'sm:max-w-2xl' }" @update:open="(v: boolean) => { if (!v) viewFile = null }"> | |
| 367 | - <template #body> | |
| 368 | - <MarkdownView v-if="viewFile && detail" :path="joinPath(detail.item.path, viewFile)" /> | |
| 369 | - </template> | |
| 370 | - </USlideover> | |
| 371 | - | |
| 372 | - <!-- Delete --> | |
| 373 | - <UModal v-model:open="delOpen" :title="`Delete ${name}`" description="This removes the item from the catalog."> | |
| 374 | - <template #body> | |
| 375 | - <div class="space-y-3 text-sm"> | |
| 376 | - <p> | |
| 377 | - <strong>{{ name }}</strong> will be deleted from the catalog folder. Scopes where it was already written keep their files, | |
| 378 | - but the item will be reported as missing in the catalog. | |
| 379 | - </p> | |
| 380 | - <UAlert | |
| 381 | - v-if="delConflict || usedBy.length" | |
| 382 | - color="warning" | |
| 383 | - variant="subtle" | |
| 384 | - icon="i-lucide-triangle-alert" | |
| 385 | - :title="delConflict ? 'This item is used' : 'Used by'" | |
| 386 | - > | |
| 387 | - <template #description> | |
| 388 | - <p v-if="delConflict" class="mb-1">Still referenced by the configuration of these scopes. If you delete it anyway, they will report it as missing from the catalog.</p> | |
| 389 | - <ul class="list-disc pl-4"> | |
| 390 | - <li v-for="s in usedBy" :key="s">{{ scopeLabel(s, scopeNames) }}</li> | |
| 391 | - </ul> | |
| 392 | - </template> | |
| 393 | - </UAlert> | |
| 456 | + <!-- Skill files --> | |
| 457 | + <div v-if="type === 'skill'" class="flex flex-col gap-3 border-t border-sk-line2 px-[22px] py-5"> | |
| 458 | + <SkSectionLabel :count="existingFiles.length + Object.keys(extraFiles).length">Extra files</SkSectionLabel> | |
| 459 | + <ul v-if="existingFiles.length || Object.keys(extraFiles).length" class="m-0 flex list-none flex-col p-0"> | |
| 460 | + <li v-for="f in existingFiles" :key="f.path" class="flex items-center gap-3 border-t border-sk-line2 py-1.5 first:border-t-0" :class="deleteFiles.includes(f.path) && 'opacity-55'"> | |
| 461 | + <button type="button" class="min-w-0 flex-1 cursor-pointer truncate border-0 bg-transparent p-0 text-left font-mono text-xs text-sk-text hover:text-sk-accent-text" :class="deleteFiles.includes(f.path) && 'line-through'" @click="viewFile = f.path">{{ f.path }}</button> | |
| 462 | + <span class="font-mono text-[11px] text-sk-faint">{{ formatBytes(f.sizeBytes) }}</span> | |
| 463 | + <SkButton size="xs" variant="ghost" :label="deleteFiles.includes(f.path) ? 'Keep' : 'Delete'" @click="toggleDelete(f.path)" /> | |
| 464 | + </li> | |
| 465 | + <li v-for="(_c, p) in extraFiles" :key="p" class="flex items-center gap-3 border-t border-sk-line2 py-1.5 first:border-t-0"> | |
| 466 | + <span class="min-w-0 flex-1 truncate font-mono text-xs">{{ p }}</span> | |
| 467 | + <SkTag mono>new</SkTag> | |
| 468 | + <SkButton size="xs" variant="ghost" label="Discard" @click="dropNew(p as string)" /> | |
| 469 | + </li> | |
| 470 | + </ul> | |
| 471 | + <p v-else class="m-0 text-sk-faint">SKILL.md only.</p> | |
| 472 | + | |
| 473 | + <div class="flex flex-col gap-2 rounded-[10px] border border-dashed border-sk-line p-3"> | |
| 474 | + <UInput v-model="newFile.path" placeholder="references/notes.md" class="w-full" :ui="{ base: 'font-mono text-xs' }" aria-label="New file path" /> | |
| 475 | + <span v-if="newFileError" class="text-xs text-sk-accent-text">{{ newFileError }}</span> | |
| 476 | + <UTextarea v-model="newFile.content" :rows="4" placeholder="Text content" class="w-full" :ui="{ base: 'font-mono text-xs' }" aria-label="New file content" /> | |
| 477 | + <div><SkButton size="sm" icon="i-lucide-plus" label="Add file" :disabled="!newFile.path.trim() || !!newFileError" @click="addFile" /></div> | |
| 394 | 478 | </div> |
| 395 | - </template> | |
| 396 | - <template #footer> | |
| 397 | - <div class="flex w-full justify-end gap-2"> | |
| 398 | - <UButton label="Cancel" color="neutral" variant="outline" @click="delOpen = false" /> | |
| 399 | - <UButton :label="delConflict ? 'Delete anyway' : 'Delete'" color="error" :loading="deleting" @click="doDelete(!!delConflict)" /> | |
| 479 | + </div> | |
| 480 | + </template> | |
| 481 | + | |
| 482 | + <!-- Edit: MCP / hook form + YAML --> | |
| 483 | + <template v-else> | |
| 484 | + <div class="flex items-center justify-between gap-3 px-[22px] py-2.5"> | |
| 485 | + <span class="truncate font-mono text-[11px] text-sk-faint">{{ yamlLabel }}</span> | |
| 486 | + <SkSeg v-model="formView" :options="FORM_VIEWS" size="xs" aria-label="Editor view" /> | |
| 487 | + </div> | |
| 488 | + <div class="border-t border-sk-line2 px-[22px] py-5"> | |
| 489 | + <template v-if="formView === 'form'"> | |
| 490 | + <McpForm v-if="type === 'mcp'" v-model="mcp" :key="detail?.item.hash ?? 'new'" /> | |
| 491 | + <HookForm v-else v-model="hook" /> | |
| 492 | + </template> | |
| 493 | + <div v-else class="flex flex-col gap-2"> | |
| 494 | + <SkCode :text="yaml" max-height="520px" /> | |
| 495 | + <p class="m-0 text-xs text-sk-faint">Read-only: generated from the form.</p> | |
| 400 | 496 | </div> |
| 401 | - </template> | |
| 402 | - </UModal> | |
| 497 | + </div> | |
| 498 | + </template> | |
| 403 | 499 | </template> |
| 404 | - </UDashboardPanel> | |
| 500 | + | |
| 501 | + <SkDrawer :open="!!viewFile" :title="viewFile ?? ''" width="lg" @update:open="(v: boolean) => { if (!v) viewFile = null }"> | |
| 502 | + <MarkdownView v-if="viewFile && detail" :path="joinPath(detail.item.path, viewFile)" /> | |
| 503 | + </SkDrawer> | |
| 504 | + | |
| 505 | + <CatalogDeleteModal | |
| 506 | + v-model:open="delOpen" | |
| 507 | + kind="item" | |
| 508 | + :name="name ?? ''" | |
| 509 | + :used-by="usedBy" | |
| 510 | + :conflict="delConflict" | |
| 511 | + :deleting="deleting" | |
| 512 | + @confirm="doDelete" | |
| 513 | + /> | |
| 514 | + </SkPanel> | |
| 405 | 515 | </template> |
| @@ -1,12 +1,14 @@ | |||
| 1 | <script setup lang="ts"> | 1 | <script setup lang="ts"> |
| 2 | import type { CatalogItemDetail, CatalogItemType, HookTemplate, McpTemplate, SaveItemInput } from '@skilly/core' | 2 | import type { CatalogItemDetail, CatalogItemType, HookTemplate, McpTemplate, SaveItemInput } from '@skilly/core' |
| 3 | 3 | ||
| 4 | -/** Catalog item editor: Markdown (skill / rule / agent) or form + YAML (mcp / hook). `name` is undefined for a new item. */ | 4 | +/** |
| 5 | + * Detail panel of a catalog item: read view, then edit mode (Markdown split editor for skill / rule / subagent, | ||
| 6 | + * form + YAML for MCP servers and hooks). `name` is undefined for a new item (starts in edit mode). | ||
| 7 | + */ | ||
| 5 | const props = defineProps<{ type: CatalogItemType, name?: string }>() | 8 | const props = defineProps<{ type: CatalogItemType, name?: string }>() |
| 6 | 9 | ||
| 7 | const { request } = useApi() | 10 | const { request } = useApi() |
| 8 | const catalog = useCatalog() | 11 | const catalog = useCatalog() |
| 9 | -const scopeNames = useScopeNames() | ||
| 10 | const toast = useToast() | 12 | const toast = useToast() |
| 11 | 13 | ||
| 12 | const isNew = computed(() => !props.name) | 14 | const isNew = computed(() => !props.name) |
| @@ -16,6 +18,7 @@ const isMd = computed(() => meta.value.markdown) | |||
| 16 | const loading = ref(!!props.name) | 18 | const loading = ref(!!props.name) |
| 17 | const loadError = ref<string | null>(null) | 19 | const loadError = ref<string | null>(null) |
| 18 | const detail = ref<CatalogItemDetail | null>(null) | 20 | const detail = ref<CatalogItemDetail | null>(null) |
| 21 | +const editing = ref(!props.name) | ||
| 19 | 22 | ||
| 20 | const form = reactive({ name: props.name ?? '', content: '', tags: [] as string[] }) | 23 | const form = reactive({ name: props.name ?? '', content: '', tags: [] as string[] }) |
| 21 | const mcp = ref<McpTemplate>({ name: '', transport: 'stdio', command: '', args: [] }) | 24 | const mcp = ref<McpTemplate>({ name: '', transport: 'stdio', command: '', args: [] }) |
| @@ -24,6 +27,7 @@ const extraFiles = ref<Record<string, string>>({}) | |||
| 24 | const deleteFiles = ref<string[]>([]) | 27 | const deleteFiles = ref<string[]>([]) |
| 25 | const original = ref('') | 28 | const original = ref('') |
| 26 | const view = ref<'edit' | 'split' | 'preview'>('split') | 29 | const view = ref<'edit' | 'split' | 'preview'>('split') |
| 30 | +const formView = ref<'form' | 'yaml'>('form') | ||
| 27 | const saving = ref(false) | 31 | const saving = ref(false) |
| 28 | const saveError = ref<string | null>(null) | 32 | const saveError = ref<string | null>(null) |
| 29 | 33 | ||
| @@ -36,6 +40,7 @@ const mainContent = computed(() => (isMd.value ? form.content : yaml.value)) | |||
| 36 | 40 | ||
| 37 | const snapshot = () => JSON.stringify([form.name, mainContent.value, form.tags, extraFiles.value, deleteFiles.value]) | 41 | const snapshot = () => JSON.stringify([form.name, mainContent.value, form.tags, extraFiles.value, deleteFiles.value]) |
| 38 | const dirty = computed(() => snapshot() !== original.value) | 42 | const dirty = computed(() => snapshot() !== original.value) |
| 43 | +const guarded = computed(() => editing.value && dirty.value) | ||
| 39 | 44 | ||
| 40 | function seed(d: CatalogItemDetail | null) { | 45 | function seed(d: CatalogItemDetail | null) { |
| 41 | detail.value = d | 46 | detail.value = d |
| @@ -48,7 +53,7 @@ function seed(d: CatalogItemDetail | null) { | |||
| 48 | if (props.type === 'mcp' && d.template) mcp.value = structuredClone(toRaw(d.template)) as McpTemplate | 53 | if (props.type === 'mcp' && d.template) mcp.value = structuredClone(toRaw(d.template)) as McpTemplate |
| 49 | if (props.type === 'hook' && d.template) hook.value = structuredClone(toRaw(d.template)) as HookTemplate | 54 | if (props.type === 'hook' && d.template) hook.value = structuredClone(toRaw(d.template)) as HookTemplate |
| 50 | } else if (isMd.value) { | 55 | } else if (isMd.value) { |
| 51 | - form.content = newItemContent(props.type, form.name || 'new-item') | 56 | + form.content = newItemContent(props.type, form.name) |
| 52 | } | 57 | } |
| 53 | nextTick(() => { original.value = snapshot() }) | 58 | nextTick(() => { original.value = snapshot() }) |
| 54 | } | 59 | } |
| @@ -71,29 +76,49 @@ onMounted(() => { | |||
| 71 | load() | 76 | load() |
| 72 | }) | 77 | }) |
| 73 | 78 | ||
| 74 | -/* ------------------------------------------------ Markdown fields */ | 79 | +/* ------------------------------------------------ Markdown fields (synced with the frontmatter) */ |
| 75 | 80 | ||
| 81 | +const parsed = computed(() => splitFrontmatter(form.content)) | ||
| 76 | const description = computed({ | 82 | const description = computed({ |
| 77 | get: () => getFm(form.content, 'description'), | 83 | get: () => getFm(form.content, 'description'), |
| 78 | set: (v: string) => { form.content = setFm(form.content, 'description', v) } | 84 | set: (v: string) => { form.content = setFm(form.content, 'description', v) } |
| 79 | }) | 85 | }) |
| 86 | +const fmName = computed(() => getFm(form.content, 'name')) | ||
| 87 | + | ||
| 80 | function onName(v: string) { | 88 | function onName(v: string) { |
| 89 | + const before = form.name | ||
| 81 | form.name = v | 90 | form.name = v |
| 82 | - if (isMd.value) form.content = setFm(form.content, 'name', v) | 91 | + if (!isMd.value) return |
| 92 | + let c = setFm(form.content, 'name', v) | ||
| 93 | + // A new skill's title follows the name until the heading is edited by hand. | ||
| 94 | + if (props.type === 'skill' && isNew.value) c = retitle(c, before || PLACEHOLDER_TITLE, v || PLACEHOLDER_TITLE) | ||
| 95 | + form.content = c | ||
| 96 | +} | ||
| 97 | +// The name edited in the frontmatter wins over the field. | ||
| 98 | +watch(fmName, (v) => { if (isMd.value && v && v !== form.name) form.name = v }) | ||
| 99 | + | ||
| 100 | +const pathsText = ref('') | ||
| 101 | +const normPaths = (v: string) => v.split('\n').map(x => x.trim()).filter(Boolean).join('\n') | ||
| 102 | +watch(() => getFmList(form.content, 'paths').join('\n'), (v) => { if (v !== normPaths(pathsText.value)) pathsText.value = v }, { immediate: true }) | ||
| 103 | +function onPaths(v: string) { | ||
| 104 | + pathsText.value = v | ||
| 105 | + form.content = setFmList(form.content, 'paths', v.split('\n')) | ||
| 83 | } | 106 | } |
| 84 | -// New item: the name typed first also seeds the template when the content is still the untouched one. | ||
| 85 | -const fmName = computed(() => getFm(form.content, 'name')) | ||
| 86 | 107 | ||
| 87 | -const parsed = computed(() => splitFrontmatter(form.content)) | 108 | +const bodyHtml = computed(() => renderMarkdown(parsed.value.body)) |
| 88 | -const previewHtml = computed(() => renderMarkdown(parsed.value.body)) | 109 | +const rulePaths = computed(() => getFmList(form.content, 'paths')) |
| 89 | 110 | ||
| 90 | /* ------------------------------------------------ Validation */ | 111 | /* ------------------------------------------------ Validation */ |
| 91 | 112 | ||
| 113 | +const renaming = computed(() => !isNew.value && form.name !== props.name) | ||
| 114 | +const nameError = computed(() => { | ||
| 115 | + if (!form.name) return isNew.value && !dirty.value ? null : 'A name is required.' | ||
| 116 | + if (!NAME_RE.test(form.name)) return 'Use kebab-case: lowercase letters, digits, "." "_" "-", starting with a letter or digit.' | ||
| 117 | + if ((isNew.value || renaming.value) && catalog.findItem(props.type, form.name)) return `A ${meta.value.singular.toLowerCase()} named "${form.name}" already exists.` | ||
| 118 | + return null | ||
| 119 | +}) | ||
| 92 | const problems = computed(() => { | 120 | const problems = computed(() => { |
| 93 | const p: string[] = [] | 121 | const p: string[] = [] |
| 94 | - if (!form.name) p.push('A name is required.') | ||
| 95 | - else if (!NAME_RE.test(form.name)) p.push('Name must be kebab-case: lowercase letters, digits, "." "_" "-", starting with a letter or digit.') | ||
| 96 | - else if ((isNew.value || form.name !== props.name) && catalog.findItem(props.type, form.name)) p.push(`A ${meta.value.singular.toLowerCase()} named "${form.name}" already exists.`) | ||
| 97 | if (isMd.value) { | 122 | if (isMd.value) { |
| 98 | if (!parsed.value.frontmatter) p.push('The file has no frontmatter block (--- ... ---).') | 123 | if (!parsed.value.frontmatter) p.push('The file has no frontmatter block (--- ... ---).') |
| 99 | if (!description.value.trim()) p.push('The frontmatter needs a description.') | 124 | if (!description.value.trim()) p.push('The frontmatter needs a description.') |
| @@ -105,17 +130,17 @@ const problems = computed(() => { | |||
| 105 | } | 130 | } |
| 106 | return p | 131 | return p |
| 107 | }) | 132 | }) |
| 108 | -const nameMismatch = computed(() => isMd.value && !!parsed.value.frontmatter && fmName.value !== form.name && !!form.name) | 133 | +const canSave = computed(() => dirty.value && !!form.name && !nameError.value && !problems.value.length) |
| 109 | -const mismatchTitle = computed(() => `Frontmatter name is ${fmName.value ? '"' + fmName.value + '"' : 'missing'}`) | 134 | + |
| 110 | -const renaming = computed(() => !isNew.value && form.name !== props.name) | 135 | +/* ------------------------------------------------ Save / cancel */ |
| 111 | 136 | ||
| 112 | -/* ------------------------------------------------ Save */ | 137 | +const usedBy = computed(() => (props.name ? catalog.usedBy(props.type, props.name) : [])) |
| 113 | 138 | ||
| 114 | async function save() { | 139 | async function save() { |
| 115 | - if (problems.value.length) return | 140 | + if (!canSave.value) return |
| 116 | saving.value = true | 141 | saving.value = true |
| 117 | saveError.value = null | 142 | saveError.value = null |
| 118 | - const content = isMd.value && nameMismatch.value ? setFm(form.content, 'name', form.name) : mainContent.value | 143 | + const content = isMd.value && fmName.value !== form.name ? setFm(form.content, 'name', form.name) : mainContent.value |
| 119 | const body: SaveItemInput = { | 144 | const body: SaveItemInput = { |
| 120 | type: props.type, | 145 | type: props.type, |
| 121 | name: form.name, | 146 | name: form.name, |
| @@ -126,7 +151,13 @@ async function save() { | |||
| 126 | } | 151 | } |
| 127 | try { | 152 | try { |
| 128 | await request(`/api/catalog/items/${props.type}/${encodeURIComponent(form.name)}`, { method: 'PUT', body, silent: true }) | 153 | await request(`/api/catalog/items/${props.type}/${encodeURIComponent(form.name)}`, { method: 'PUT', body, silent: true }) |
| 129 | - toast.add({ title: isNew.value ? 'Created' : 'Saved', description: form.name, icon: 'i-lucide-check', color: 'success' }) | 154 | + const n = usedBy.value.length |
| 155 | + toast.add({ | ||
| 156 | + title: isNew.value ? 'Created' : 'Saved to catalog', | ||
| 157 | + description: !isNew.value && n ? `${form.name} · ${n} scope${n > 1 ? 's' : ''} will show an update` : form.name, | ||
| 158 | + icon: 'i-lucide-check', | ||
| 159 | + color: 'success' | ||
| 160 | + }) | ||
| 130 | await catalog.refresh() | 161 | await catalog.refresh() |
| 131 | if (isNew.value || renaming.value) { | 162 | if (isNew.value || renaming.value) { |
| 132 | original.value = snapshot() | 163 | original.value = snapshot() |
| @@ -134,6 +165,7 @@ async function save() { | |||
| 134 | } else { | 165 | } else { |
| 135 | form.content = content | 166 | form.content = content |
| 136 | await load() | 167 | await load() |
| 168 | + editing.value = false | ||
| 137 | } | 169 | } |
| 138 | } catch (e) { | 170 | } catch (e) { |
| 139 | saveError.value = (e as Error).message | 171 | saveError.value = (e as Error).message |
| @@ -142,14 +174,30 @@ async function save() { | |||
| 142 | } | 174 | } |
| 143 | } | 175 | } |
| 144 | 176 | ||
| 145 | -onBeforeRouteLeave(() => !dirty.value || window.confirm('Discard unsaved changes?')) | 177 | +function cancel() { |
| 178 | + if (dirty.value && !window.confirm('Discard unsaved changes?')) return | ||
| 179 | + if (isNew.value) { | ||
| 180 | + original.value = snapshot() | ||
| 181 | + navigateTo(`/catalog?type=${props.type}`) | ||
| 182 | + return | ||
| 183 | + } | ||
| 184 | + saveError.value = null | ||
| 185 | + seed(detail.value) | ||
| 186 | + editing.value = false | ||
| 187 | +} | ||
| 188 | + | ||
| 189 | +onBeforeRouteLeave(() => !guarded.value || window.confirm('Discard unsaved changes?')) | ||
| 190 | +function beforeUnload(e: BeforeUnloadEvent) { | ||
| 191 | + if (guarded.value) e.preventDefault() | ||
| 192 | +} | ||
| 193 | +onMounted(() => window.addEventListener('beforeunload', beforeUnload)) | ||
| 194 | +onBeforeUnmount(() => window.removeEventListener('beforeunload', beforeUnload)) | ||
| 146 | 195 | ||
| 147 | /* ------------------------------------------------ Delete */ | 196 | /* ------------------------------------------------ Delete */ |
| 148 | 197 | ||
| 149 | const delOpen = ref(false) | 198 | const delOpen = ref(false) |
| 150 | const deleting = ref(false) | 199 | const deleting = ref(false) |
| 151 | const delConflict = ref<string | null>(null) | 200 | const delConflict = ref<string | null>(null) |
| 152 | -const usedBy = computed(() => (props.name ? catalog.usedBy(props.type, props.name) : [])) | ||
| 153 | 201 | ||
| 154 | async function doDelete(force = false) { | 202 | async function doDelete(force = false) { |
| 155 | deleting.value = true | 203 | deleting.value = true |
| @@ -200,206 +248,268 @@ function toggleDelete(p: string) { | |||
| 200 | deleteFiles.value = deleteFiles.value.includes(p) ? deleteFiles.value.filter(x => x !== p) : [...deleteFiles.value, p] | 248 | deleteFiles.value = deleteFiles.value.includes(p) ? deleteFiles.value.filter(x => x !== p) : [...deleteFiles.value, p] |
| 201 | } | 249 | } |
| 202 | 250 | ||
| 203 | -const VIEWS = [ | 251 | +/* ------------------------------------------------ View helpers */ |
| 204 | - { label: 'Edit', value: 'edit' }, | 252 | + |
| 205 | - { label: 'Split', value: 'split' }, | 253 | +const EDIT_VIEWS = [ |
| 206 | - { label: 'Preview', value: 'preview' } | 254 | + { label: 'Edit', value: 'edit' as const }, |
| 255 | + { label: 'Split', value: 'split' as const }, | ||
| 256 | + { label: 'Preview', value: 'preview' as const } | ||
| 257 | +] | ||
| 258 | +const FORM_VIEWS = [ | ||
| 259 | + { label: 'Form', value: 'form' as const }, | ||
| 260 | + { label: 'YAML', value: 'yaml' as const } | ||
| 207 | ] | 261 | ] |
| 208 | -const TA = 'block w-full resize-y rounded-lg border border-default bg-default p-3 font-mono text-xs leading-relaxed outline-none focus:border-primary focus:ring-1 focus:ring-primary' | 262 | + |
| 263 | +const originLabel = computed(() => { | ||
| 264 | + const o = detail.value?.item.origin | ||
| 265 | + if (!o || o.kind === 'created') return 'Created in skilly' | ||
| 266 | + if (o.kind === 'import') return 'Imported' | ||
| 267 | + return 'Marketplace' | ||
| 268 | +}) | ||
| 269 | +const tpl = computed(() => detail.value?.template) | ||
| 270 | +const envNames = computed(() => { | ||
| 271 | + if (props.type !== 'mcp') return [] | ||
| 272 | + const m = detail.value?.template as McpTemplate | undefined | ||
| 273 | + if (!m) return [] | ||
| 274 | + const all = { ...(m.env ?? {}), ...(m.headers ?? {}) } | ||
| 275 | + const out = Object.values(all).map(v => v.fromEnv).filter((v): v is string => !!v) | ||
| 276 | + if (m.auth?.bearerFromEnv) out.push(m.auth.bearerFromEnv) | ||
| 277 | + return out | ||
| 278 | +}) | ||
| 279 | +const hookAgents = computed(() => { | ||
| 280 | + const a = (detail.value?.template as HookTemplate | undefined)?.agents | ||
| 281 | + return a?.length ? a.map(x => AGENT_LABELS[x]).join(', ') : 'Claude Code, Codex' | ||
| 282 | +}) | ||
| 283 | +const fileLabel = computed(() => { | ||
| 284 | + if (props.type === 'skill') return form.name ? `skills/${form.name}/SKILL.md` : 'SKILL.md' | ||
| 285 | + const dir = props.type === 'rule' ? 'rules' : 'agents' | ||
| 286 | + return form.name ? `${dir}/${form.name}.md` : '' | ||
| 287 | +}) | ||
| 288 | +const yamlLabel = computed(() => (form.name ? `${props.type === 'mcp' ? 'mcp' : 'hooks'}/${form.name}.yaml` : 'YAML')) | ||
| 289 | +const title = computed(() => form.name || (isNew.value ? meta.value.newLabel : '')) | ||
| 290 | + | ||
| 291 | +const TA = 'block w-full resize-none border-0 bg-sk-bg px-5 py-[18px] font-mono text-[12.5px] leading-[1.65] text-sk-text outline-none placeholder:text-sk-faint' | ||
| 209 | </script> | 292 | </script> |
| 210 | 293 | ||
| 211 | <template> | 294 | <template> |
| 212 | - <UDashboardPanel :id="`catalog-item-${type}`"> | 295 | + <SkPanel class="flex min-w-0 flex-col"> |
| 213 | - <template #header> | 296 | + <div v-if="loading" class="flex flex-col gap-3 p-[22px]"> |
| 214 | - <UDashboardNavbar icon="i-lucide-library"> | 297 | + <USkeleton class="h-6 w-1/3" /> |
| 215 | - <template #title> | 298 | + <USkeleton class="h-4 w-2/3" /> |
| 216 | - <NuxtLink :to="`/catalog?type=${type}`" class="text-muted hover:text-default">Catalog</NuxtLink> | 299 | + <USkeleton class="h-64 w-full" /> |
| 217 | - <UIcon name="i-lucide-chevron-right" class="size-4 text-dimmed" /> | 300 | + </div> |
| 218 | - <span class="text-muted">{{ meta.label }}</span> | 301 | + <div v-else-if="loadError" class="p-[22px]"> |
| 219 | - <UIcon name="i-lucide-chevron-right" class="size-4 text-dimmed" /> | 302 | + <SkNote tone="accent"> |
| 220 | - <span class="truncate">{{ isNew ? 'New' : name }}</span> | 303 | + <p class="m-0 font-medium">Could not load this item</p> |
| 221 | - </template> | 304 | + <p class="mt-1 mb-0 text-xs">{{ loadError }}</p> |
| 222 | - <template #right> | 305 | + <template #aside><SkButton size="sm" label="Retry" @click="load" /></template> |
| 223 | - <UBadge v-if="dirty" label="Unsaved" color="warning" variant="subtle" /> | 306 | + </SkNote> |
| 224 | - <UButton v-if="!isNew" label="Delete" icon="i-lucide-trash-2" color="error" variant="soft" @click="askDelete" /> | 307 | + </div> |
| 225 | - <UButton label="Save" icon="i-lucide-save" :loading="saving" :disabled="!dirty || !!problems.length" @click="save" /> | 308 | + |
| 226 | - </template> | 309 | + <template v-else> |
| 227 | - </UDashboardNavbar> | 310 | + <!-- Header: title, actions, fields or meta --> |
| 228 | - </template> | 311 | + <div class="flex flex-col gap-3 border-b border-sk-line2 px-[22px] py-5"> |
| 312 | + <div class="flex items-start justify-between gap-3"> | ||
| 313 | + <div class="flex min-w-0 flex-col gap-1"> | ||
| 314 | + <div class="flex items-baseline gap-2.5"> | ||
| 315 | + <h2 class="m-0 truncate text-lg font-semibold tracking-[-0.01em]" :class="!form.name && 'text-sk-faint'">{{ title }}</h2> | ||
| 316 | + <SkTypeTag :type="type" /> | ||
| 317 | + <span v-if="editing && dirty" class="font-mono text-[11px] text-sk-accent-text">unsaved</span> | ||
| 318 | + </div> | ||
| 319 | + <p v-if="!editing && detail?.item.description" class="m-0 text-pretty text-sk-muted">{{ detail.item.description }}</p> | ||
| 320 | + </div> | ||
| 321 | + <div class="flex shrink-0 gap-1.5"> | ||
| 322 | + <template v-if="!editing"> | ||
| 323 | + <SkButton size="sm" label="Edit" @click="editing = true" /> | ||
| 324 | + </template> | ||
| 325 | + <template v-else> | ||
| 326 | + <SkButton v-if="!isNew" size="sm" variant="danger" label="Delete" @click="askDelete" /> | ||
| 327 | + <SkButton size="sm" label="Cancel" @click="cancel" /> | ||
| 328 | + <SkButton size="sm" variant="primary" label="Save" :loading="saving" :disabled="!canSave" @click="save" /> | ||
| 329 | + </template> | ||
| 330 | + </div> | ||
| 331 | + </div> | ||
| 229 | 332 | ||
| 230 | - <template #body> | 333 | + <!-- Edit: fields --> |
| 231 | - <div v-if="loading" class="space-y-3"> | 334 | + <template v-if="editing"> |
| 232 | - <USkeleton class="h-10 w-1/2" /> | 335 | + <SkNote v-if="saveError" tone="accent"> |
| 233 | - <USkeleton class="h-72 w-full" /> | 336 | + <p class="m-0 font-medium">Save failed</p> |
| 234 | - </div> | 337 | + <p class="mt-1 mb-0 text-xs whitespace-pre-wrap">{{ saveError }}</p> |
| 235 | - <UAlert v-else-if="loadError" color="error" variant="subtle" icon="i-lucide-circle-alert" title="Could not load this item" :description="loadError" :actions="[{ label: 'Retry', color: 'neutral', variant: 'outline', onClick: load }]" /> | 338 | + </SkNote> |
| 236 | - | 339 | + |
| 237 | - <div v-else class="grid items-start gap-6 xl:grid-cols-[minmax(0,1fr)_20rem]"> | 340 | + <div class="grid gap-x-4 gap-y-3.5" :class="isMd ? 'md:grid-cols-2' : ''"> |
| 238 | - <!-- Main column --> | 341 | + <CatalogField :label="renaming ? 'Name (renaming)' : 'Name'" required :error="nameError"> |
| 239 | - <div class="min-w-0 space-y-4"> | 342 | + <UInput |
| 240 | - <UAlert v-if="saveError" color="error" variant="subtle" icon="i-lucide-circle-alert" title="Save failed" :description="saveError" class="whitespace-pre-wrap" /> | 343 | + :model-value="form.name" |
| 241 | - | 344 | + class="w-full" |
| 242 | - <div class="grid gap-4 sm:grid-cols-2"> | 345 | + placeholder="kebab-case-name" |
| 243 | - <UFormField :label="renaming ? 'Name (renaming)' : 'Name'" required> | 346 | + :color="nameError ? 'error' : undefined" |
| 244 | - <UInput :model-value="form.name" class="w-full" placeholder="kebab-case-name" :ui="{ base: 'font-mono' }" @update:model-value="onName" /> | 347 | + :highlight="!!nameError" |
| 245 | - </UFormField> | 348 | + :ui="{ base: 'font-mono text-xs' }" |
| 246 | - <UFormField v-if="isMd" label="Description" required :description="type === 'skill' ? 'Tells the agent when to use the skill.' : undefined"> | 349 | + @update:model-value="onName" |
| 247 | - <UInput v-model="description" class="w-full" /> | 350 | + /> |
| 248 | - </UFormField> | 351 | + </CatalogField> |
| 352 | + <CatalogField v-if="isMd" label="Description" required :hint="type === 'skill' ? 'Tells the agent when to use the skill.' : undefined"> | ||
| 353 | + <UInput v-model="description" class="w-full" :placeholder="type === 'rule' ? 'What the rule enforces' : 'When to use this'" /> | ||
| 354 | + </CatalogField> | ||
| 355 | + <CatalogField v-if="type === 'rule'" label="Applies to" hint="Globs, one per line. Empty: always loaded." class="md:col-span-2"> | ||
| 356 | + <UTextarea | ||
| 357 | + :model-value="pathsText" | ||
| 358 | + class="w-full" | ||
| 359 | + :rows="3" | ||
| 360 | + placeholder="src/**/*.ts app/**/*.vue" | ||
| 361 | + :ui="{ base: 'font-mono text-xs' }" | ||
| 362 | + aria-label="paths globs" | ||
| 363 | + @update:model-value="onPaths" | ||
| 364 | + /> | ||
| 365 | + </CatalogField> | ||
| 366 | + <CatalogField label="Tags" hint="Press Enter to add" class="md:col-span-2"> | ||
| 367 | + <UInputTags v-model="form.tags" add-on-blur placeholder="Add a tag" class="w-full" /> | ||
| 368 | + </CatalogField> | ||
| 249 | </div> | 369 | </div> |
| 250 | 370 | ||
| 251 | - <UAlert v-if="renaming" color="info" variant="subtle" icon="i-lucide-info" title="Renaming" description="The item is renamed in the catalog. Scopes that reference the old name will report it as missing until they are updated." /> | 371 | + <SkNote v-if="renaming"> |
| 372 | + Renaming <span class="font-mono text-xs">{{ name }}</span> to <span class="font-mono text-xs">{{ form.name }}</span>. | ||
| 373 | + Scopes that reference the old name report it as missing until they are updated. | ||
| 374 | + </SkNote> | ||
| 375 | + <SkNote v-if="problems.length && (dirty || !isNew)" tone="accent"> | ||
| 376 | + <p class="m-0 font-medium">Fix before saving</p> | ||
| 377 | + <ul class="mt-1 mb-0 list-disc pl-4 text-xs"> | ||
| 378 | + <li v-for="p in problems" :key="p">{{ p }}</li> | ||
| 379 | + </ul> | ||
| 380 | + </SkNote> | ||
| 381 | + </template> | ||
| 252 | 382 | ||
| 253 | - <UAlert v-if="problems.length && (dirty || !isNew)" color="warning" variant="subtle" icon="i-lucide-triangle-alert" title="Fix before saving"> | 383 | + <!-- View: meta grid --> |
| 254 | - <template #description> | 384 | + <SkMetaGrid v-else label-width="96px" class="text-xs"> |
| 255 | - <ul class="list-disc pl-4"> | 385 | + <SkMeta label="Tags"> |
| 256 | - <li v-for="p in problems" :key="p">{{ p }}</li> | 386 | + <span v-if="detail?.item.tags.length" class="flex flex-wrap gap-1"><SkTag v-for="t in detail.item.tags" :key="t">{{ t }}</SkTag></span> |
| 257 | - </ul> | 387 | + <span v-else class="text-sk-faint">No tags</span> |
| 258 | - </template> | 388 | + </SkMeta> |
| 259 | - </UAlert> | 389 | + <SkMeta v-if="type === 'rule'" label="Applies to" :mono="!!rulePaths.length"> |
| 260 | - <UAlert | 390 | + <template v-if="rulePaths.length"><div v-for="p in rulePaths" :key="p">{{ p }}</div></template> |
| 261 | - v-else-if="nameMismatch" | 391 | + <span v-else class="font-sans text-sk-muted">Every file (always loaded)</span> |
| 262 | - color="warning" | 392 | + </SkMeta> |
| 263 | - variant="subtle" | 393 | + <SkMeta v-if="type === 'mcp' && tpl" label="Transport"><span class="text-sk-muted">{{ (tpl as McpTemplate).transport }}</span></SkMeta> |
| 264 | - icon="i-lucide-triangle-alert" | 394 | + <SkMeta v-if="type === 'hook' && tpl" label="Event"><span class="font-mono text-sk-muted">{{ (tpl as HookTemplate).event }}<template v-if="(tpl as HookTemplate).matcher"> · {{ (tpl as HookTemplate).matcher }}</template></span></SkMeta> |
| 265 | - :title="mismatchTitle" | 395 | + <SkMeta label="Renders for"><span class="text-sk-muted">{{ type === 'hook' ? hookAgents : 'Claude Code, Codex' }}</span></SkMeta> |
| 266 | - description="It will be set to the item name when you save." | 396 | + <SkMeta label="Source"> |
| 267 | - /> | 397 | + <span class="text-sk-muted">{{ originLabel }}</span> |
| 398 | + <span v-if="detail?.item.origin?.source" class="ml-1.5 font-mono text-[11px] text-sk-faint">{{ detail.item.origin.source }}</span> | ||
| 399 | + </SkMeta> | ||
| 400 | + <SkMeta v-if="envNames.length" label="Env vars" mono><span class="text-sk-muted">{{ envNames.join(', ') }}</span></SkMeta> | ||
| 401 | + <SkMeta label="Used in"><CatalogUsedBy :scopes="usedBy" /></SkMeta> | ||
| 402 | + <SkMeta v-if="detail" label="Updated"><span class="text-sk-muted">{{ formatDate(detail.item.updatedAt) }}</span></SkMeta> | ||
| 403 | + <SkMeta v-if="detail" label="Path" mono><span class="break-all text-sk-faint" :title="detail.item.path">{{ detail.item.path }}</span></SkMeta> | ||
| 404 | + </SkMetaGrid> | ||
| 405 | + </div> | ||
| 268 | 406 | ||
| 269 | - <!-- Markdown editor --> | 407 | + <!-- View: body --> |
| 408 | + <template v-if="!editing"> | ||
| 409 | + <div class="flex flex-col gap-3 px-[22px] py-5"> | ||
| 270 | <template v-if="isMd"> | 410 | <template v-if="isMd"> |
| 271 | - <div class="flex items-center justify-between"> | 411 | + <MarkdownBody v-if="parsed.body.trim()" :html="bodyHtml" /> |
| 272 | - <h2 class="text-sm font-semibold">{{ type === 'skill' ? 'SKILL.md' : `${name ?? form.name}.md` }}</h2> | 412 | + <p v-else class="m-0 text-sk-faint">This item has no content yet.</p> |
| 273 | - <UTabs v-model="view" :items="VIEWS" :content="false" size="xs" variant="pill" color="neutral" class="w-auto" /> | ||
| 274 | - </div> | ||
| 275 | - <div class="grid gap-3" :class="view === 'split' ? 'lg:grid-cols-2' : ''"> | ||
| 276 | - <textarea v-if="view !== 'preview'" v-model="form.content" :class="TA" class="h-[32rem]" spellcheck="false" aria-label="Markdown content" /> | ||
| 277 | - <div v-if="view !== 'edit'" class="h-[32rem] overflow-auto rounded-lg border border-default p-4"> | ||
| 278 | - <div v-if="parsed.frontmatter" class="mb-3 overflow-hidden rounded-lg border border-default"> | ||
| 279 | - <table class="w-full text-xs"> | ||
| 280 | - <tbody class="divide-y divide-default"> | ||
| 281 | - <tr v-for="(v, k) in parsed.frontmatter" :key="k"> | ||
| 282 | - <th class="w-1/4 bg-elevated/40 px-2 py-1.5 text-left align-top font-medium text-muted">{{ k }}</th> | ||
| 283 | - <td class="px-2 py-1.5 break-words">{{ v }}</td> | ||
| 284 | - </tr> | ||
| 285 | - </tbody> | ||
| 286 | - </table> | ||
| 287 | - </div> | ||
| 288 | - <!-- eslint-disable-next-line vue/no-v-html --> | ||
| 289 | - <div class="md-body" v-html="previewHtml" /> | ||
| 290 | - </div> | ||
| 291 | - </div> | ||
| 292 | </template> | 413 | </template> |
| 293 | - | ||
| 294 | - <!-- MCP / hook form --> | ||
| 295 | <template v-else> | 414 | <template v-else> |
| 296 | - <div class="grid items-start gap-6 lg:grid-cols-2"> | 415 | + <SkSectionLabel>{{ yamlLabel }}</SkSectionLabel> |
| 297 | - <McpForm v-if="type === 'mcp'" v-model="mcp" :key="detail?.item.hash ?? 'new'" /> | 416 | + <SkCode :text="yaml" /> |
| 298 | - <HookForm v-else v-model="hook" /> | 417 | + <p v-if="envNames.length" class="m-0 text-pretty text-sk-muted"> |
| 299 | - <div class="space-y-2"> | 418 | + The value of {{ envNames.join(', ') }} is read from your environment. It is never stored in the catalog. |
| 300 | - <h2 class="text-sm font-semibold">{{ type === 'mcp' ? `mcp/${form.name || 'name'}.yaml` : `hooks/${form.name || 'name'}.yaml` }} <span class="font-normal text-muted">(read-only)</span></h2> | 419 | + </p> |
| 301 | - <pre class="overflow-auto rounded-lg border border-default bg-elevated p-3 font-mono text-xs">{{ yaml }}</pre> | 420 | + <p v-if="type === 'hook'" class="m-0 text-xs text-sk-faint">skilly never runs the command: the agent does.</p> |
| 302 | - <p v-if="type === 'hook'" class="text-xs text-muted">Hooks are stored in the catalog; enabling them per project is not available yet.</p> | ||
| 303 | - </div> | ||
| 304 | - </div> | ||
| 305 | </template> | 421 | </template> |
| 306 | - </div> | ||
| 307 | 422 | ||
| 308 | - <!-- Side column --> | 423 | + <div v-if="type === 'skill' && existingFiles.length" class="flex flex-col gap-2 pt-1"> |
| 309 | - <aside class="space-y-5"> | 424 | + <SkSectionLabel :count="existingFiles.length">Extra files</SkSectionLabel> |
| 310 | - <section class="space-y-1.5"> | 425 | + <ul class="m-0 flex list-none flex-col p-0"> |
| 311 | - <h3 class="text-sm font-semibold">Tags</h3> | 426 | + <li v-for="f in existingFiles" :key="f.path" class="flex items-center gap-3 border-t border-sk-line2 py-1.5 first:border-t-0"> |
| 312 | - <UInputTags v-model="form.tags" add-on-blur placeholder="Add a tag, press Enter" class="w-full" /> | 427 | + <button type="button" class="min-w-0 flex-1 cursor-pointer truncate border-0 bg-transparent p-0 text-left font-mono text-xs text-sk-text hover:text-sk-accent-text" @click="viewFile = f.path">{{ f.path }}</button> |
| 313 | - </section> | 428 | + <span class="font-mono text-[11px] text-sk-faint">{{ formatBytes(f.sizeBytes) }}</span> |
| 314 | - | ||
| 315 | - <section v-if="!isNew" class="space-y-1.5"> | ||
| 316 | - <h3 class="text-sm font-semibold">Used by</h3> | ||
| 317 | - <ul v-if="usedBy.length" class="space-y-1 text-sm"> | ||
| 318 | - <li v-for="s in usedBy" :key="s"> | ||
| 319 | - <NuxtLink :to="scopeLink(s)" class="flex items-center gap-1.5 text-primary hover:underline"> | ||
| 320 | - <UIcon :name="s === 'home' ? 'i-lucide-house' : 'i-lucide-folder'" class="size-4" /> | ||
| 321 | - {{ scopeLabel(s, scopeNames) }} | ||
| 322 | - </NuxtLink> | ||
| 323 | </li> | 429 | </li> |
| 324 | </ul> | 430 | </ul> |
| 325 | - <p v-else class="text-xs text-muted">Not used by any scope.</p> | 431 | + </div> |
| 326 | - </section> | 432 | + </div> |
| 327 | - | 433 | + </template> |
| 328 | - <section v-if="detail" class="space-y-1 text-xs text-muted"> | ||
| 329 | - <h3 class="text-sm font-semibold text-default">Details</h3> | ||
| 330 | - <p>Origin: <span class="text-default">{{ detail.item.origin?.kind ?? 'created' }}</span><span v-if="detail.item.origin?.source"> ({{ detail.item.origin.source }})</span></p> | ||
| 331 | - <p>Updated: <span class="text-default">{{ formatDate(detail.item.updatedAt) }}</span></p> | ||
| 332 | - <p class="break-all font-mono" :title="detail.item.path">{{ detail.item.path }}</p> | ||
| 333 | - </section> | ||
| 334 | - | ||
| 335 | - <!-- Skill files --> | ||
| 336 | - <section v-if="type === 'skill'" class="space-y-2"> | ||
| 337 | - <h3 class="text-sm font-semibold">Extra files</h3> | ||
| 338 | - <ul v-if="existingFiles.length || Object.keys(extraFiles).length" class="divide-y divide-default rounded-lg border border-default text-xs"> | ||
| 339 | - <li v-for="f in existingFiles" :key="f.path" class="flex items-center gap-1 px-2 py-1" :class="deleteFiles.includes(f.path) ? 'opacity-50' : ''"> | ||
| 340 | - <button type="button" class="flex min-w-0 flex-1 items-center gap-1.5 text-left font-mono hover:text-highlighted" :class="deleteFiles.includes(f.path) ? 'line-through' : ''" @click="viewFile = f.path"> | ||
| 341 | - <UIcon name="i-lucide-file" class="size-3.5 shrink-0 text-muted" /><span class="truncate">{{ f.path }}</span> | ||
| 342 | - </button> | ||
| 343 | - <span class="shrink-0 text-dimmed">{{ formatBytes(f.sizeBytes) }}</span> | ||
| 344 | - <UButton :icon="deleteFiles.includes(f.path) ? 'i-lucide-undo-2' : 'i-lucide-trash-2'" size="xs" color="neutral" variant="ghost" :aria-label="deleteFiles.includes(f.path) ? 'Keep file' : 'Delete file'" @click="toggleDelete(f.path)" /> | ||
| 345 | - </li> | ||
| 346 | - <li v-for="(_c, p) in extraFiles" :key="p" class="flex items-center gap-1 px-2 py-1"> | ||
| 347 | - <UIcon name="i-lucide-file-plus" class="size-3.5 shrink-0 text-success" /> | ||
| 348 | - <span class="min-w-0 flex-1 truncate font-mono">{{ p }}</span> | ||
| 349 | - <UBadge label="new" size="sm" color="success" variant="subtle" /> | ||
| 350 | - <UButton icon="i-lucide-x" size="xs" color="neutral" variant="ghost" aria-label="Discard new file" @click="dropNew(p as string)" /> | ||
| 351 | - </li> | ||
| 352 | - </ul> | ||
| 353 | - <p v-else class="text-xs text-muted">SKILL.md only.</p> | ||
| 354 | 434 | ||
| 355 | - <div class="space-y-1.5 rounded-lg border border-dashed border-default p-2"> | 435 | + <!-- Edit: Markdown split editor --> |
| 356 | - <UInput v-model="newFile.path" size="sm" placeholder="references/notes.md" class="w-full" :ui="{ base: 'font-mono' }" /> | 436 | + <template v-else-if="isMd"> |
| 357 | - <p v-if="newFileError" class="text-xs text-error">{{ newFileError }}</p> | 437 | + <div class="flex items-center justify-between gap-3 px-[22px] py-2.5"> |
| 358 | - <textarea v-model="newFile.content" :class="TA" class="h-24" placeholder="Text content" spellcheck="false" aria-label="New file content" /> | 438 | + <span class="truncate font-mono text-[11px] text-sk-faint">{{ fileLabel || 'content' }}</span> |
| 359 | - <UButton label="Add file" icon="i-lucide-plus" size="xs" color="neutral" variant="outline" :disabled="!newFile.path.trim() || !!newFileError" @click="addFile" /> | 439 | + <SkSeg v-model="view" :options="EDIT_VIEWS" size="xs" aria-label="Editor view" /> |
| 360 | - </div> | 440 | + </div> |
| 361 | - </section> | 441 | + <div class="grid min-h-[420px] border-t border-sk-line2" :class="view === 'split' ? 'md:grid-cols-2' : ''"> |
| 362 | - </aside> | 442 | + <textarea |
| 363 | - </div> | 443 | + v-if="view !== 'preview'" |
| 444 | + v-model="form.content" | ||
| 445 | + :class="[TA, view === 'split' && 'md:border-r md:border-sk-line2']" | ||
| 446 | + class="h-[520px] rounded-bl-[20px]" | ||
| 447 | + spellcheck="false" | ||
| 448 | + aria-label="Markdown content" | ||
| 449 | + /> | ||
| 450 | + <div v-if="view !== 'edit'" class="flex h-[520px] flex-col gap-2.5 overflow-auto px-5 py-[18px]"> | ||
| 451 | + <SkSectionLabel>Preview</SkSectionLabel> | ||
| 452 | + <MarkdownBody :html="bodyHtml" /> | ||
| 453 | + </div> | ||
| 454 | + </div> | ||
| 364 | 455 | ||
| 365 | - <!-- Skill file viewer --> | 456 | + <!-- Skill files --> |
| 366 | - <USlideover :open="!!viewFile" :title="viewFile ?? ''" :ui="{ content: 'sm:max-w-2xl' }" @update:open="(v: boolean) => { if (!v) viewFile = null }"> | 457 | + <div v-if="type === 'skill'" class="flex flex-col gap-3 border-t border-sk-line2 px-[22px] py-5"> |
| 367 | - <template #body> | 458 | + <SkSectionLabel :count="existingFiles.length + Object.keys(extraFiles).length">Extra files</SkSectionLabel> |
| 368 | - <MarkdownView v-if="viewFile && detail" :path="joinPath(detail.item.path, viewFile)" /> | 459 | + <ul v-if="existingFiles.length || Object.keys(extraFiles).length" class="m-0 flex list-none flex-col p-0"> |
| 369 | - </template> | 460 | + <li v-for="f in existingFiles" :key="f.path" class="flex items-center gap-3 border-t border-sk-line2 py-1.5 first:border-t-0" :class="deleteFiles.includes(f.path) && 'opacity-55'"> |
| 370 | - </USlideover> | 461 | + <button type="button" class="min-w-0 flex-1 cursor-pointer truncate border-0 bg-transparent p-0 text-left font-mono text-xs text-sk-text hover:text-sk-accent-text" :class="deleteFiles.includes(f.path) && 'line-through'" @click="viewFile = f.path">{{ f.path }}</button> |
| 371 | - | 462 | + <span class="font-mono text-[11px] text-sk-faint">{{ formatBytes(f.sizeBytes) }}</span> |
| 372 | - <!-- Delete --> | 463 | + <SkButton size="xs" variant="ghost" :label="deleteFiles.includes(f.path) ? 'Keep' : 'Delete'" @click="toggleDelete(f.path)" /> |
| 373 | - <UModal v-model:open="delOpen" :title="`Delete ${name}`" description="This removes the item from the catalog."> | 464 | + </li> |
| 374 | - <template #body> | 465 | + <li v-for="(_c, p) in extraFiles" :key="p" class="flex items-center gap-3 border-t border-sk-line2 py-1.5 first:border-t-0"> |
| 375 | - <div class="space-y-3 text-sm"> | 466 | + <span class="min-w-0 flex-1 truncate font-mono text-xs">{{ p }}</span> |
| 376 | - <p> | 467 | + <SkTag mono>new</SkTag> |
| 377 | - <strong>{{ name }}</strong> will be deleted from the catalog folder. Scopes where it was already written keep their files, | 468 | + <SkButton size="xs" variant="ghost" label="Discard" @click="dropNew(p as string)" /> |
| 378 | - but the item will be reported as missing in the catalog. | 469 | + </li> |
| 379 | - </p> | 470 | + </ul> |
| 380 | - <UAlert | 471 | + <p v-else class="m-0 text-sk-faint">SKILL.md only.</p> |
| 381 | - v-if="delConflict || usedBy.length" | 472 | + |
| 382 | - color="warning" | 473 | + <div class="flex flex-col gap-2 rounded-[10px] border border-dashed border-sk-line p-3"> |
| 383 | - variant="subtle" | 474 | + <UInput v-model="newFile.path" placeholder="references/notes.md" class="w-full" :ui="{ base: 'font-mono text-xs' }" aria-label="New file path" /> |
| 384 | - icon="i-lucide-triangle-alert" | 475 | + <span v-if="newFileError" class="text-xs text-sk-accent-text">{{ newFileError }}</span> |
| 385 | - :title="delConflict ? 'This item is used' : 'Used by'" | 476 | + <UTextarea v-model="newFile.content" :rows="4" placeholder="Text content" class="w-full" :ui="{ base: 'font-mono text-xs' }" aria-label="New file content" /> |
| 386 | - > | 477 | + <div><SkButton size="sm" icon="i-lucide-plus" label="Add file" :disabled="!newFile.path.trim() || !!newFileError" @click="addFile" /></div> |
| 387 | - <template #description> | ||
| 388 | - <p v-if="delConflict" class="mb-1">Still referenced by the configuration of these scopes. If you delete it anyway, they will report it as missing from the catalog.</p> | ||
| 389 | - <ul class="list-disc pl-4"> | ||
| 390 | - <li v-for="s in usedBy" :key="s">{{ scopeLabel(s, scopeNames) }}</li> | ||
| 391 | - </ul> | ||
| 392 | - </template> | ||
| 393 | - </UAlert> | ||
| 394 | </div> | 478 | </div> |
| 395 | - </template> | 479 | + </div> |
| 396 | - <template #footer> | 480 | + </template> |
| 397 | - <div class="flex w-full justify-end gap-2"> | 481 | + |
| 398 | - <UButton label="Cancel" color="neutral" variant="outline" @click="delOpen = false" /> | 482 | + <!-- Edit: MCP / hook form + YAML --> |
| 399 | - <UButton :label="delConflict ? 'Delete anyway' : 'Delete'" color="error" :loading="deleting" @click="doDelete(!!delConflict)" /> | 483 | + <template v-else> |
| 484 | + <div class="flex items-center justify-between gap-3 px-[22px] py-2.5"> | ||
| 485 | + <span class="truncate font-mono text-[11px] text-sk-faint">{{ yamlLabel }}</span> | ||
| 486 | + <SkSeg v-model="formView" :options="FORM_VIEWS" size="xs" aria-label="Editor view" /> | ||
| 487 | + </div> | ||
| 488 | + <div class="border-t border-sk-line2 px-[22px] py-5"> | ||
| 489 | + <template v-if="formView === 'form'"> | ||
| 490 | + <McpForm v-if="type === 'mcp'" v-model="mcp" :key="detail?.item.hash ?? 'new'" /> | ||
| 491 | + <HookForm v-else v-model="hook" /> | ||
| 492 | + </template> | ||
| 493 | + <div v-else class="flex flex-col gap-2"> | ||
| 494 | + <SkCode :text="yaml" max-height="520px" /> | ||
| 495 | + <p class="m-0 text-xs text-sk-faint">Read-only: generated from the form.</p> | ||
| 400 | </div> | 496 | </div> |
| 401 | - </template> | 497 | + </div> |
| 402 | - </UModal> | 498 | + </template> |
| 403 | </template> | 499 | </template> |
| 404 | - </UDashboardPanel> | 500 | + |
| 501 | + <SkDrawer :open="!!viewFile" :title="viewFile ?? ''" width="lg" @update:open="(v: boolean) => { if (!v) viewFile = null }"> | ||
| 502 | + <MarkdownView v-if="viewFile && detail" :path="joinPath(detail.item.path, viewFile)" /> | ||
| 503 | + </SkDrawer> | ||
| 504 | + | ||
| 505 | + <CatalogDeleteModal | ||
| 506 | + v-model:open="delOpen" | ||
| 507 | + kind="item" | ||
| 508 | + :name="name ?? ''" | ||
| 509 | + :used-by="usedBy" | ||
| 510 | + :conflict="delConflict" | ||
| 511 | + :deleting="deleting" | ||
| 512 | + @confirm="doDelete" | ||
| 513 | + /> | ||
| 514 | + </SkPanel> | ||
| 405 | </template> | 515 | </template> |
modified
packages/ui/app/components/CatalogSetupCard.vue +14 -12 | @@ -1,4 +1,5 @@ | ||
| 1 | 1 | <script setup lang="ts"> |
| 2 | +/** Setup state of the Catalog pages: the catalog folder does not exist yet. */ | |
| 2 | 3 | const { request } = useApi() |
| 3 | 4 | const { init, refresh } = useCatalog() |
| 4 | 5 | const toast = useToast() |
| @@ -26,17 +27,18 @@ async function create() { | ||
| 26 | 27 | </script> |
| 27 | 28 | |
| 28 | 29 | <template> |
| 29 | - <div class="mx-auto flex max-w-xl flex-col items-center gap-3 rounded-xl border border-dashed border-default px-6 py-12 text-center"> | |
| 30 | - <UIcon name="i-lucide-library" class="size-10 text-dimmed" /> | |
| 31 | - <h2 class="font-semibold">No catalog yet</h2> | |
| 32 | - <p class="text-sm text-muted"> | |
| 33 | - The catalog is a git repository holding your skills, rules, MCP servers, hooks, agents and presets. | |
| 34 | - Create one at the location below, or point skilly to an existing repository in Settings. | |
| 35 | - </p> | |
| 36 | - <p class="max-w-full truncate rounded bg-elevated px-2 py-1 font-mono text-xs" :title="path">{{ path || '...' }}</p> | |
| 37 | - <div class="flex gap-2"> | |
| 38 | - <UButton label="Create catalog" icon="i-lucide-folder-plus" :loading="creating" :disabled="!path" @click="create" /> | |
| 39 | - <UButton label="Change location" to="/settings" icon="i-lucide-settings" color="neutral" variant="outline" /> | |
| 30 | + <SkPanel class="mx-auto w-full max-w-xl" padded> | |
| 31 | + <div class="flex flex-col items-center gap-3 py-4 text-center"> | |
| 32 | + <h2 class="m-0 text-base font-semibold">No catalog yet</h2> | |
| 33 | + <p class="m-0 max-w-md text-pretty text-sk-muted"> | |
| 34 | + The catalog is a git repository holding your skills, rules, MCP servers, hooks, subagents and presets. | |
| 35 | + Create one at the location below, or point skilly to an existing repository in Settings. | |
| 36 | + </p> | |
| 37 | + <p class="m-0 max-w-full truncate rounded-[7px] border border-sk-line2 bg-sk-bg px-3 py-1.5 font-mono text-xs" :title="path">{{ path || '...' }}</p> | |
| 38 | + <div class="flex gap-2"> | |
| 39 | + <SkButton variant="primary" label="Create catalog" :loading="creating" :disabled="!path" @click="create" /> | |
| 40 | + <SkButton label="Change location" to="/settings" /> | |
| 41 | + </div> | |
| 40 | 42 | </div> |
| 41 | - </div> | |
| 43 | + </SkPanel> | |
| 42 | 44 | </template> |
| @@ -1,4 +1,5 @@ | |||
| 1 | <script setup lang="ts"> | 1 | <script setup lang="ts"> |
| 2 | +/** Setup state of the Catalog pages: the catalog folder does not exist yet. */ | ||
| 2 | const { request } = useApi() | 3 | const { request } = useApi() |
| 3 | const { init, refresh } = useCatalog() | 4 | const { init, refresh } = useCatalog() |
| 4 | const toast = useToast() | 5 | const toast = useToast() |
| @@ -26,17 +27,18 @@ async function create() { | |||
| 26 | </script> | 27 | </script> |
| 27 | 28 | ||
| 28 | <template> | 29 | <template> |
| 29 | - <div class="mx-auto flex max-w-xl flex-col items-center gap-3 rounded-xl border border-dashed border-default px-6 py-12 text-center"> | 30 | + <SkPanel class="mx-auto w-full max-w-xl" padded> |
| 30 | - <UIcon name="i-lucide-library" class="size-10 text-dimmed" /> | 31 | + <div class="flex flex-col items-center gap-3 py-4 text-center"> |
| 31 | - <h2 class="font-semibold">No catalog yet</h2> | 32 | + <h2 class="m-0 text-base font-semibold">No catalog yet</h2> |
| 32 | - <p class="text-sm text-muted"> | 33 | + <p class="m-0 max-w-md text-pretty text-sk-muted"> |
| 33 | - The catalog is a git repository holding your skills, rules, MCP servers, hooks, agents and presets. | 34 | + The catalog is a git repository holding your skills, rules, MCP servers, hooks, subagents and presets. |
| 34 | - Create one at the location below, or point skilly to an existing repository in Settings. | 35 | + Create one at the location below, or point skilly to an existing repository in Settings. |
| 35 | - </p> | 36 | + </p> |
| 36 | - <p class="max-w-full truncate rounded bg-elevated px-2 py-1 font-mono text-xs" :title="path">{{ path || '...' }}</p> | 37 | + <p class="m-0 max-w-full truncate rounded-[7px] border border-sk-line2 bg-sk-bg px-3 py-1.5 font-mono text-xs" :title="path">{{ path || '...' }}</p> |
| 37 | - <div class="flex gap-2"> | 38 | + <div class="flex gap-2"> |
| 38 | - <UButton label="Create catalog" icon="i-lucide-folder-plus" :loading="creating" :disabled="!path" @click="create" /> | 39 | + <SkButton variant="primary" label="Create catalog" :loading="creating" :disabled="!path" @click="create" /> |
| 39 | - <UButton label="Change location" to="/settings" icon="i-lucide-settings" color="neutral" variant="outline" /> | 40 | + <SkButton label="Change location" to="/settings" /> |
| 41 | + </div> | ||
| 40 | </div> | 42 | </div> |
| 41 | - </div> | 43 | + </SkPanel> |
| 42 | </template> | 44 | </template> |
added
packages/ui/app/components/CatalogUsedBy.vue +18 -0 | new file mode 100644 | ||
| @@ -0,0 +1,18 @@ | ||
| 1 | +<script setup lang="ts"> | |
| 2 | +/** "Used in" links: scope ids (project ids or `home`) rendered as underlined links, or a faint fallback. */ | |
| 3 | +defineProps<{ scopes: string[], empty?: string }>() | |
| 4 | +const names = useScopeNames() | |
| 5 | +</script> | |
| 6 | + | |
| 7 | +<template> | |
| 8 | + <span class="flex flex-wrap gap-x-3 gap-y-1"> | |
| 9 | + <NuxtLink | |
| 10 | + v-for="s in scopes" | |
| 11 | + :key="s" | |
| 12 | + :to="scopeLink(s)" | |
| 13 | + class="text-sk-text underline decoration-sk-line underline-offset-[3px] hover:text-sk-accent-text" | |
| 14 | + @click.stop | |
| 15 | + >{{ scopeLabel(s, names) }}</NuxtLink> | |
| 16 | + <span v-if="!scopes.length" class="text-sk-faint">{{ empty ?? 'No project yet' }}</span> | |
| 17 | + </span> | |
| 18 | +</template> | |
| new file mode 100644 | |||
| @@ -0,0 +1,18 @@ | |||
| 1 | +<script setup lang="ts"> | ||
| 2 | +/** "Used in" links: scope ids (project ids or `home`) rendered as underlined links, or a faint fallback. */ | ||
| 3 | +defineProps<{ scopes: string[], empty?: string }>() | ||
| 4 | +const names = useScopeNames() | ||
| 5 | +</script> | ||
| 6 | + | ||
| 7 | +<template> | ||
| 8 | + <span class="flex flex-wrap gap-x-3 gap-y-1"> | ||
| 9 | + <NuxtLink | ||
| 10 | + v-for="s in scopes" | ||
| 11 | + :key="s" | ||
| 12 | + :to="scopeLink(s)" | ||
| 13 | + class="text-sk-text underline decoration-sk-line underline-offset-[3px] hover:text-sk-accent-text" | ||
| 14 | + @click.stop | ||
| 15 | + >{{ scopeLabel(s, names) }}</NuxtLink> | ||
| 16 | + <span v-if="!scopes.length" class="text-sk-faint">{{ empty ?? 'No project yet' }}</span> | ||
| 17 | + </span> | ||
| 18 | +</template> | ||
deleted
packages/ui/app/components/CountChips.vue +0 -15 | deleted file mode 100644 | ||
| @@ -1,15 +0,0 @@ | ||
| 1 | -<script setup lang="ts"> | |
| 2 | -import type { ItemType } from '@skilly/core' | |
| 3 | - | |
| 4 | -defineProps<{ counts: Record<ItemType, number>, compact?: boolean }>() | |
| 5 | -</script> | |
| 6 | - | |
| 7 | -<template> | |
| 8 | - <div class="flex flex-wrap" :class="compact ? 'gap-x-3 gap-y-1' : 'gap-x-4 gap-y-1.5'"> | |
| 9 | - <div v-for="t in TYPE_ORDER" :key="t" class="flex items-center gap-1" :class="[counts[t] ? '' : 'text-dimmed', compact ? 'text-xs' : 'text-sm']" :title="TYPE_META[t].label"> | |
| 10 | - <UIcon :name="TYPE_META[t].icon" class="size-3.5" /> | |
| 11 | - <span class="font-medium tabular-nums">{{ counts[t] ?? 0 }}</span> | |
| 12 | - <span class="text-muted">{{ TYPE_META[t].label }}</span> | |
| 13 | - </div> | |
| 14 | - </div> | |
| 15 | -</template> | |
| deleted file mode 100644 | |||
| @@ -1,15 +0,0 @@ | |||
| 1 | -<script setup lang="ts"> | ||
| 2 | -import type { ItemType } from '@skilly/core' | ||
| 3 | - | ||
| 4 | -defineProps<{ counts: Record<ItemType, number>, compact?: boolean }>() | ||
| 5 | -</script> | ||
| 6 | - | ||
| 7 | -<template> | ||
| 8 | - <div class="flex flex-wrap" :class="compact ? 'gap-x-3 gap-y-1' : 'gap-x-4 gap-y-1.5'"> | ||
| 9 | - <div v-for="t in TYPE_ORDER" :key="t" class="flex items-center gap-1" :class="[counts[t] ? '' : 'text-dimmed', compact ? 'text-xs' : 'text-sm']" :title="TYPE_META[t].label"> | ||
| 10 | - <UIcon :name="TYPE_META[t].icon" class="size-3.5" /> | ||
| 11 | - <span class="font-medium tabular-nums">{{ counts[t] ?? 0 }}</span> | ||
| 12 | - <span class="text-muted">{{ TYPE_META[t].label }}</span> | ||
| 13 | - </div> | ||
| 14 | - </div> | ||
| 15 | -</template> | ||
modified
packages/ui/app/components/EnvRows.vue +21 -18 | @@ -32,35 +32,38 @@ function removeRow(i: number) { | ||
| 32 | 32 | rows.value.splice(i, 1) |
| 33 | 33 | push() |
| 34 | 34 | } |
| 35 | +function setMode(r: Row, mode: 'env' | 'literal') { | |
| 36 | + r.mode = mode | |
| 37 | + push() | |
| 38 | +} | |
| 35 | 39 | |
| 36 | 40 | const MODES = [ |
| 37 | - { label: 'From env var', value: 'env' }, | |
| 38 | - { label: 'Literal value', value: 'literal' } | |
| 41 | + { label: 'From env var', value: 'env' as const }, | |
| 42 | + { label: 'Literal', value: 'literal' as const } | |
| 39 | 43 | ] |
| 40 | 44 | </script> |
| 41 | 45 | |
| 42 | 46 | <template> |
| 43 | - <div class="space-y-2"> | |
| 44 | - <div v-for="(r, i) in rows" :key="i" class="flex items-center gap-2"> | |
| 45 | - <UInput v-model="r.key" :placeholder="keyPlaceholder" :aria-label="keyLabel" class="w-48" :ui="{ base: 'font-mono' }" @update:model-value="push" /> | |
| 46 | - <USelect v-model="r.mode" :items="MODES" class="w-40" @update:model-value="push" /> | |
| 47 | + <div class="flex flex-col gap-2"> | |
| 48 | + <div v-for="(r, i) in rows" :key="i" class="flex flex-wrap items-center gap-2"> | |
| 49 | + <UInput v-model="r.key" :placeholder="keyPlaceholder" :aria-label="keyLabel" class="w-44" :ui="{ base: 'font-mono text-xs' }" @update:model-value="push" /> | |
| 50 | + <SkSeg :model-value="r.mode" :options="MODES" size="xs" aria-label="Value source" @update:model-value="(v) => v && setMode(r, v)" /> | |
| 47 | 51 | <UInput |
| 48 | 52 | v-model="r.val" |
| 49 | 53 | :placeholder="r.mode === 'env' ? 'ENV_VAR_NAME' : 'value (stored in clear)'" |
| 50 | - class="flex-1" | |
| 51 | - :ui="{ base: 'font-mono' }" | |
| 54 | + :aria-label="r.mode === 'env' ? 'Environment variable name' : 'Literal value'" | |
| 55 | + class="min-w-40 flex-1" | |
| 56 | + :ui="{ base: 'font-mono text-xs' }" | |
| 52 | 57 | @update:model-value="push" |
| 53 | 58 | /> |
| 54 | - <UButton icon="i-lucide-x" color="neutral" variant="ghost" size="sm" aria-label="Remove row" @click="removeRow(i)" /> | |
| 59 | + <SkButton variant="ghost" size="xs" icon="i-lucide-x" aria-label="Remove row" @click="removeRow(i)" /> | |
| 60 | + </div> | |
| 61 | + <div> | |
| 62 | + <SkButton size="xs" icon="i-lucide-plus" label="Add row" @click="add" /> | |
| 55 | 63 | </div> |
| 56 | - <UButton label="Add row" icon="i-lucide-plus" size="xs" color="neutral" variant="outline" @click="add" /> | |
| 57 | - <UAlert | |
| 58 | - v-if="hasLiteral" | |
| 59 | - color="warning" | |
| 60 | - variant="subtle" | |
| 61 | - icon="i-lucide-triangle-alert" | |
| 62 | - title="Literal values are stored in clear in the catalog" | |
| 63 | - description="The catalog is a git repository that may be shared. Use a literal value only for non-secret settings; for tokens and keys, choose 'From env var'." | |
| 64 | - /> | |
| 64 | + <SkNote v-if="hasLiteral" tone="accent"> | |
| 65 | + <strong class="font-semibold">Literal values are stored in clear in the catalog.</strong> | |
| 66 | + The catalog is a git repository that may be shared. Use a literal only for non-secret settings; for tokens and keys, choose "From env var". | |
| 67 | + </SkNote> | |
| 65 | 68 | </div> |
| 66 | 69 | </template> |
| @@ -32,35 +32,38 @@ function removeRow(i: number) { | |||
| 32 | rows.value.splice(i, 1) | 32 | rows.value.splice(i, 1) |
| 33 | push() | 33 | push() |
| 34 | } | 34 | } |
| 35 | +function setMode(r: Row, mode: 'env' | 'literal') { | ||
| 36 | + r.mode = mode | ||
| 37 | + push() | ||
| 38 | +} | ||
| 35 | 39 | ||
| 36 | const MODES = [ | 40 | const MODES = [ |
| 37 | - { label: 'From env var', value: 'env' }, | 41 | + { label: 'From env var', value: 'env' as const }, |
| 38 | - { label: 'Literal value', value: 'literal' } | 42 | + { label: 'Literal', value: 'literal' as const } |
| 39 | ] | 43 | ] |
| 40 | </script> | 44 | </script> |
| 41 | 45 | ||
| 42 | <template> | 46 | <template> |
| 43 | - <div class="space-y-2"> | 47 | + <div class="flex flex-col gap-2"> |
| 44 | - <div v-for="(r, i) in rows" :key="i" class="flex items-center gap-2"> | 48 | + <div v-for="(r, i) in rows" :key="i" class="flex flex-wrap items-center gap-2"> |
| 45 | - <UInput v-model="r.key" :placeholder="keyPlaceholder" :aria-label="keyLabel" class="w-48" :ui="{ base: 'font-mono' }" @update:model-value="push" /> | 49 | + <UInput v-model="r.key" :placeholder="keyPlaceholder" :aria-label="keyLabel" class="w-44" :ui="{ base: 'font-mono text-xs' }" @update:model-value="push" /> |
| 46 | - <USelect v-model="r.mode" :items="MODES" class="w-40" @update:model-value="push" /> | 50 | + <SkSeg :model-value="r.mode" :options="MODES" size="xs" aria-label="Value source" @update:model-value="(v) => v && setMode(r, v)" /> |
| 47 | <UInput | 51 | <UInput |
| 48 | v-model="r.val" | 52 | v-model="r.val" |
| 49 | :placeholder="r.mode === 'env' ? 'ENV_VAR_NAME' : 'value (stored in clear)'" | 53 | :placeholder="r.mode === 'env' ? 'ENV_VAR_NAME' : 'value (stored in clear)'" |
| 50 | - class="flex-1" | 54 | + :aria-label="r.mode === 'env' ? 'Environment variable name' : 'Literal value'" |
| 51 | - :ui="{ base: 'font-mono' }" | 55 | + class="min-w-40 flex-1" |
| 56 | + :ui="{ base: 'font-mono text-xs' }" | ||
| 52 | @update:model-value="push" | 57 | @update:model-value="push" |
| 53 | /> | 58 | /> |
| 54 | - <UButton icon="i-lucide-x" color="neutral" variant="ghost" size="sm" aria-label="Remove row" @click="removeRow(i)" /> | 59 | + <SkButton variant="ghost" size="xs" icon="i-lucide-x" aria-label="Remove row" @click="removeRow(i)" /> |
| 60 | + </div> | ||
| 61 | + <div> | ||
| 62 | + <SkButton size="xs" icon="i-lucide-plus" label="Add row" @click="add" /> | ||
| 55 | </div> | 63 | </div> |
| 56 | - <UButton label="Add row" icon="i-lucide-plus" size="xs" color="neutral" variant="outline" @click="add" /> | 64 | + <SkNote v-if="hasLiteral" tone="accent"> |
| 57 | - <UAlert | 65 | + <strong class="font-semibold">Literal values are stored in clear in the catalog.</strong> |
| 58 | - v-if="hasLiteral" | 66 | + The catalog is a git repository that may be shared. Use a literal only for non-secret settings; for tokens and keys, choose "From env var". |
| 59 | - color="warning" | 67 | + </SkNote> |
| 60 | - variant="subtle" | ||
| 61 | - icon="i-lucide-triangle-alert" | ||
| 62 | - title="Literal values are stored in clear in the catalog" | ||
| 63 | - description="The catalog is a git repository that may be shared. Use a literal value only for non-secret settings; for tokens and keys, choose 'From env var'." | ||
| 64 | - /> | ||
| 65 | </div> | 68 | </div> |
| 66 | </template> | 69 | </template> |
modified
packages/ui/app/components/FolderBrowser.vue +29 -26 | @@ -5,7 +5,6 @@ import type { FsListing } from '@skilly/core' | ||
| 5 | 5 | const props = defineProps<{ start?: string, height?: string }>() |
| 6 | 6 | const selected = defineModel<string | null>('selected', { default: null }) |
| 7 | 7 | |
| 8 | - | |
| 9 | 8 | const { request } = useApi() |
| 10 | 9 | const listing = ref<FsListing | null>(null) |
| 11 | 10 | const loading = ref(false) |
| @@ -36,52 +35,56 @@ function highlight(path: string) { | ||
| 36 | 35 | function reset() { browse(props.start || undefined) } |
| 37 | 36 | defineExpose({ reset }) |
| 38 | 37 | onMounted(reset) |
| 38 | + | |
| 39 | +const isDrive = (name: string) => /^[a-zA-Z]:[\\/]?$/.test(name) | |
| 39 | 40 | </script> |
| 40 | 41 | |
| 41 | 42 | <template> |
| 42 | - <div class="space-y-3"> | |
| 43 | - <div class="flex flex-wrap items-center gap-1 text-sm"> | |
| 44 | - <UButton label="This PC" icon="i-lucide-hard-drive" size="xs" color="neutral" variant="ghost" @click="browse()" /> | |
| 43 | + <div class="flex flex-col gap-2.5"> | |
| 44 | + <nav class="flex flex-wrap items-center gap-0.5 font-mono text-xs" aria-label="Folder path"> | |
| 45 | + <button type="button" class="cursor-pointer rounded-md border-0 bg-transparent px-1.5 py-1 text-sk-muted hover:bg-sk-line2 hover:text-sk-text" @click="browse()">This PC</button> | |
| 45 | 46 | <template v-for="c in crumbs" :key="c.path"> |
| 46 | - <UIcon name="i-lucide-chevron-right" class="size-3.5 text-dimmed" /> | |
| 47 | - <UButton :label="c.name" size="xs" color="neutral" variant="ghost" @click="browse(c.path)" /> | |
| 47 | + <span class="text-sk-faint" aria-hidden="true">/</span> | |
| 48 | + <button type="button" class="cursor-pointer rounded-md border-0 bg-transparent px-1.5 py-1 text-sk-muted hover:bg-sk-line2 hover:text-sk-text" @click="browse(c.path)">{{ c.name }}</button> | |
| 48 | 49 | </template> |
| 49 | - </div> | |
| 50 | + </nav> | |
| 50 | 51 | |
| 51 | - <div class="overflow-y-auto rounded-lg border border-default" :class="height ?? 'h-72'"> | |
| 52 | - <div v-if="loading" class="space-y-1 p-2"> | |
| 52 | + <div class="overflow-y-auto rounded-[10px] border border-sk-line" :class="height ?? 'h-72'"> | |
| 53 | + <div v-if="loading" class="flex flex-col gap-1 p-2"> | |
| 53 | 54 | <USkeleton v-for="i in 6" :key="i" class="h-8 w-full" /> |
| 54 | 55 | </div> |
| 55 | - <div v-else-if="listError" class="p-3"> | |
| 56 | - <UAlert color="error" variant="subtle" icon="i-lucide-circle-alert" title="Cannot list this folder" :description="listError" /> | |
| 57 | - </div> | |
| 58 | - <ul v-else-if="listing" class="divide-y divide-default text-sm"> | |
| 59 | - <li v-if="listing.parent"> | |
| 60 | - <button type="button" class="flex w-full items-center gap-2 px-3 py-2 hover:bg-elevated/60" @click="browse(listing.parent!)"> | |
| 61 | - <UIcon name="i-lucide-corner-left-up" class="size-4 text-muted" /> | |
| 62 | - <span class="text-muted">..</span> | |
| 63 | - </button> | |
| 56 | + <SkNote v-else-if="listError" tone="accent" class="m-2">Cannot list this folder: {{ listError }}</SkNote> | |
| 57 | + <ul v-else-if="listing" class="m-0 list-none p-0"> | |
| 58 | + <li v-if="listing.parent" class="border-b border-sk-line2"> | |
| 59 | + <button type="button" class="flex w-full cursor-pointer items-center gap-2 border-0 bg-transparent px-3.5 py-2 text-left text-sk-muted hover:bg-sk-hover" @click="browse(listing.parent!)">..</button> | |
| 64 | 60 | </li> |
| 65 | 61 | <li |
| 66 | 62 | v-for="e in listing.entries" |
| 67 | 63 | :key="e.path" |
| 68 | - class="flex items-center hover:bg-elevated/60" | |
| 69 | - :class="highlighted === e.path ? 'bg-primary/10' : ''" | |
| 64 | + class="flex items-center border-t border-sk-line2 first:border-t-0 hover:bg-sk-hover" | |
| 65 | + :class="highlighted === e.path && 'bg-sk-sel hover:bg-sk-sel'" | |
| 70 | 66 | > |
| 71 | 67 | <button |
| 72 | 68 | type="button" |
| 73 | - class="flex min-w-0 flex-1 items-center gap-2 py-2 pl-3 text-left" | |
| 69 | + class="flex min-w-0 flex-1 cursor-pointer items-center gap-2.5 border-0 bg-transparent py-2 pr-2 pl-3.5 text-left text-sk-text" | |
| 74 | 70 | :title="e.path" |
| 75 | 71 | @click="highlight(e.path)" |
| 76 | 72 | @dblclick="browse(e.path)" |
| 77 | 73 | > |
| 78 | - <UIcon :name="listing.path ? 'i-lucide-folder' : /^[a-zA-Z]:[\\/]?$/.test(e.name) ? 'i-lucide-hard-drive' : 'i-lucide-house'" class="size-4 shrink-0 text-muted" /> | |
| 79 | - <span class="flex-1 truncate">{{ e.name }}</span> | |
| 80 | - <UBadge v-if="e.hasAgentConfig" label="agent config" color="primary" variant="subtle" size="sm" icon="i-lucide-sparkles" class="shrink-0" /> | |
| 74 | + <span class="truncate" :class="highlighted === e.path && 'font-medium'">{{ e.name }}</span> | |
| 75 | + <span v-if="isDrive(e.name) && !listing.path" class="font-mono text-[11px] text-sk-faint">drive</span> | |
| 76 | + <span v-if="e.hasAgentConfig" class="ml-auto shrink-0 font-mono text-[11px] text-sk-faint">agent config</span> | |
| 77 | + </button> | |
| 78 | + <button | |
| 79 | + type="button" | |
| 80 | + class="mr-2 flex size-6 shrink-0 cursor-pointer items-center justify-center rounded-md border-0 bg-transparent text-sk-faint hover:bg-sk-line2 hover:text-sk-text" | |
| 81 | + :aria-label="`Open ${e.name}`" | |
| 82 | + @click="browse(e.path)" | |
| 83 | + > | |
| 84 | + <UIcon name="i-lucide-chevron-right" class="size-3.5" /> | |
| 81 | 85 | </button> |
| 82 | - <UButton icon="i-lucide-chevron-right" size="xs" color="neutral" variant="ghost" class="mx-2 shrink-0" :aria-label="`Open ${e.name}`" @click="browse(e.path)" /> | |
| 83 | 86 | </li> |
| 84 | - <li v-if="!listing.entries.length" class="px-3 py-8 text-center text-muted">No subfolder here.</li> | |
| 87 | + <li v-if="!listing.entries.length" class="px-3 py-8 text-center text-sk-faint">No subfolder here.</li> | |
| 85 | 88 | </ul> |
| 86 | 89 | </div> |
| 87 | 90 | </div> |
| @@ -5,7 +5,6 @@ import type { FsListing } from '@skilly/core' | |||
| 5 | const props = defineProps<{ start?: string, height?: string }>() | 5 | const props = defineProps<{ start?: string, height?: string }>() |
| 6 | const selected = defineModel<string | null>('selected', { default: null }) | 6 | const selected = defineModel<string | null>('selected', { default: null }) |
| 7 | 7 | ||
| 8 | - | ||
| 9 | const { request } = useApi() | 8 | const { request } = useApi() |
| 10 | const listing = ref<FsListing | null>(null) | 9 | const listing = ref<FsListing | null>(null) |
| 11 | const loading = ref(false) | 10 | const loading = ref(false) |
| @@ -36,52 +35,56 @@ function highlight(path: string) { | |||
| 36 | function reset() { browse(props.start || undefined) } | 35 | function reset() { browse(props.start || undefined) } |
| 37 | defineExpose({ reset }) | 36 | defineExpose({ reset }) |
| 38 | onMounted(reset) | 37 | onMounted(reset) |
| 38 | + | ||
| 39 | +const isDrive = (name: string) => /^[a-zA-Z]:[\\/]?$/.test(name) | ||
| 39 | </script> | 40 | </script> |
| 40 | 41 | ||
| 41 | <template> | 42 | <template> |
| 42 | - <div class="space-y-3"> | 43 | + <div class="flex flex-col gap-2.5"> |
| 43 | - <div class="flex flex-wrap items-center gap-1 text-sm"> | 44 | + <nav class="flex flex-wrap items-center gap-0.5 font-mono text-xs" aria-label="Folder path"> |
| 44 | - <UButton label="This PC" icon="i-lucide-hard-drive" size="xs" color="neutral" variant="ghost" @click="browse()" /> | 45 | + <button type="button" class="cursor-pointer rounded-md border-0 bg-transparent px-1.5 py-1 text-sk-muted hover:bg-sk-line2 hover:text-sk-text" @click="browse()">This PC</button> |
| 45 | <template v-for="c in crumbs" :key="c.path"> | 46 | <template v-for="c in crumbs" :key="c.path"> |
| 46 | - <UIcon name="i-lucide-chevron-right" class="size-3.5 text-dimmed" /> | 47 | + <span class="text-sk-faint" aria-hidden="true">/</span> |
| 47 | - <UButton :label="c.name" size="xs" color="neutral" variant="ghost" @click="browse(c.path)" /> | 48 | + <button type="button" class="cursor-pointer rounded-md border-0 bg-transparent px-1.5 py-1 text-sk-muted hover:bg-sk-line2 hover:text-sk-text" @click="browse(c.path)">{{ c.name }}</button> |
| 48 | </template> | 49 | </template> |
| 49 | - </div> | 50 | + </nav> |
| 50 | 51 | ||
| 51 | - <div class="overflow-y-auto rounded-lg border border-default" :class="height ?? 'h-72'"> | 52 | + <div class="overflow-y-auto rounded-[10px] border border-sk-line" :class="height ?? 'h-72'"> |
| 52 | - <div v-if="loading" class="space-y-1 p-2"> | 53 | + <div v-if="loading" class="flex flex-col gap-1 p-2"> |
| 53 | <USkeleton v-for="i in 6" :key="i" class="h-8 w-full" /> | 54 | <USkeleton v-for="i in 6" :key="i" class="h-8 w-full" /> |
| 54 | </div> | 55 | </div> |
| 55 | - <div v-else-if="listError" class="p-3"> | 56 | + <SkNote v-else-if="listError" tone="accent" class="m-2">Cannot list this folder: {{ listError }}</SkNote> |
| 56 | - <UAlert color="error" variant="subtle" icon="i-lucide-circle-alert" title="Cannot list this folder" :description="listError" /> | 57 | + <ul v-else-if="listing" class="m-0 list-none p-0"> |
| 57 | - </div> | 58 | + <li v-if="listing.parent" class="border-b border-sk-line2"> |
| 58 | - <ul v-else-if="listing" class="divide-y divide-default text-sm"> | 59 | + <button type="button" class="flex w-full cursor-pointer items-center gap-2 border-0 bg-transparent px-3.5 py-2 text-left text-sk-muted hover:bg-sk-hover" @click="browse(listing.parent!)">..</button> |
| 59 | - <li v-if="listing.parent"> | ||
| 60 | - <button type="button" class="flex w-full items-center gap-2 px-3 py-2 hover:bg-elevated/60" @click="browse(listing.parent!)"> | ||
| 61 | - <UIcon name="i-lucide-corner-left-up" class="size-4 text-muted" /> | ||
| 62 | - <span class="text-muted">..</span> | ||
| 63 | - </button> | ||
| 64 | </li> | 60 | </li> |
| 65 | <li | 61 | <li |
| 66 | v-for="e in listing.entries" | 62 | v-for="e in listing.entries" |
| 67 | :key="e.path" | 63 | :key="e.path" |
| 68 | - class="flex items-center hover:bg-elevated/60" | 64 | + class="flex items-center border-t border-sk-line2 first:border-t-0 hover:bg-sk-hover" |
| 69 | - :class="highlighted === e.path ? 'bg-primary/10' : ''" | 65 | + :class="highlighted === e.path && 'bg-sk-sel hover:bg-sk-sel'" |
| 70 | > | 66 | > |
| 71 | <button | 67 | <button |
| 72 | type="button" | 68 | type="button" |
| 73 | - class="flex min-w-0 flex-1 items-center gap-2 py-2 pl-3 text-left" | 69 | + class="flex min-w-0 flex-1 cursor-pointer items-center gap-2.5 border-0 bg-transparent py-2 pr-2 pl-3.5 text-left text-sk-text" |
| 74 | :title="e.path" | 70 | :title="e.path" |
| 75 | @click="highlight(e.path)" | 71 | @click="highlight(e.path)" |
| 76 | @dblclick="browse(e.path)" | 72 | @dblclick="browse(e.path)" |
| 77 | > | 73 | > |
| 78 | - <UIcon :name="listing.path ? 'i-lucide-folder' : /^[a-zA-Z]:[\\/]?$/.test(e.name) ? 'i-lucide-hard-drive' : 'i-lucide-house'" class="size-4 shrink-0 text-muted" /> | 74 | + <span class="truncate" :class="highlighted === e.path && 'font-medium'">{{ e.name }}</span> |
| 79 | - <span class="flex-1 truncate">{{ e.name }}</span> | 75 | + <span v-if="isDrive(e.name) && !listing.path" class="font-mono text-[11px] text-sk-faint">drive</span> |
| 80 | - <UBadge v-if="e.hasAgentConfig" label="agent config" color="primary" variant="subtle" size="sm" icon="i-lucide-sparkles" class="shrink-0" /> | 76 | + <span v-if="e.hasAgentConfig" class="ml-auto shrink-0 font-mono text-[11px] text-sk-faint">agent config</span> |
| 77 | + </button> | ||
| 78 | + <button | ||
| 79 | + type="button" | ||
| 80 | + class="mr-2 flex size-6 shrink-0 cursor-pointer items-center justify-center rounded-md border-0 bg-transparent text-sk-faint hover:bg-sk-line2 hover:text-sk-text" | ||
| 81 | + :aria-label="`Open ${e.name}`" | ||
| 82 | + @click="browse(e.path)" | ||
| 83 | + > | ||
| 84 | + <UIcon name="i-lucide-chevron-right" class="size-3.5" /> | ||
| 81 | </button> | 85 | </button> |
| 82 | - <UButton icon="i-lucide-chevron-right" size="xs" color="neutral" variant="ghost" class="mx-2 shrink-0" :aria-label="`Open ${e.name}`" @click="browse(e.path)" /> | ||
| 83 | </li> | 86 | </li> |
| 84 | - <li v-if="!listing.entries.length" class="px-3 py-8 text-center text-muted">No subfolder here.</li> | 87 | + <li v-if="!listing.entries.length" class="px-3 py-8 text-center text-sk-faint">No subfolder here.</li> |
| 85 | </ul> | 88 | </ul> |
| 86 | </div> | 89 | </div> |
| 87 | </div> | 90 | </div> |
modified
packages/ui/app/components/FolderPickerModal.vue +6 -12 | @@ -12,18 +12,12 @@ function confirm() { | ||
| 12 | 12 | </script> |
| 13 | 13 | |
| 14 | 14 | <template> |
| 15 | - <UModal v-model:open="open" :title="title ?? 'Choose a folder'" description="Browse to a folder, then select it." :ui="{ content: 'max-w-2xl' }"> | |
| 16 | - <template #body> | |
| 17 | - <FolderBrowser v-if="open" v-model:selected="selected" :start="start" /> | |
| 18 | - </template> | |
| 15 | + <SkModal v-model:open="open" :title="title ?? 'Choose a folder'" description="Browse to a folder, then select it." width="lg"> | |
| 16 | + <FolderBrowser v-if="open" v-model:selected="selected" :start="start" /> | |
| 19 | 17 | <template #footer> |
| 20 | - <div class="flex w-full items-center justify-between gap-2"> | |
| 21 | - <span class="truncate font-mono text-xs text-muted" :title="selected ?? ''">{{ selected ?? 'Select a drive or folder' }}</span> | |
| 22 | - <div class="flex shrink-0 gap-2"> | |
| 23 | - <UButton label="Cancel" color="neutral" variant="outline" @click="open = false" /> | |
| 24 | - <UButton label="Select this folder" icon="i-lucide-check" :disabled="!selected" @click="confirm" /> | |
| 25 | - </div> | |
| 26 | - </div> | |
| 18 | + <span class="mr-auto min-w-0 truncate font-mono text-[11px] text-sk-faint" :title="selected ?? ''">{{ selected ?? 'Select a drive or folder' }}</span> | |
| 19 | + <SkButton label="Cancel" @click="open = false" /> | |
| 20 | + <SkButton variant="primary" label="Select this folder" :disabled="!selected" @click="confirm" /> | |
| 27 | 21 | </template> |
| 28 | - </UModal> | |
| 22 | + </SkModal> | |
| 29 | 23 | </template> |
| @@ -12,18 +12,12 @@ function confirm() { | |||
| 12 | </script> | 12 | </script> |
| 13 | 13 | ||
| 14 | <template> | 14 | <template> |
| 15 | - <UModal v-model:open="open" :title="title ?? 'Choose a folder'" description="Browse to a folder, then select it." :ui="{ content: 'max-w-2xl' }"> | 15 | + <SkModal v-model:open="open" :title="title ?? 'Choose a folder'" description="Browse to a folder, then select it." width="lg"> |
| 16 | - <template #body> | 16 | + <FolderBrowser v-if="open" v-model:selected="selected" :start="start" /> |
| 17 | - <FolderBrowser v-if="open" v-model:selected="selected" :start="start" /> | ||
| 18 | - </template> | ||
| 19 | <template #footer> | 17 | <template #footer> |
| 20 | - <div class="flex w-full items-center justify-between gap-2"> | 18 | + <span class="mr-auto min-w-0 truncate font-mono text-[11px] text-sk-faint" :title="selected ?? ''">{{ selected ?? 'Select a drive or folder' }}</span> |
| 21 | - <span class="truncate font-mono text-xs text-muted" :title="selected ?? ''">{{ selected ?? 'Select a drive or folder' }}</span> | 19 | + <SkButton label="Cancel" @click="open = false" /> |
| 22 | - <div class="flex shrink-0 gap-2"> | 20 | + <SkButton variant="primary" label="Select this folder" :disabled="!selected" @click="confirm" /> |
| 23 | - <UButton label="Cancel" color="neutral" variant="outline" @click="open = false" /> | ||
| 24 | - <UButton label="Select this folder" icon="i-lucide-check" :disabled="!selected" @click="confirm" /> | ||
| 25 | - </div> | ||
| 26 | - </div> | ||
| 27 | </template> | 21 | </template> |
| 28 | - </UModal> | 22 | + </SkModal> |
| 29 | </template> | 23 | </template> |
modified
packages/ui/app/components/HookForm.vue +18 -17 | @@ -23,34 +23,35 @@ function toggleAgent(a: AgentId, on: boolean | 'indeterminate') { | ||
| 23 | 23 | </script> |
| 24 | 24 | |
| 25 | 25 | <template> |
| 26 | - <div class="space-y-4"> | |
| 27 | - <UFormField label="Description"> | |
| 28 | - <UInput :model-value="t.description ?? ''" class="w-full" placeholder="What this hook does" @update:model-value="(v: string) => patch({ description: v })" /> | |
| 29 | - </UFormField> | |
| 26 | + <div class="flex flex-col gap-4"> | |
| 27 | + <CatalogField label="Description"> | |
| 28 | + <UInput :model-value="t.description ?? ''" class="w-full" placeholder="What this hook does" @update:model-value="(v: string) => patch({ description: v || undefined })" /> | |
| 29 | + </CatalogField> | |
| 30 | 30 | |
| 31 | 31 | <div class="grid gap-4 sm:grid-cols-2"> |
| 32 | - <UFormField label="Event" required> | |
| 32 | + <CatalogField label="Event" required> | |
| 33 | 33 | <USelect :model-value="t.event" :items="events" placeholder="Choose an event" class="w-full" @update:model-value="(v: string) => patch({ event: v })" /> |
| 34 | - </UFormField> | |
| 35 | - <UFormField label="Matcher" description="Tool name or regex; empty matches everything."> | |
| 36 | - <UInput :model-value="t.matcher ?? ''" class="w-full" placeholder="Edit|Write" :ui="{ base: 'font-mono' }" @update:model-value="(v: string) => patch({ matcher: v || undefined })" /> | |
| 37 | - </UFormField> | |
| 34 | + </CatalogField> | |
| 35 | + <CatalogField label="Matcher" hint="Tool name or regex; empty matches everything."> | |
| 36 | + <UInput :model-value="t.matcher ?? ''" class="w-full" placeholder="Edit|Write" :ui="{ base: 'font-mono text-xs' }" @update:model-value="(v: string) => patch({ matcher: v || undefined })" /> | |
| 37 | + </CatalogField> | |
| 38 | 38 | </div> |
| 39 | 39 | |
| 40 | - <UFormField label="Command" required description="Shell command run by the agent. skilly never executes it."> | |
| 41 | - <UInput :model-value="t.handler.command" class="w-full" placeholder="prettier --write $FILE" :ui="{ base: 'font-mono' }" @update:model-value="(v: string) => patchHandler({ command: v })" /> | |
| 42 | - </UFormField> | |
| 40 | + <CatalogField label="Command" required hint="Run by the agent. skilly never executes it."> | |
| 41 | + <UInput :model-value="t.handler.command" class="w-full" placeholder="prettier --write $FILE" :ui="{ base: 'font-mono text-xs' }" @update:model-value="(v: string) => patchHandler({ command: v })" /> | |
| 42 | + </CatalogField> | |
| 43 | 43 | |
| 44 | 44 | <div class="grid gap-4 sm:grid-cols-2"> |
| 45 | - <UFormField label="Timeout (seconds)"> | |
| 45 | + <CatalogField label="Timeout" hint="Seconds."> | |
| 46 | 46 | <UInputNumber :model-value="t.handler.timeout" :min="1" class="w-40" @update:model-value="(v: number | null | undefined) => patchHandler({ timeout: v || undefined })" /> |
| 47 | - </UFormField> | |
| 48 | - <UFormField label="Agents" description="None selected means all target agents."> | |
| 49 | - <div class="flex gap-4 pt-1"> | |
| 47 | + </CatalogField> | |
| 48 | + <div class="flex flex-col gap-1.5"> | |
| 49 | + <span class="flex flex-wrap items-baseline gap-x-2"><span class="font-medium">Agents</span><span class="text-xs text-sk-faint">None selected means all target agents.</span></span> | |
| 50 | + <div class="flex gap-4 pt-1.5"> | |
| 50 | 51 | <UCheckbox label="Claude Code" :model-value="agents.includes('claude')" @update:model-value="(v) => toggleAgent('claude', v)" /> |
| 51 | 52 | <UCheckbox label="Codex" :model-value="agents.includes('codex')" @update:model-value="(v) => toggleAgent('codex', v)" /> |
| 52 | 53 | </div> |
| 53 | - </UFormField> | |
| 54 | + </div> | |
| 54 | 55 | </div> |
| 55 | 56 | </div> |
| 56 | 57 | </template> |
| @@ -23,34 +23,35 @@ function toggleAgent(a: AgentId, on: boolean | 'indeterminate') { | |||
| 23 | </script> | 23 | </script> |
| 24 | 24 | ||
| 25 | <template> | 25 | <template> |
| 26 | - <div class="space-y-4"> | 26 | + <div class="flex flex-col gap-4"> |
| 27 | - <UFormField label="Description"> | 27 | + <CatalogField label="Description"> |
| 28 | - <UInput :model-value="t.description ?? ''" class="w-full" placeholder="What this hook does" @update:model-value="(v: string) => patch({ description: v })" /> | 28 | + <UInput :model-value="t.description ?? ''" class="w-full" placeholder="What this hook does" @update:model-value="(v: string) => patch({ description: v || undefined })" /> |
| 29 | - </UFormField> | 29 | + </CatalogField> |
| 30 | 30 | ||
| 31 | <div class="grid gap-4 sm:grid-cols-2"> | 31 | <div class="grid gap-4 sm:grid-cols-2"> |
| 32 | - <UFormField label="Event" required> | 32 | + <CatalogField label="Event" required> |
| 33 | <USelect :model-value="t.event" :items="events" placeholder="Choose an event" class="w-full" @update:model-value="(v: string) => patch({ event: v })" /> | 33 | <USelect :model-value="t.event" :items="events" placeholder="Choose an event" class="w-full" @update:model-value="(v: string) => patch({ event: v })" /> |
| 34 | - </UFormField> | 34 | + </CatalogField> |
| 35 | - <UFormField label="Matcher" description="Tool name or regex; empty matches everything."> | 35 | + <CatalogField label="Matcher" hint="Tool name or regex; empty matches everything."> |
| 36 | - <UInput :model-value="t.matcher ?? ''" class="w-full" placeholder="Edit|Write" :ui="{ base: 'font-mono' }" @update:model-value="(v: string) => patch({ matcher: v || undefined })" /> | 36 | + <UInput :model-value="t.matcher ?? ''" class="w-full" placeholder="Edit|Write" :ui="{ base: 'font-mono text-xs' }" @update:model-value="(v: string) => patch({ matcher: v || undefined })" /> |
| 37 | - </UFormField> | 37 | + </CatalogField> |
| 38 | </div> | 38 | </div> |
| 39 | 39 | ||
| 40 | - <UFormField label="Command" required description="Shell command run by the agent. skilly never executes it."> | 40 | + <CatalogField label="Command" required hint="Run by the agent. skilly never executes it."> |
| 41 | - <UInput :model-value="t.handler.command" class="w-full" placeholder="prettier --write $FILE" :ui="{ base: 'font-mono' }" @update:model-value="(v: string) => patchHandler({ command: v })" /> | 41 | + <UInput :model-value="t.handler.command" class="w-full" placeholder="prettier --write $FILE" :ui="{ base: 'font-mono text-xs' }" @update:model-value="(v: string) => patchHandler({ command: v })" /> |
| 42 | - </UFormField> | 42 | + </CatalogField> |
| 43 | 43 | ||
| 44 | <div class="grid gap-4 sm:grid-cols-2"> | 44 | <div class="grid gap-4 sm:grid-cols-2"> |
| 45 | - <UFormField label="Timeout (seconds)"> | 45 | + <CatalogField label="Timeout" hint="Seconds."> |
| 46 | <UInputNumber :model-value="t.handler.timeout" :min="1" class="w-40" @update:model-value="(v: number | null | undefined) => patchHandler({ timeout: v || undefined })" /> | 46 | <UInputNumber :model-value="t.handler.timeout" :min="1" class="w-40" @update:model-value="(v: number | null | undefined) => patchHandler({ timeout: v || undefined })" /> |
| 47 | - </UFormField> | 47 | + </CatalogField> |
| 48 | - <UFormField label="Agents" description="None selected means all target agents."> | 48 | + <div class="flex flex-col gap-1.5"> |
| 49 | - <div class="flex gap-4 pt-1"> | 49 | + <span class="flex flex-wrap items-baseline gap-x-2"><span class="font-medium">Agents</span><span class="text-xs text-sk-faint">None selected means all target agents.</span></span> |
| 50 | + <div class="flex gap-4 pt-1.5"> | ||
| 50 | <UCheckbox label="Claude Code" :model-value="agents.includes('claude')" @update:model-value="(v) => toggleAgent('claude', v)" /> | 51 | <UCheckbox label="Claude Code" :model-value="agents.includes('claude')" @update:model-value="(v) => toggleAgent('claude', v)" /> |
| 51 | <UCheckbox label="Codex" :model-value="agents.includes('codex')" @update:model-value="(v) => toggleAgent('codex', v)" /> | 52 | <UCheckbox label="Codex" :model-value="agents.includes('codex')" @update:model-value="(v) => toggleAgent('codex', v)" /> |
| 52 | </div> | 53 | </div> |
| 53 | - </UFormField> | 54 | + </div> |
| 54 | </div> | 55 | </div> |
| 55 | </div> | 56 | </div> |
| 56 | </template> | 57 | </template> |
deleted
packages/ui/app/components/ItemDetail.vue +0 -127 | deleted file mode 100644 | ||
| @@ -1,127 +0,0 @@ | ||
| 1 | -<script setup lang="ts"> | |
| 2 | -import type { InventoryItem, SkillDetails } from '@skilly/core' | |
| 3 | - | |
| 4 | -const props = defineProps<{ item: InventoryItem | null }>() | |
| 5 | -const open = defineModel<boolean>('open', { default: false }) | |
| 6 | - | |
| 7 | -const filePath = computed<string | null>(() => { | |
| 8 | - const it = props.item | |
| 9 | - if (!it) return null | |
| 10 | - if (it.type === 'skill') return joinPath(it.source.path, 'SKILL.md') | |
| 11 | - if (['rule', 'agent', 'instructions'].includes(it.type) && it.source.kind === 'file') return it.source.path | |
| 12 | - return null | |
| 13 | -}) | |
| 14 | - | |
| 15 | -/** Detail rows whose value is a path, command or identifier (shown in monospace). */ | |
| 16 | -const MONO_ROWS = new Set(['Source', 'Config key', 'Symlink target', 'Hash', 'Command', 'URL', 'Env vars', 'Literal env keys', 'Matcher', 'Tools', 'Imports', 'Applies to']) | |
| 17 | - | |
| 18 | -/** Skill file currently displayed (defaults to SKILL.md). */ | |
| 19 | -const currentFile = ref<string | null>(null) | |
| 20 | -watch(() => props.item?.id, () => { currentFile.value = null }) | |
| 21 | -const shownPath = computed(() => currentFile.value ?? filePath.value) | |
| 22 | - | |
| 23 | -// SKILL.md first: it is the file shown by default. | |
| 24 | -const skillFiles = computed(() => (props.item?.type === 'skill' | |
| 25 | - ? [...(props.item.details as SkillDetails).files].sort((a, b) => Number(b === 'SKILL.md') - Number(a === 'SKILL.md')) | |
| 26 | - : [])) | |
| 27 | - | |
| 28 | -const frontmatter = computed(() => { | |
| 29 | - const d = props.item?.details as { frontmatter?: Record<string, unknown> } | undefined | |
| 30 | - return currentFile.value ? undefined : d?.frontmatter | |
| 31 | -}) | |
| 32 | - | |
| 33 | -const rows = computed(() => { | |
| 34 | - const it = props.item | |
| 35 | - if (!it) return [] | |
| 36 | - const r: [string, string][] = [ | |
| 37 | - ['Type', it.type], | |
| 38 | - ['Agent', AGENT_LABELS[it.agent]], | |
| 39 | - ['Source', it.source.path] | |
| 40 | - ] | |
| 41 | - if (it.source.configKey) r.push(['Config key', it.source.configKey]) | |
| 42 | - if (it.source.symlinkTarget) r.push(['Symlink target', it.source.symlinkTarget]) | |
| 43 | - if (it.source.plugin) r.push(['Plugin', it.source.plugin]) | |
| 44 | - if (it.hash) r.push(['Hash', it.hash.slice(0, 12)]) | |
| 45 | - const d = it.details | |
| 46 | - if (d.type === 'skill') { | |
| 47 | - r.push(['Visibility', d.visibility]) | |
| 48 | - if (d.visibilitySource) r.push(['Visibility set by', d.visibilitySource]) | |
| 49 | - } else if (d.type === 'rule') { | |
| 50 | - r.push(['Applies to', d.paths.length ? d.paths.join(', ') : 'always']) | |
| 51 | - } else if (d.type === 'mcp') { | |
| 52 | - r.push(['Transport', d.transport]) | |
| 53 | - if (d.command) r.push(['Command', [d.command, ...(d.args ?? [])].join(' ')]) | |
| 54 | - if (d.url) r.push(['URL', d.url]) | |
| 55 | - if (d.agentScope) r.push(['Scope', d.agentScope]) | |
| 56 | - r.push(['Enabled', d.enabled ? 'yes' : 'no']) | |
| 57 | - if (d.envVarNames.length) r.push(['Env vars', d.envVarNames.join(', ')]) | |
| 58 | - if (d.literalEnvKeys.length) r.push(['Literal env keys', d.literalEnvKeys.join(', ')]) | |
| 59 | - } else if (d.type === 'hook') { | |
| 60 | - r.push(['Event', d.event]) | |
| 61 | - if (d.matcher) r.push(['Matcher', d.matcher]) | |
| 62 | - r.push(['Handler', d.handlerType]) | |
| 63 | - if (d.command) r.push(['Command', d.command]) | |
| 64 | - } else if (d.type === 'agent') { | |
| 65 | - if (d.model) r.push(['Model', d.model]) | |
| 66 | - if (d.tools?.length) r.push(['Tools', d.tools.join(', ')]) | |
| 67 | - } else if (d.type === 'instructions') { | |
| 68 | - r.push(['Size', formatBytes(d.sizeBytes)]) | |
| 69 | - r.push(['Loaded', d.loaded ? 'yes' : `no${d.notLoadedReason ? ` - ${d.notLoadedReason}` : ''}`]) | |
| 70 | - if (d.imports.length) r.push(['Imports', d.imports.join(', ')]) | |
| 71 | - } | |
| 72 | - return r | |
| 73 | -}) | |
| 74 | -</script> | |
| 75 | - | |
| 76 | -<template> | |
| 77 | - <USlideover v-model:open="open" :title="item?.name" :description="item?.description" :ui="{ content: 'sm:max-w-2xl', description: 'line-clamp-3' }"> | |
| 78 | - <template #body> | |
| 79 | - <div v-if="item" class="space-y-5"> | |
| 80 | - <UAlert | |
| 81 | - v-if="item.warnings.length" | |
| 82 | - color="warning" | |
| 83 | - variant="subtle" | |
| 84 | - icon="i-lucide-triangle-alert" | |
| 85 | - :title="item.warnings.length > 1 ? 'Warnings' : 'Warning'" | |
| 86 | - > | |
| 87 | - <template #description> | |
| 88 | - <ul class="list-disc space-y-0.5 pl-4"> | |
| 89 | - <li v-for="w in item.warnings" :key="w">{{ w }}</li> | |
| 90 | - </ul> | |
| 91 | - </template> | |
| 92 | - </UAlert> | |
| 93 | - | |
| 94 | - <div class="overflow-hidden rounded-lg border border-default"> | |
| 95 | - <table class="w-full text-sm"> | |
| 96 | - <tbody class="divide-y divide-default"> | |
| 97 | - <tr v-for="[k, v] in rows" :key="k"> | |
| 98 | - <th class="w-36 bg-elevated/40 px-3 py-2 text-left align-top text-xs font-medium text-muted">{{ k }}</th> | |
| 99 | - <td class="px-3 py-2 text-xs" :class="MONO_ROWS.has(k) ? 'font-mono break-all' : 'break-words'">{{ v }}</td> | |
| 100 | - </tr> | |
| 101 | - </tbody> | |
| 102 | - </table> | |
| 103 | - </div> | |
| 104 | - | |
| 105 | - <div v-if="skillFiles.length" class="space-y-2"> | |
| 106 | - <h3 class="text-sm font-semibold">Files ({{ skillFiles.length }})</h3> | |
| 107 | - <ul class="max-h-48 overflow-auto rounded-lg border border-default text-xs"> | |
| 108 | - <li v-for="f in skillFiles" :key="f"> | |
| 109 | - <button | |
| 110 | - type="button" | |
| 111 | - class="flex w-full items-center gap-2 px-3 py-1.5 text-left font-mono hover:bg-elevated/60" | |
| 112 | - :class="shownPath === joinPath(item.source.path, f) ? 'bg-elevated text-highlighted' : ''" | |
| 113 | - @click="currentFile = f === 'SKILL.md' ? null : joinPath(item.source.path, f)" | |
| 114 | - > | |
| 115 | - <UIcon name="i-lucide-file" class="size-3.5 shrink-0 text-muted" /> | |
| 116 | - <span class="truncate">{{ f }}</span> | |
| 117 | - </button> | |
| 118 | - </li> | |
| 119 | - </ul> | |
| 120 | - </div> | |
| 121 | - | |
| 122 | - <MarkdownView v-if="shownPath" :path="shownPath" :frontmatter="frontmatter" /> | |
| 123 | - <p v-else class="text-sm text-muted">This item is an entry of a configuration file: there is no separate file to display.</p> | |
| 124 | - </div> | |
| 125 | - </template> | |
| 126 | - </USlideover> | |
| 127 | -</template> | |
| deleted file mode 100644 | |||
| @@ -1,127 +0,0 @@ | |||
| 1 | -<script setup lang="ts"> | ||
| 2 | -import type { InventoryItem, SkillDetails } from '@skilly/core' | ||
| 3 | - | ||
| 4 | -const props = defineProps<{ item: InventoryItem | null }>() | ||
| 5 | -const open = defineModel<boolean>('open', { default: false }) | ||
| 6 | - | ||
| 7 | -const filePath = computed<string | null>(() => { | ||
| 8 | - const it = props.item | ||
| 9 | - if (!it) return null | ||
| 10 | - if (it.type === 'skill') return joinPath(it.source.path, 'SKILL.md') | ||
| 11 | - if (['rule', 'agent', 'instructions'].includes(it.type) && it.source.kind === 'file') return it.source.path | ||
| 12 | - return null | ||
| 13 | -}) | ||
| 14 | - | ||
| 15 | -/** Detail rows whose value is a path, command or identifier (shown in monospace). */ | ||
| 16 | -const MONO_ROWS = new Set(['Source', 'Config key', 'Symlink target', 'Hash', 'Command', 'URL', 'Env vars', 'Literal env keys', 'Matcher', 'Tools', 'Imports', 'Applies to']) | ||
| 17 | - | ||
| 18 | -/** Skill file currently displayed (defaults to SKILL.md). */ | ||
| 19 | -const currentFile = ref<string | null>(null) | ||
| 20 | -watch(() => props.item?.id, () => { currentFile.value = null }) | ||
| 21 | -const shownPath = computed(() => currentFile.value ?? filePath.value) | ||
| 22 | - | ||
| 23 | -// SKILL.md first: it is the file shown by default. | ||
| 24 | -const skillFiles = computed(() => (props.item?.type === 'skill' | ||
| 25 | - ? [...(props.item.details as SkillDetails).files].sort((a, b) => Number(b === 'SKILL.md') - Number(a === 'SKILL.md')) | ||
| 26 | - : [])) | ||
| 27 | - | ||
| 28 | -const frontmatter = computed(() => { | ||
| 29 | - const d = props.item?.details as { frontmatter?: Record<string, unknown> } | undefined | ||
| 30 | - return currentFile.value ? undefined : d?.frontmatter | ||
| 31 | -}) | ||
| 32 | - | ||
| 33 | -const rows = computed(() => { | ||
| 34 | - const it = props.item | ||
| 35 | - if (!it) return [] | ||
| 36 | - const r: [string, string][] = [ | ||
| 37 | - ['Type', it.type], | ||
| 38 | - ['Agent', AGENT_LABELS[it.agent]], | ||
| 39 | - ['Source', it.source.path] | ||
| 40 | - ] | ||
| 41 | - if (it.source.configKey) r.push(['Config key', it.source.configKey]) | ||
| 42 | - if (it.source.symlinkTarget) r.push(['Symlink target', it.source.symlinkTarget]) | ||
| 43 | - if (it.source.plugin) r.push(['Plugin', it.source.plugin]) | ||
| 44 | - if (it.hash) r.push(['Hash', it.hash.slice(0, 12)]) | ||
| 45 | - const d = it.details | ||
| 46 | - if (d.type === 'skill') { | ||
| 47 | - r.push(['Visibility', d.visibility]) | ||
| 48 | - if (d.visibilitySource) r.push(['Visibility set by', d.visibilitySource]) | ||
| 49 | - } else if (d.type === 'rule') { | ||
| 50 | - r.push(['Applies to', d.paths.length ? d.paths.join(', ') : 'always']) | ||
| 51 | - } else if (d.type === 'mcp') { | ||
| 52 | - r.push(['Transport', d.transport]) | ||
| 53 | - if (d.command) r.push(['Command', [d.command, ...(d.args ?? [])].join(' ')]) | ||
| 54 | - if (d.url) r.push(['URL', d.url]) | ||
| 55 | - if (d.agentScope) r.push(['Scope', d.agentScope]) | ||
| 56 | - r.push(['Enabled', d.enabled ? 'yes' : 'no']) | ||
| 57 | - if (d.envVarNames.length) r.push(['Env vars', d.envVarNames.join(', ')]) | ||
| 58 | - if (d.literalEnvKeys.length) r.push(['Literal env keys', d.literalEnvKeys.join(', ')]) | ||
| 59 | - } else if (d.type === 'hook') { | ||
| 60 | - r.push(['Event', d.event]) | ||
| 61 | - if (d.matcher) r.push(['Matcher', d.matcher]) | ||
| 62 | - r.push(['Handler', d.handlerType]) | ||
| 63 | - if (d.command) r.push(['Command', d.command]) | ||
| 64 | - } else if (d.type === 'agent') { | ||
| 65 | - if (d.model) r.push(['Model', d.model]) | ||
| 66 | - if (d.tools?.length) r.push(['Tools', d.tools.join(', ')]) | ||
| 67 | - } else if (d.type === 'instructions') { | ||
| 68 | - r.push(['Size', formatBytes(d.sizeBytes)]) | ||
| 69 | - r.push(['Loaded', d.loaded ? 'yes' : `no${d.notLoadedReason ? ` - ${d.notLoadedReason}` : ''}`]) | ||
| 70 | - if (d.imports.length) r.push(['Imports', d.imports.join(', ')]) | ||
| 71 | - } | ||
| 72 | - return r | ||
| 73 | -}) | ||
| 74 | -</script> | ||
| 75 | - | ||
| 76 | -<template> | ||
| 77 | - <USlideover v-model:open="open" :title="item?.name" :description="item?.description" :ui="{ content: 'sm:max-w-2xl', description: 'line-clamp-3' }"> | ||
| 78 | - <template #body> | ||
| 79 | - <div v-if="item" class="space-y-5"> | ||
| 80 | - <UAlert | ||
| 81 | - v-if="item.warnings.length" | ||
| 82 | - color="warning" | ||
| 83 | - variant="subtle" | ||
| 84 | - icon="i-lucide-triangle-alert" | ||
| 85 | - :title="item.warnings.length > 1 ? 'Warnings' : 'Warning'" | ||
| 86 | - > | ||
| 87 | - <template #description> | ||
| 88 | - <ul class="list-disc space-y-0.5 pl-4"> | ||
| 89 | - <li v-for="w in item.warnings" :key="w">{{ w }}</li> | ||
| 90 | - </ul> | ||
| 91 | - </template> | ||
| 92 | - </UAlert> | ||
| 93 | - | ||
| 94 | - <div class="overflow-hidden rounded-lg border border-default"> | ||
| 95 | - <table class="w-full text-sm"> | ||
| 96 | - <tbody class="divide-y divide-default"> | ||
| 97 | - <tr v-for="[k, v] in rows" :key="k"> | ||
| 98 | - <th class="w-36 bg-elevated/40 px-3 py-2 text-left align-top text-xs font-medium text-muted">{{ k }}</th> | ||
| 99 | - <td class="px-3 py-2 text-xs" :class="MONO_ROWS.has(k) ? 'font-mono break-all' : 'break-words'">{{ v }}</td> | ||
| 100 | - </tr> | ||
| 101 | - </tbody> | ||
| 102 | - </table> | ||
| 103 | - </div> | ||
| 104 | - | ||
| 105 | - <div v-if="skillFiles.length" class="space-y-2"> | ||
| 106 | - <h3 class="text-sm font-semibold">Files ({{ skillFiles.length }})</h3> | ||
| 107 | - <ul class="max-h-48 overflow-auto rounded-lg border border-default text-xs"> | ||
| 108 | - <li v-for="f in skillFiles" :key="f"> | ||
| 109 | - <button | ||
| 110 | - type="button" | ||
| 111 | - class="flex w-full items-center gap-2 px-3 py-1.5 text-left font-mono hover:bg-elevated/60" | ||
| 112 | - :class="shownPath === joinPath(item.source.path, f) ? 'bg-elevated text-highlighted' : ''" | ||
| 113 | - @click="currentFile = f === 'SKILL.md' ? null : joinPath(item.source.path, f)" | ||
| 114 | - > | ||
| 115 | - <UIcon name="i-lucide-file" class="size-3.5 shrink-0 text-muted" /> | ||
| 116 | - <span class="truncate">{{ f }}</span> | ||
| 117 | - </button> | ||
| 118 | - </li> | ||
| 119 | - </ul> | ||
| 120 | - </div> | ||
| 121 | - | ||
| 122 | - <MarkdownView v-if="shownPath" :path="shownPath" :frontmatter="frontmatter" /> | ||
| 123 | - <p v-else class="text-sm text-muted">This item is an entry of a configuration file: there is no separate file to display.</p> | ||
| 124 | - </div> | ||
| 125 | - </template> | ||
| 126 | - </USlideover> | ||
| 127 | -</template> | ||
modified
packages/ui/app/components/ItemPicker.vue +76 -50 | @@ -2,78 +2,104 @@ | ||
| 2 | 2 | import type { CatalogItem, CatalogItemType } from '@skilly/core' |
| 3 | 3 | |
| 4 | 4 | /** |
| 5 | - * Checkbox picker over the catalog, per type with search. | |
| 6 | - * `locked` maps `type/name` to a label: those rows are shown checked and disabled (already provided elsewhere). | |
| 5 | + * Checkbox picker over the catalog, grouped by type, with a filter. | |
| 6 | + * `locked` maps `type/name` to a label ("via Base"): those rows are shown checked at reduced opacity and cannot be toggled | |
| 7 | + * (already provided elsewhere). | |
| 8 | + * `height` is a Tailwind height class for the scrollable list (default `h-72`). | |
| 9 | + * `title`: when set, the picker renders as a full panel (title + filter in the header, rows edge to edge, no inner scroll), | |
| 10 | + * as in the preset editor. Without it the picker is a bordered, scrollable list (modals, drawers). | |
| 7 | 11 | */ |
| 8 | 12 | const props = withDefaults(defineProps<{ |
| 9 | 13 | items: CatalogItem[] |
| 10 | 14 | types?: CatalogItemType[] |
| 11 | 15 | locked?: Record<string, string> |
| 12 | 16 | height?: string |
| 13 | -}>(), { types: () => CAT_TYPES, locked: () => ({}), height: 'h-72' }) | |
| 17 | + title?: string | |
| 18 | +}>(), { types: () => CAT_ORDER, locked: () => ({}), height: 'h-72', title: undefined }) | |
| 14 | 19 | |
| 15 | 20 | const selected = defineModel<Partial<Record<CatalogItemType, string[]>>>({ required: true }) |
| 16 | 21 | |
| 17 | -const tab = ref<CatalogItemType>(props.types[0] ?? 'skill') | |
| 18 | 22 | const search = ref('') |
| 19 | 23 | const LIMIT = 200 |
| 20 | -const showAll = ref(false) | |
| 24 | +const expanded = ref<Partial<Record<CatalogItemType, boolean>>>({}) | |
| 25 | +watch(search, () => { expanded.value = {} }) | |
| 21 | 26 | |
| 22 | -watch([tab, search], () => { showAll.value = false }) | |
| 27 | +const matches = (i: CatalogItem, q: string) => !q || `${i.name} ${i.description ?? ''} ${i.tags.join(' ')}`.toLowerCase().includes(q) | |
| 23 | 28 | |
| 24 | -const available = computed(() => props.items.filter(i => i.type === tab.value).sort((a, b) => a.name.localeCompare(b.name))) | |
| 25 | -const filtered = computed(() => { | |
| 29 | +const groups = computed(() => { | |
| 26 | 30 | const q = search.value.trim().toLowerCase() |
| 27 | - return q ? available.value.filter(i => `${i.name} ${i.description ?? ''} ${i.tags.join(' ')}`.toLowerCase().includes(q)) : available.value | |
| 31 | + return props.types.map((t) => { | |
| 32 | + const all = props.items.filter(i => i.type === t && matches(i, q)).sort((a, b) => a.name.localeCompare(b.name)) | |
| 33 | + return { type: t, total: all.length, rows: expanded.value[t] ? all : all.slice(0, LIMIT) } | |
| 34 | + }) | |
| 28 | 35 | }) |
| 29 | -const shown = computed(() => (showAll.value ? filtered.value : filtered.value.slice(0, LIMIT))) | |
| 36 | +const empty = computed(() => groups.value.every(g => !g.total)) | |
| 30 | 37 | |
| 31 | -const tabs = computed(() => props.types.map(t => ({ | |
| 32 | - label: CAT_META[t].label, | |
| 33 | - icon: CAT_META[t].icon, | |
| 34 | - value: t, | |
| 35 | - badge: { label: String(selected.value[t]?.length ?? 0), color: (selected.value[t]?.length ? 'primary' : 'neutral') as 'primary' | 'neutral', variant: 'subtle' as const } | |
| 36 | -}))) | |
| 38 | +const lockedLabel = (i: CatalogItem) => props.locked[itemKey(i.type, i.name)] | |
| 39 | +const isOn = (i: CatalogItem) => !!lockedLabel(i) || !!selected.value[i.type]?.includes(i.name) | |
| 40 | +const count = (t: CatalogItemType) => selected.value[t]?.length ?? 0 | |
| 37 | 41 | |
| 38 | -const isOn = (name: string) => !!selected.value[tab.value]?.includes(name) | |
| 39 | - | |
| 40 | -function toggle(name: string, on: boolean | 'indeterminate') { | |
| 41 | - const cur = new Set(selected.value[tab.value] ?? []) | |
| 42 | - if (on === true) cur.add(name) | |
| 43 | - else cur.delete(name) | |
| 44 | - selected.value = { ...selected.value, [tab.value]: [...cur] } | |
| 42 | +function toggle(i: CatalogItem, on?: boolean | 'indeterminate') { | |
| 43 | + if (lockedLabel(i)) return | |
| 44 | + const cur = new Set(selected.value[i.type] ?? []) | |
| 45 | + const next = on === undefined ? !cur.has(i.name) : on === true | |
| 46 | + if (next) cur.add(i.name) | |
| 47 | + else cur.delete(i.name) | |
| 48 | + selected.value = { ...selected.value, [i.type]: [...cur] } | |
| 45 | 49 | } |
| 46 | 50 | </script> |
| 47 | 51 | |
| 48 | 52 | <template> |
| 49 | - <div class="space-y-3"> | |
| 50 | - <UTabs v-model="tab" :items="tabs" :content="false" variant="link" size="sm" class="w-full" /> | |
| 51 | - <UInput v-model="search" icon="i-lucide-search" size="sm" placeholder="Search..." class="w-full" /> | |
| 52 | - <div class="overflow-y-auto rounded-lg border border-default" :class="height"> | |
| 53 | - <ul v-if="shown.length" class="divide-y divide-default"> | |
| 54 | - <li v-for="i in shown" :key="i.name" class="flex items-start gap-2 px-3 py-2 hover:bg-elevated/50"> | |
| 55 | - <UCheckbox | |
| 56 | - class="mt-0.5" | |
| 57 | - :model-value="!!locked[itemKey(i.type, i.name)] || isOn(i.name)" | |
| 58 | - :disabled="!!locked[itemKey(i.type, i.name)]" | |
| 59 | - :aria-label="i.name" | |
| 60 | - @update:model-value="(v) => toggle(i.name, v)" | |
| 61 | - /> | |
| 62 | - <div class="min-w-0 flex-1"> | |
| 63 | - <div class="flex items-center gap-2"> | |
| 64 | - <span class="truncate text-sm font-medium">{{ i.name }}</span> | |
| 65 | - <UBadge v-if="locked[itemKey(i.type, i.name)]" :label="locked[itemKey(i.type, i.name)]" size="sm" color="neutral" variant="subtle" /> | |
| 66 | - <UBadge v-for="t in i.tags.slice(0, 3)" :key="t" :label="t" size="sm" color="neutral" variant="outline" /> | |
| 67 | - </div> | |
| 68 | - <p v-if="i.description" class="line-clamp-1 text-xs text-muted" :title="i.description">{{ i.description }}</p> | |
| 53 | + <div class="flex min-w-0 flex-col" :class="title && 'overflow-hidden rounded-[20px] bg-sk-panel shadow-sk-ring'"> | |
| 54 | + <div class="flex items-center justify-between gap-3" :class="title ? 'px-5 py-3.5' : 'pb-2.5'"> | |
| 55 | + <span v-if="title" class="font-semibold">{{ title }}</span> | |
| 56 | + <UInput | |
| 57 | + v-model="search" | |
| 58 | + :placeholder="title ? 'Filter items' : 'Search items'" | |
| 59 | + aria-label="Filter items" | |
| 60 | + :size="title ? 'sm' : 'md'" | |
| 61 | + :class="title ? 'w-[220px] max-w-1/2' : 'w-full'" | |
| 62 | + /> | |
| 63 | + </div> | |
| 64 | + | |
| 65 | + <div | |
| 66 | + :class="title ? '' : ['overflow-y-auto rounded-[11px] border border-sk-line2 bg-sk-panel', height]" | |
| 67 | + > | |
| 68 | + <template v-for="g in groups" :key="g.type"> | |
| 69 | + <template v-if="g.total"> | |
| 70 | + <SkSectionLabel | |
| 71 | + class="border-t border-sk-line2 bg-sk-panel first:border-t-0" | |
| 72 | + :class="title ? 'px-5 pt-2.5 pb-1.5' : 'px-3.5 pt-2.5 pb-1.5'" | |
| 73 | + :count="count(g.type) ? `${count(g.type)}/${g.total}` : g.total" | |
| 74 | + >{{ CAT_META[g.type].label }}</SkSectionLabel> | |
| 75 | + <div | |
| 76 | + v-for="i in g.rows" | |
| 77 | + :key="i.name" | |
| 78 | + class="flex items-center gap-3 border-t border-sk-line2 py-2 hover:bg-sk-hover" | |
| 79 | + :class="[title ? 'px-5' : 'px-3.5', lockedLabel(i) ? 'cursor-not-allowed' : 'cursor-pointer']" | |
| 80 | + @click="toggle(i)" | |
| 81 | + > | |
| 82 | + <span :class="lockedLabel(i) && 'opacity-45'" @click.stop> | |
| 83 | + <UCheckbox | |
| 84 | + :model-value="isOn(i)" | |
| 85 | + :disabled="!!lockedLabel(i)" | |
| 86 | + :aria-label="i.name" | |
| 87 | + @update:model-value="(v) => toggle(i, v)" | |
| 88 | + /> | |
| 89 | + </span> | |
| 90 | + <span class="flex min-w-0 flex-1 flex-col"> | |
| 91 | + <span class="truncate font-medium">{{ i.name }}</span> | |
| 92 | + <span v-if="i.description" class="truncate text-xs text-sk-muted" :title="i.description">{{ i.description }}</span> | |
| 93 | + </span> | |
| 94 | + <span v-if="lockedLabel(i)" class="text-xs whitespace-nowrap text-sk-faint">{{ lockedLabel(i) }}</span> | |
| 95 | + </div> | |
| 96 | + <div v-if="g.total > g.rows.length" class="border-t border-sk-line2 p-2 text-center"> | |
| 97 | + <SkButton size="xs" variant="ghost" :label="`Show all ${g.total}`" @click="expanded = { ...expanded, [g.type]: true }" /> | |
| 69 | 98 | </div> |
| 70 | - </li> | |
| 71 | - <li v-if="filtered.length > shown.length" class="p-2 text-center"> | |
| 72 | - <UButton :label="`Show all ${filtered.length}`" size="xs" color="neutral" variant="link" @click="showAll = true" /> | |
| 73 | - </li> | |
| 74 | - </ul> | |
| 75 | - <p v-else class="p-6 text-center text-sm text-muted"> | |
| 76 | - {{ search ? 'No result.' : `No ${CAT_META[tab].label.toLowerCase()} in the catalog.` }} | |
| 99 | + </template> | |
| 100 | + </template> | |
| 101 | + <p v-if="empty" class="m-0 px-6 py-8 text-center text-sk-faint"> | |
| 102 | + {{ search ? 'Nothing matches.' : 'The catalog has no items of these types yet.' }} | |
| 77 | 103 | </p> |
| 78 | 104 | </div> |
| 79 | 105 | </div> |
| @@ -2,78 +2,104 @@ | |||
| 2 | import type { CatalogItem, CatalogItemType } from '@skilly/core' | 2 | import type { CatalogItem, CatalogItemType } from '@skilly/core' |
| 3 | 3 | ||
| 4 | /** | 4 | /** |
| 5 | - * Checkbox picker over the catalog, per type with search. | 5 | + * Checkbox picker over the catalog, grouped by type, with a filter. |
| 6 | - * `locked` maps `type/name` to a label: those rows are shown checked and disabled (already provided elsewhere). | 6 | + * `locked` maps `type/name` to a label ("via Base"): those rows are shown checked at reduced opacity and cannot be toggled |
| 7 | + * (already provided elsewhere). | ||
| 8 | + * `height` is a Tailwind height class for the scrollable list (default `h-72`). | ||
| 9 | + * `title`: when set, the picker renders as a full panel (title + filter in the header, rows edge to edge, no inner scroll), | ||
| 10 | + * as in the preset editor. Without it the picker is a bordered, scrollable list (modals, drawers). | ||
| 7 | */ | 11 | */ |
| 8 | const props = withDefaults(defineProps<{ | 12 | const props = withDefaults(defineProps<{ |
| 9 | items: CatalogItem[] | 13 | items: CatalogItem[] |
| 10 | types?: CatalogItemType[] | 14 | types?: CatalogItemType[] |
| 11 | locked?: Record<string, string> | 15 | locked?: Record<string, string> |
| 12 | height?: string | 16 | height?: string |
| 13 | -}>(), { types: () => CAT_TYPES, locked: () => ({}), height: 'h-72' }) | 17 | + title?: string |
| 18 | +}>(), { types: () => CAT_ORDER, locked: () => ({}), height: 'h-72', title: undefined }) | ||
| 14 | 19 | ||
| 15 | const selected = defineModel<Partial<Record<CatalogItemType, string[]>>>({ required: true }) | 20 | const selected = defineModel<Partial<Record<CatalogItemType, string[]>>>({ required: true }) |
| 16 | 21 | ||
| 17 | -const tab = ref<CatalogItemType>(props.types[0] ?? 'skill') | ||
| 18 | const search = ref('') | 22 | const search = ref('') |
| 19 | const LIMIT = 200 | 23 | const LIMIT = 200 |
| 20 | -const showAll = ref(false) | 24 | +const expanded = ref<Partial<Record<CatalogItemType, boolean>>>({}) |
| 25 | +watch(search, () => { expanded.value = {} }) | ||
| 21 | 26 | ||
| 22 | -watch([tab, search], () => { showAll.value = false }) | 27 | +const matches = (i: CatalogItem, q: string) => !q || `${i.name} ${i.description ?? ''} ${i.tags.join(' ')}`.toLowerCase().includes(q) |
| 23 | 28 | ||
| 24 | -const available = computed(() => props.items.filter(i => i.type === tab.value).sort((a, b) => a.name.localeCompare(b.name))) | 29 | +const groups = computed(() => { |
| 25 | -const filtered = computed(() => { | ||
| 26 | const q = search.value.trim().toLowerCase() | 30 | const q = search.value.trim().toLowerCase() |
| 27 | - return q ? available.value.filter(i => `${i.name} ${i.description ?? ''} ${i.tags.join(' ')}`.toLowerCase().includes(q)) : available.value | 31 | + return props.types.map((t) => { |
| 32 | + const all = props.items.filter(i => i.type === t && matches(i, q)).sort((a, b) => a.name.localeCompare(b.name)) | ||
| 33 | + return { type: t, total: all.length, rows: expanded.value[t] ? all : all.slice(0, LIMIT) } | ||
| 34 | + }) | ||
| 28 | }) | 35 | }) |
| 29 | -const shown = computed(() => (showAll.value ? filtered.value : filtered.value.slice(0, LIMIT))) | 36 | +const empty = computed(() => groups.value.every(g => !g.total)) |
| 30 | 37 | ||
| 31 | -const tabs = computed(() => props.types.map(t => ({ | 38 | +const lockedLabel = (i: CatalogItem) => props.locked[itemKey(i.type, i.name)] |
| 32 | - label: CAT_META[t].label, | 39 | +const isOn = (i: CatalogItem) => !!lockedLabel(i) || !!selected.value[i.type]?.includes(i.name) |
| 33 | - icon: CAT_META[t].icon, | 40 | +const count = (t: CatalogItemType) => selected.value[t]?.length ?? 0 |
| 34 | - value: t, | ||
| 35 | - badge: { label: String(selected.value[t]?.length ?? 0), color: (selected.value[t]?.length ? 'primary' : 'neutral') as 'primary' | 'neutral', variant: 'subtle' as const } | ||
| 36 | -}))) | ||
| 37 | 41 | ||
| 38 | -const isOn = (name: string) => !!selected.value[tab.value]?.includes(name) | 42 | +function toggle(i: CatalogItem, on?: boolean | 'indeterminate') { |
| 39 | - | 43 | + if (lockedLabel(i)) return |
| 40 | -function toggle(name: string, on: boolean | 'indeterminate') { | 44 | + const cur = new Set(selected.value[i.type] ?? []) |
| 41 | - const cur = new Set(selected.value[tab.value] ?? []) | 45 | + const next = on === undefined ? !cur.has(i.name) : on === true |
| 42 | - if (on === true) cur.add(name) | 46 | + if (next) cur.add(i.name) |
| 43 | - else cur.delete(name) | 47 | + else cur.delete(i.name) |
| 44 | - selected.value = { ...selected.value, [tab.value]: [...cur] } | 48 | + selected.value = { ...selected.value, [i.type]: [...cur] } |
| 45 | } | 49 | } |
| 46 | </script> | 50 | </script> |
| 47 | 51 | ||
| 48 | <template> | 52 | <template> |
| 49 | - <div class="space-y-3"> | 53 | + <div class="flex min-w-0 flex-col" :class="title && 'overflow-hidden rounded-[20px] bg-sk-panel shadow-sk-ring'"> |
| 50 | - <UTabs v-model="tab" :items="tabs" :content="false" variant="link" size="sm" class="w-full" /> | 54 | + <div class="flex items-center justify-between gap-3" :class="title ? 'px-5 py-3.5' : 'pb-2.5'"> |
| 51 | - <UInput v-model="search" icon="i-lucide-search" size="sm" placeholder="Search..." class="w-full" /> | 55 | + <span v-if="title" class="font-semibold">{{ title }}</span> |
| 52 | - <div class="overflow-y-auto rounded-lg border border-default" :class="height"> | 56 | + <UInput |
| 53 | - <ul v-if="shown.length" class="divide-y divide-default"> | 57 | + v-model="search" |
| 54 | - <li v-for="i in shown" :key="i.name" class="flex items-start gap-2 px-3 py-2 hover:bg-elevated/50"> | 58 | + :placeholder="title ? 'Filter items' : 'Search items'" |
| 55 | - <UCheckbox | 59 | + aria-label="Filter items" |
| 56 | - class="mt-0.5" | 60 | + :size="title ? 'sm' : 'md'" |
| 57 | - :model-value="!!locked[itemKey(i.type, i.name)] || isOn(i.name)" | 61 | + :class="title ? 'w-[220px] max-w-1/2' : 'w-full'" |
| 58 | - :disabled="!!locked[itemKey(i.type, i.name)]" | 62 | + /> |
| 59 | - :aria-label="i.name" | 63 | + </div> |
| 60 | - @update:model-value="(v) => toggle(i.name, v)" | 64 | + |
| 61 | - /> | 65 | + <div |
| 62 | - <div class="min-w-0 flex-1"> | 66 | + :class="title ? '' : ['overflow-y-auto rounded-[11px] border border-sk-line2 bg-sk-panel', height]" |
| 63 | - <div class="flex items-center gap-2"> | 67 | + > |
| 64 | - <span class="truncate text-sm font-medium">{{ i.name }}</span> | 68 | + <template v-for="g in groups" :key="g.type"> |
| 65 | - <UBadge v-if="locked[itemKey(i.type, i.name)]" :label="locked[itemKey(i.type, i.name)]" size="sm" color="neutral" variant="subtle" /> | 69 | + <template v-if="g.total"> |
| 66 | - <UBadge v-for="t in i.tags.slice(0, 3)" :key="t" :label="t" size="sm" color="neutral" variant="outline" /> | 70 | + <SkSectionLabel |
| 67 | - </div> | 71 | + class="border-t border-sk-line2 bg-sk-panel first:border-t-0" |
| 68 | - <p v-if="i.description" class="line-clamp-1 text-xs text-muted" :title="i.description">{{ i.description }}</p> | 72 | + :class="title ? 'px-5 pt-2.5 pb-1.5' : 'px-3.5 pt-2.5 pb-1.5'" |
| 73 | + :count="count(g.type) ? `${count(g.type)}/${g.total}` : g.total" | ||
| 74 | + >{{ CAT_META[g.type].label }}</SkSectionLabel> | ||
| 75 | + <div | ||
| 76 | + v-for="i in g.rows" | ||
| 77 | + :key="i.name" | ||
| 78 | + class="flex items-center gap-3 border-t border-sk-line2 py-2 hover:bg-sk-hover" | ||
| 79 | + :class="[title ? 'px-5' : 'px-3.5', lockedLabel(i) ? 'cursor-not-allowed' : 'cursor-pointer']" | ||
| 80 | + @click="toggle(i)" | ||
| 81 | + > | ||
| 82 | + <span :class="lockedLabel(i) && 'opacity-45'" @click.stop> | ||
| 83 | + <UCheckbox | ||
| 84 | + :model-value="isOn(i)" | ||
| 85 | + :disabled="!!lockedLabel(i)" | ||
| 86 | + :aria-label="i.name" | ||
| 87 | + @update:model-value="(v) => toggle(i, v)" | ||
| 88 | + /> | ||
| 89 | + </span> | ||
| 90 | + <span class="flex min-w-0 flex-1 flex-col"> | ||
| 91 | + <span class="truncate font-medium">{{ i.name }}</span> | ||
| 92 | + <span v-if="i.description" class="truncate text-xs text-sk-muted" :title="i.description">{{ i.description }}</span> | ||
| 93 | + </span> | ||
| 94 | + <span v-if="lockedLabel(i)" class="text-xs whitespace-nowrap text-sk-faint">{{ lockedLabel(i) }}</span> | ||
| 95 | + </div> | ||
| 96 | + <div v-if="g.total > g.rows.length" class="border-t border-sk-line2 p-2 text-center"> | ||
| 97 | + <SkButton size="xs" variant="ghost" :label="`Show all ${g.total}`" @click="expanded = { ...expanded, [g.type]: true }" /> | ||
| 69 | </div> | 98 | </div> |
| 70 | - </li> | 99 | + </template> |
| 71 | - <li v-if="filtered.length > shown.length" class="p-2 text-center"> | 100 | + </template> |
| 72 | - <UButton :label="`Show all ${filtered.length}`" size="xs" color="neutral" variant="link" @click="showAll = true" /> | 101 | + <p v-if="empty" class="m-0 px-6 py-8 text-center text-sk-faint"> |
| 73 | - </li> | 102 | + {{ search ? 'Nothing matches.' : 'The catalog has no items of these types yet.' }} |
| 74 | - </ul> | ||
| 75 | - <p v-else class="p-6 text-center text-sm text-muted"> | ||
| 76 | - {{ search ? 'No result.' : `No ${CAT_META[tab].label.toLowerCase()} in the catalog.` }} | ||
| 77 | </p> | 103 | </p> |
| 78 | </div> | 104 | </div> |
| 79 | </div> | 105 | </div> |
deleted
packages/ui/app/components/ItemTable.vue +0 -239 | deleted file mode 100644 | ||
| @@ -1,239 +0,0 @@ | ||
| 1 | -<script setup lang="ts"> | |
| 2 | -import type { | |
| 3 | - AgentDetails, CatalogRef, HookDetails, InstructionsDetails, InventoryItem, ItemStatus, ItemType, McpDetails, RuleDetails, SkillDetails | |
| 4 | -} from '@skilly/core' | |
| 5 | - | |
| 6 | -const props = defineProps<{ | |
| 7 | - type: ItemType | |
| 8 | - items: InventoryItem[] | |
| 9 | - filtered: boolean | |
| 10 | - statuses?: Record<string, ItemStatus> | |
| 11 | - /** Show checkboxes and row actions (catalog import, remove from global). */ | |
| 12 | - actionable?: boolean | |
| 13 | - /** Home scope: enables "Remove from global" on skills, rules and agents. */ | |
| 14 | - home?: boolean | |
| 15 | -}>() | |
| 16 | -const selection = defineModel<string[]>('selection', { default: () => [] }) | |
| 17 | -const emit = defineEmits<{ | |
| 18 | - select: [item: InventoryItem] | |
| 19 | - addToCatalog: [item: InventoryItem] | |
| 20 | - openCatalog: [ref: CatalogRef] | |
| 21 | - removeGlobal: [item: InventoryItem] | |
| 22 | -}>() | |
| 23 | - | |
| 24 | -const skill = (i: InventoryItem) => i.details as SkillDetails | |
| 25 | -const rule = (i: InventoryItem) => i.details as RuleDetails | |
| 26 | -const mcp = (i: InventoryItem) => i.details as McpDetails | |
| 27 | -const hook = (i: InventoryItem) => i.details as HookDetails | |
| 28 | -const agent = (i: InventoryItem) => i.details as AgentDetails | |
| 29 | -const instr = (i: InventoryItem) => i.details as InstructionsDetails | |
| 30 | - | |
| 31 | -const VISIBILITY = { | |
| 32 | - 'auto': { label: 'auto', color: 'success' }, | |
| 33 | - 'on-demand': { label: 'on demand', color: 'info' }, | |
| 34 | - 'name-only': { label: 'name only', color: 'warning' }, | |
| 35 | - 'off': { label: 'off', color: 'neutral' } | |
| 36 | -} as const | |
| 37 | - | |
| 38 | -const canImport = (i: InventoryItem) => { | |
| 39 | - const st = props.statuses?.[i.id]?.state | |
| 40 | - return !!props.actionable && i.type !== 'instructions' && (st === 'not-in-catalog' || st === 'differs-from-catalog') | |
| 41 | -} | |
| 42 | -const canRemoveGlobal = (i: InventoryItem) => !!props.actionable && !!props.home && ['skill', 'rule', 'agent'].includes(i.type) | |
| 43 | -const selectable = (i: InventoryItem) => canImport(i) || canRemoveGlobal(i) | |
| 44 | - | |
| 45 | -function toggleRow(id: string, on: boolean | 'indeterminate') { | |
| 46 | - selection.value = on === true ? [...new Set([...selection.value, id])] : selection.value.filter(x => x !== id) | |
| 47 | -} | |
| 48 | -const selectableIds = computed(() => props.items.filter(selectable).map(i => i.id)) | |
| 49 | -const allState = computed(() => { | |
| 50 | - const n = selectableIds.value.filter(id => selection.value.includes(id)).length | |
| 51 | - return n === 0 ? false : n === selectableIds.value.length ? true : 'indeterminate' as const | |
| 52 | -}) | |
| 53 | -function toggleAll(on: boolean | 'indeterminate') { | |
| 54 | - const rest = selection.value.filter(id => !selectableIds.value.includes(id)) | |
| 55 | - selection.value = on === true ? [...rest, ...selectableIds.value] : rest | |
| 56 | -} | |
| 57 | - | |
| 58 | -const TH = 'px-3 py-2 text-left text-xs font-medium uppercase tracking-wide text-muted' | |
| 59 | -const TD = 'px-3 py-2 align-middle' | |
| 60 | -const MAX_ENV = 4 | |
| 61 | -</script> | |
| 62 | - | |
| 63 | -<template> | |
| 64 | - <div | |
| 65 | - v-if="!items.length" | |
| 66 | - class="flex flex-col items-center gap-2 rounded-xl border border-dashed border-default py-12 text-center" | |
| 67 | - > | |
| 68 | - <UIcon :name="filtered ? 'i-lucide-search-x' : TYPE_META[type].icon" class="size-8 text-dimmed" /> | |
| 69 | - <p class="text-sm text-muted"> | |
| 70 | - {{ filtered ? 'No result matches the current filters.' : `No ${TYPE_META[type].label.toLowerCase()} found in this scope.` }} | |
| 71 | - </p> | |
| 72 | - </div> | |
| 73 | - | |
| 74 | - <!-- table-fixed with the same widths for the leading columns keeps columns aligned from one tab to the next --> | |
| 75 | - <div v-else class="overflow-x-auto rounded-xl border border-default"> | |
| 76 | - <table class="w-full min-w-[960px] table-fixed text-sm"> | |
| 77 | - <thead class="border-b border-default bg-elevated/40"> | |
| 78 | - <tr> | |
| 79 | - <th v-if="actionable" :class="TH" class="w-10"> | |
| 80 | - <UCheckbox v-if="selectableIds.length" :model-value="allState" aria-label="Select all" @update:model-value="toggleAll" /> | |
| 81 | - </th> | |
| 82 | - <th :class="TH" class="w-[26%]">{{ type === 'hook' ? 'Event' : type === 'instructions' ? 'File' : 'Name' }}</th> | |
| 83 | - <th :class="TH" class="w-16">Agent</th> | |
| 84 | - <th :class="TH" class="w-44">Status</th> | |
| 85 | - <template v-if="type === 'skill'"> | |
| 86 | - <th :class="TH" class="w-28">Visibility</th><th :class="TH">Origin</th> | |
| 87 | - </template> | |
| 88 | - <template v-else-if="type === 'rule'"> | |
| 89 | - <th :class="TH">Applies to</th> | |
| 90 | - </template> | |
| 91 | - <template v-else-if="type === 'mcp'"> | |
| 92 | - <th :class="TH" class="w-24">Transport</th><th :class="TH">Command / URL</th><th :class="TH">Env vars</th><th :class="TH" class="w-20">Scope</th><th :class="TH" class="w-24">State</th> | |
| 93 | - </template> | |
| 94 | - <template v-else-if="type === 'hook'"> | |
| 95 | - <th :class="TH" class="w-28">Matcher</th><th :class="TH">Command</th><th :class="TH" class="w-24">Handler</th> | |
| 96 | - </template> | |
| 97 | - <template v-else-if="type === 'agent'"> | |
| 98 | - <th :class="TH">Model</th> | |
| 99 | - </template> | |
| 100 | - <template v-else> | |
| 101 | - <th :class="TH" class="w-20">Size</th><th :class="TH" class="w-28">Loaded</th><th :class="TH">Imports</th> | |
| 102 | - </template> | |
| 103 | - <th v-if="actionable" :class="TH" class="w-28"><span class="sr-only">Actions</span></th> | |
| 104 | - </tr> | |
| 105 | - </thead> | |
| 106 | - | |
| 107 | - <tbody class="divide-y divide-default"> | |
| 108 | - <tr | |
| 109 | - v-for="item in items" | |
| 110 | - :key="item.id" | |
| 111 | - class="cursor-pointer transition-colors hover:bg-elevated/50" | |
| 112 | - tabindex="0" | |
| 113 | - @click="$emit('select', item)" | |
| 114 | - @keydown.enter="$emit('select', item)" | |
| 115 | - > | |
| 116 | - <td v-if="actionable" :class="TD" @click.stop> | |
| 117 | - <UCheckbox v-if="selectable(item)" :model-value="selection.includes(item.id)" :aria-label="`Select ${item.name}`" @update:model-value="(v) => toggleRow(item.id, v)" /> | |
| 118 | - </td> | |
| 119 | - | |
| 120 | - <!-- Hooks lead with the event, others with the name --> | |
| 121 | - <td :class="TD"> | |
| 122 | - <div class="min-w-0"> | |
| 123 | - <div class="flex items-center gap-1.5"> | |
| 124 | - <span class="truncate font-medium text-highlighted" :title="type === 'hook' ? hook(item).event : item.name">{{ type === 'hook' ? hook(item).event : item.name }}</span> | |
| 125 | - <UTooltip v-if="item.warnings.length" :text="item.warnings.join('\n')"> | |
| 126 | - <UIcon name="i-lucide-triangle-alert" class="size-4 shrink-0 text-warning" /> | |
| 127 | - </UTooltip> | |
| 128 | - </div> | |
| 129 | - <p v-if="item.description && type !== 'hook'" class="line-clamp-1 text-xs text-muted" :title="item.description">{{ item.description }}</p> | |
| 130 | - </div> | |
| 131 | - </td> | |
| 132 | - <td :class="TD"><AgentBadge :agent="item.agent" compact /></td> | |
| 133 | - <td :class="TD"> | |
| 134 | - <!-- Project instructions are never shared, so a catalog status would be meaningless. --> | |
| 135 | - <span v-if="item.type === 'instructions'" class="text-xs text-dimmed">-</span> | |
| 136 | - <StatusBadge v-else :status="statuses?.[item.id]" /> | |
| 137 | - </td> | |
| 138 | - | |
| 139 | - <!-- skills --> | |
| 140 | - <template v-if="type === 'skill'"> | |
| 141 | - <td :class="TD"> | |
| 142 | - <UTooltip :text="skill(item).visibilitySource ?? 'default'"> | |
| 143 | - <UBadge :label="VISIBILITY[skill(item).visibility].label" :color="VISIBILITY[skill(item).visibility].color" variant="subtle" size="sm" /> | |
| 144 | - </UTooltip> | |
| 145 | - </td> | |
| 146 | - <td :class="TD"> | |
| 147 | - <div class="flex flex-wrap gap-1"> | |
| 148 | - <UBadge v-if="item.source.plugin" :label="`plugin: ${item.source.plugin}`" icon="i-lucide-puzzle" color="primary" variant="subtle" size="sm" /> | |
| 149 | - <UTooltip v-if="item.source.symlinkTarget" :text="item.source.symlinkTarget"> | |
| 150 | - <UBadge label="symlink" icon="i-lucide-link" color="neutral" variant="subtle" size="sm" /> | |
| 151 | - </UTooltip> | |
| 152 | - <!-- rtl keeps the end of the path (the meaningful part) visible when truncated --> | |
| 153 | - <span v-if="item.source.symlinkTarget" dir="rtl" class="max-w-56 truncate text-left font-mono text-xs text-muted" :title="item.source.symlinkTarget"> | |
| 154 | - <bdi>{{ item.source.symlinkTarget }}</bdi> | |
| 155 | - </span> | |
| 156 | - <span v-if="!item.source.plugin && !item.source.symlinkTarget" class="text-xs text-dimmed">local</span> | |
| 157 | - </div> | |
| 158 | - </td> | |
| 159 | - </template> | |
| 160 | - | |
| 161 | - <!-- rules --> | |
| 162 | - <template v-else-if="type === 'rule'"> | |
| 163 | - <td :class="TD"> | |
| 164 | - <span v-if="!rule(item).paths.length" class="text-xs text-muted">always</span> | |
| 165 | - <div v-else class="flex flex-wrap gap-1"> | |
| 166 | - <UBadge v-for="p in rule(item).paths" :key="p" :label="p" color="neutral" variant="outline" size="sm" class="font-mono" /> | |
| 167 | - </div> | |
| 168 | - </td> | |
| 169 | - </template> | |
| 170 | - | |
| 171 | - <!-- mcp --> | |
| 172 | - <template v-else-if="type === 'mcp'"> | |
| 173 | - <td :class="TD"><UBadge :label="mcp(item).transport" color="neutral" variant="subtle" size="sm" /></td> | |
| 174 | - <td :class="TD"> | |
| 175 | - <span class="block truncate font-mono text-xs" :title="mcp(item).url ?? [mcp(item).command, ...(mcp(item).args ?? [])].join(' ')"> | |
| 176 | - {{ mcp(item).url ?? [mcp(item).command, ...(mcp(item).args ?? [])].filter(Boolean).join(' ') }} | |
| 177 | - </span> | |
| 178 | - </td> | |
| 179 | - <td :class="TD"> | |
| 180 | - <div class="flex flex-wrap gap-1"> | |
| 181 | - <UBadge v-for="v in mcp(item).envVarNames.slice(0, MAX_ENV)" :key="v" :label="v" color="neutral" variant="outline" size="sm" class="font-mono" /> | |
| 182 | - <UBadge v-if="mcp(item).envVarNames.length > MAX_ENV" :label="`+${mcp(item).envVarNames.length - MAX_ENV}`" color="neutral" variant="subtle" size="sm" /> | |
| 183 | - </div> | |
| 184 | - </td> | |
| 185 | - <td :class="TD"><span class="text-xs text-muted">{{ mcp(item).agentScope ?? '-' }}</span></td> | |
| 186 | - <td :class="TD"> | |
| 187 | - <UBadge :label="mcp(item).enabled ? 'enabled' : 'disabled'" :color="mcp(item).enabled ? 'success' : 'neutral'" variant="subtle" size="sm" /> | |
| 188 | - </td> | |
| 189 | - </template> | |
| 190 | - | |
| 191 | - <!-- hooks --> | |
| 192 | - <template v-else-if="type === 'hook'"> | |
| 193 | - <td :class="TD"><span class="block truncate font-mono text-xs">{{ hook(item).matcher ?? '*' }}</span></td> | |
| 194 | - <td :class="TD"> | |
| 195 | - <span class="block truncate font-mono text-xs" :title="hook(item).command">{{ hook(item).command ?? '-' }}</span> | |
| 196 | - </td> | |
| 197 | - <td :class="TD"><UBadge :label="hook(item).handlerType" color="neutral" variant="subtle" size="sm" /></td> | |
| 198 | - </template> | |
| 199 | - | |
| 200 | - <!-- agents --> | |
| 201 | - <template v-else-if="type === 'agent'"> | |
| 202 | - <td :class="TD"><span class="text-xs">{{ agent(item).model ?? 'inherit' }}</span></td> | |
| 203 | - </template> | |
| 204 | - | |
| 205 | - <!-- instructions --> | |
| 206 | - <template v-else> | |
| 207 | - <td :class="TD"><span class="text-xs tabular-nums text-muted">{{ formatBytes(instr(item).sizeBytes) }}</span></td> | |
| 208 | - <td :class="TD"> | |
| 209 | - <UTooltip v-if="!instr(item).loaded && instr(item).notLoadedReason" :text="instr(item).notLoadedReason!"> | |
| 210 | - <UBadge label="not loaded" color="warning" variant="subtle" size="sm" icon="i-lucide-circle-help" /> | |
| 211 | - </UTooltip> | |
| 212 | - <UBadge v-else :label="instr(item).loaded ? 'loaded' : 'not loaded'" :color="instr(item).loaded ? 'success' : 'warning'" variant="subtle" size="sm" /> | |
| 213 | - </td> | |
| 214 | - <td :class="TD"> | |
| 215 | - <div class="flex flex-wrap gap-1"> | |
| 216 | - <UBadge v-for="im in instr(item).imports" :key="im" :label="im" color="neutral" variant="outline" size="sm" class="font-mono" /> | |
| 217 | - <span v-if="!instr(item).imports.length" class="text-xs text-dimmed">-</span> | |
| 218 | - </div> | |
| 219 | - </td> | |
| 220 | - </template> | |
| 221 | - | |
| 222 | - <td v-if="actionable" :class="TD" @click.stop> | |
| 223 | - <div class="flex justify-end gap-0.5"> | |
| 224 | - <UTooltip v-if="canImport(item)" text="Add to catalog"> | |
| 225 | - <UButton icon="i-lucide-library" size="xs" color="neutral" variant="ghost" aria-label="Add to catalog" @click="emit('addToCatalog', item)" /> | |
| 226 | - </UTooltip> | |
| 227 | - <UTooltip v-if="statuses?.[item.id]?.catalogRef" text="Open in catalog"> | |
| 228 | - <UButton icon="i-lucide-external-link" size="xs" color="neutral" variant="ghost" aria-label="Open in catalog" @click="emit('openCatalog', statuses![item.id]!.catalogRef!)" /> | |
| 229 | - </UTooltip> | |
| 230 | - <UTooltip v-if="canRemoveGlobal(item)" text="Remove from global"> | |
| 231 | - <UButton icon="i-lucide-globe-lock" size="xs" color="neutral" variant="ghost" aria-label="Remove from global" @click="emit('removeGlobal', item)" /> | |
| 232 | - </UTooltip> | |
| 233 | - </div> | |
| 234 | - </td> | |
| 235 | - </tr> | |
| 236 | - </tbody> | |
| 237 | - </table> | |
| 238 | - </div> | |
| 239 | -</template> | |
| deleted file mode 100644 | |||
| @@ -1,239 +0,0 @@ | |||
| 1 | -<script setup lang="ts"> | ||
| 2 | -import type { | ||
| 3 | - AgentDetails, CatalogRef, HookDetails, InstructionsDetails, InventoryItem, ItemStatus, ItemType, McpDetails, RuleDetails, SkillDetails | ||
| 4 | -} from '@skilly/core' | ||
| 5 | - | ||
| 6 | -const props = defineProps<{ | ||
| 7 | - type: ItemType | ||
| 8 | - items: InventoryItem[] | ||
| 9 | - filtered: boolean | ||
| 10 | - statuses?: Record<string, ItemStatus> | ||
| 11 | - /** Show checkboxes and row actions (catalog import, remove from global). */ | ||
| 12 | - actionable?: boolean | ||
| 13 | - /** Home scope: enables "Remove from global" on skills, rules and agents. */ | ||
| 14 | - home?: boolean | ||
| 15 | -}>() | ||
| 16 | -const selection = defineModel<string[]>('selection', { default: () => [] }) | ||
| 17 | -const emit = defineEmits<{ | ||
| 18 | - select: [item: InventoryItem] | ||
| 19 | - addToCatalog: [item: InventoryItem] | ||
| 20 | - openCatalog: [ref: CatalogRef] | ||
| 21 | - removeGlobal: [item: InventoryItem] | ||
| 22 | -}>() | ||
| 23 | - | ||
| 24 | -const skill = (i: InventoryItem) => i.details as SkillDetails | ||
| 25 | -const rule = (i: InventoryItem) => i.details as RuleDetails | ||
| 26 | -const mcp = (i: InventoryItem) => i.details as McpDetails | ||
| 27 | -const hook = (i: InventoryItem) => i.details as HookDetails | ||
| 28 | -const agent = (i: InventoryItem) => i.details as AgentDetails | ||
| 29 | -const instr = (i: InventoryItem) => i.details as InstructionsDetails | ||
| 30 | - | ||
| 31 | -const VISIBILITY = { | ||
| 32 | - 'auto': { label: 'auto', color: 'success' }, | ||
| 33 | - 'on-demand': { label: 'on demand', color: 'info' }, | ||
| 34 | - 'name-only': { label: 'name only', color: 'warning' }, | ||
| 35 | - 'off': { label: 'off', color: 'neutral' } | ||
| 36 | -} as const | ||
| 37 | - | ||
| 38 | -const canImport = (i: InventoryItem) => { | ||
| 39 | - const st = props.statuses?.[i.id]?.state | ||
| 40 | - return !!props.actionable && i.type !== 'instructions' && (st === 'not-in-catalog' || st === 'differs-from-catalog') | ||
| 41 | -} | ||
| 42 | -const canRemoveGlobal = (i: InventoryItem) => !!props.actionable && !!props.home && ['skill', 'rule', 'agent'].includes(i.type) | ||
| 43 | -const selectable = (i: InventoryItem) => canImport(i) || canRemoveGlobal(i) | ||
| 44 | - | ||
| 45 | -function toggleRow(id: string, on: boolean | 'indeterminate') { | ||
| 46 | - selection.value = on === true ? [...new Set([...selection.value, id])] : selection.value.filter(x => x !== id) | ||
| 47 | -} | ||
| 48 | -const selectableIds = computed(() => props.items.filter(selectable).map(i => i.id)) | ||
| 49 | -const allState = computed(() => { | ||
| 50 | - const n = selectableIds.value.filter(id => selection.value.includes(id)).length | ||
| 51 | - return n === 0 ? false : n === selectableIds.value.length ? true : 'indeterminate' as const | ||
| 52 | -}) | ||
| 53 | -function toggleAll(on: boolean | 'indeterminate') { | ||
| 54 | - const rest = selection.value.filter(id => !selectableIds.value.includes(id)) | ||
| 55 | - selection.value = on === true ? [...rest, ...selectableIds.value] : rest | ||
| 56 | -} | ||
| 57 | - | ||
| 58 | -const TH = 'px-3 py-2 text-left text-xs font-medium uppercase tracking-wide text-muted' | ||
| 59 | -const TD = 'px-3 py-2 align-middle' | ||
| 60 | -const MAX_ENV = 4 | ||
| 61 | -</script> | ||
| 62 | - | ||
| 63 | -<template> | ||
| 64 | - <div | ||
| 65 | - v-if="!items.length" | ||
| 66 | - class="flex flex-col items-center gap-2 rounded-xl border border-dashed border-default py-12 text-center" | ||
| 67 | - > | ||
| 68 | - <UIcon :name="filtered ? 'i-lucide-search-x' : TYPE_META[type].icon" class="size-8 text-dimmed" /> | ||
| 69 | - <p class="text-sm text-muted"> | ||
| 70 | - {{ filtered ? 'No result matches the current filters.' : `No ${TYPE_META[type].label.toLowerCase()} found in this scope.` }} | ||
| 71 | - </p> | ||
| 72 | - </div> | ||
| 73 | - | ||
| 74 | - <!-- table-fixed with the same widths for the leading columns keeps columns aligned from one tab to the next --> | ||
| 75 | - <div v-else class="overflow-x-auto rounded-xl border border-default"> | ||
| 76 | - <table class="w-full min-w-[960px] table-fixed text-sm"> | ||
| 77 | - <thead class="border-b border-default bg-elevated/40"> | ||
| 78 | - <tr> | ||
| 79 | - <th v-if="actionable" :class="TH" class="w-10"> | ||
| 80 | - <UCheckbox v-if="selectableIds.length" :model-value="allState" aria-label="Select all" @update:model-value="toggleAll" /> | ||
| 81 | - </th> | ||
| 82 | - <th :class="TH" class="w-[26%]">{{ type === 'hook' ? 'Event' : type === 'instructions' ? 'File' : 'Name' }}</th> | ||
| 83 | - <th :class="TH" class="w-16">Agent</th> | ||
| 84 | - <th :class="TH" class="w-44">Status</th> | ||
| 85 | - <template v-if="type === 'skill'"> | ||
| 86 | - <th :class="TH" class="w-28">Visibility</th><th :class="TH">Origin</th> | ||
| 87 | - </template> | ||
| 88 | - <template v-else-if="type === 'rule'"> | ||
| 89 | - <th :class="TH">Applies to</th> | ||
| 90 | - </template> | ||
| 91 | - <template v-else-if="type === 'mcp'"> | ||
| 92 | - <th :class="TH" class="w-24">Transport</th><th :class="TH">Command / URL</th><th :class="TH">Env vars</th><th :class="TH" class="w-20">Scope</th><th :class="TH" class="w-24">State</th> | ||
| 93 | - </template> | ||
| 94 | - <template v-else-if="type === 'hook'"> | ||
| 95 | - <th :class="TH" class="w-28">Matcher</th><th :class="TH">Command</th><th :class="TH" class="w-24">Handler</th> | ||
| 96 | - </template> | ||
| 97 | - <template v-else-if="type === 'agent'"> | ||
| 98 | - <th :class="TH">Model</th> | ||
| 99 | - </template> | ||
| 100 | - <template v-else> | ||
| 101 | - <th :class="TH" class="w-20">Size</th><th :class="TH" class="w-28">Loaded</th><th :class="TH">Imports</th> | ||
| 102 | - </template> | ||
| 103 | - <th v-if="actionable" :class="TH" class="w-28"><span class="sr-only">Actions</span></th> | ||
| 104 | - </tr> | ||
| 105 | - </thead> | ||
| 106 | - | ||
| 107 | - <tbody class="divide-y divide-default"> | ||
| 108 | - <tr | ||
| 109 | - v-for="item in items" | ||
| 110 | - :key="item.id" | ||
| 111 | - class="cursor-pointer transition-colors hover:bg-elevated/50" | ||
| 112 | - tabindex="0" | ||
| 113 | - @click="$emit('select', item)" | ||
| 114 | - @keydown.enter="$emit('select', item)" | ||
| 115 | - > | ||
| 116 | - <td v-if="actionable" :class="TD" @click.stop> | ||
| 117 | - <UCheckbox v-if="selectable(item)" :model-value="selection.includes(item.id)" :aria-label="`Select ${item.name}`" @update:model-value="(v) => toggleRow(item.id, v)" /> | ||
| 118 | - </td> | ||
| 119 | - | ||
| 120 | - <!-- Hooks lead with the event, others with the name --> | ||
| 121 | - <td :class="TD"> | ||
| 122 | - <div class="min-w-0"> | ||
| 123 | - <div class="flex items-center gap-1.5"> | ||
| 124 | - <span class="truncate font-medium text-highlighted" :title="type === 'hook' ? hook(item).event : item.name">{{ type === 'hook' ? hook(item).event : item.name }}</span> | ||
| 125 | - <UTooltip v-if="item.warnings.length" :text="item.warnings.join('\n')"> | ||
| 126 | - <UIcon name="i-lucide-triangle-alert" class="size-4 shrink-0 text-warning" /> | ||
| 127 | - </UTooltip> | ||
| 128 | - </div> | ||
| 129 | - <p v-if="item.description && type !== 'hook'" class="line-clamp-1 text-xs text-muted" :title="item.description">{{ item.description }}</p> | ||
| 130 | - </div> | ||
| 131 | - </td> | ||
| 132 | - <td :class="TD"><AgentBadge :agent="item.agent" compact /></td> | ||
| 133 | - <td :class="TD"> | ||
| 134 | - <!-- Project instructions are never shared, so a catalog status would be meaningless. --> | ||
| 135 | - <span v-if="item.type === 'instructions'" class="text-xs text-dimmed">-</span> | ||
| 136 | - <StatusBadge v-else :status="statuses?.[item.id]" /> | ||
| 137 | - </td> | ||
| 138 | - | ||
| 139 | - <!-- skills --> | ||
| 140 | - <template v-if="type === 'skill'"> | ||
| 141 | - <td :class="TD"> | ||
| 142 | - <UTooltip :text="skill(item).visibilitySource ?? 'default'"> | ||
| 143 | - <UBadge :label="VISIBILITY[skill(item).visibility].label" :color="VISIBILITY[skill(item).visibility].color" variant="subtle" size="sm" /> | ||
| 144 | - </UTooltip> | ||
| 145 | - </td> | ||
| 146 | - <td :class="TD"> | ||
| 147 | - <div class="flex flex-wrap gap-1"> | ||
| 148 | - <UBadge v-if="item.source.plugin" :label="`plugin: ${item.source.plugin}`" icon="i-lucide-puzzle" color="primary" variant="subtle" size="sm" /> | ||
| 149 | - <UTooltip v-if="item.source.symlinkTarget" :text="item.source.symlinkTarget"> | ||
| 150 | - <UBadge label="symlink" icon="i-lucide-link" color="neutral" variant="subtle" size="sm" /> | ||
| 151 | - </UTooltip> | ||
| 152 | - <!-- rtl keeps the end of the path (the meaningful part) visible when truncated --> | ||
| 153 | - <span v-if="item.source.symlinkTarget" dir="rtl" class="max-w-56 truncate text-left font-mono text-xs text-muted" :title="item.source.symlinkTarget"> | ||
| 154 | - <bdi>{{ item.source.symlinkTarget }}</bdi> | ||
| 155 | - </span> | ||
| 156 | - <span v-if="!item.source.plugin && !item.source.symlinkTarget" class="text-xs text-dimmed">local</span> | ||
| 157 | - </div> | ||
| 158 | - </td> | ||
| 159 | - </template> | ||
| 160 | - | ||
| 161 | - <!-- rules --> | ||
| 162 | - <template v-else-if="type === 'rule'"> | ||
| 163 | - <td :class="TD"> | ||
| 164 | - <span v-if="!rule(item).paths.length" class="text-xs text-muted">always</span> | ||
| 165 | - <div v-else class="flex flex-wrap gap-1"> | ||
| 166 | - <UBadge v-for="p in rule(item).paths" :key="p" :label="p" color="neutral" variant="outline" size="sm" class="font-mono" /> | ||
| 167 | - </div> | ||
| 168 | - </td> | ||
| 169 | - </template> | ||
| 170 | - | ||
| 171 | - <!-- mcp --> | ||
| 172 | - <template v-else-if="type === 'mcp'"> | ||
| 173 | - <td :class="TD"><UBadge :label="mcp(item).transport" color="neutral" variant="subtle" size="sm" /></td> | ||
| 174 | - <td :class="TD"> | ||
| 175 | - <span class="block truncate font-mono text-xs" :title="mcp(item).url ?? [mcp(item).command, ...(mcp(item).args ?? [])].join(' ')"> | ||
| 176 | - {{ mcp(item).url ?? [mcp(item).command, ...(mcp(item).args ?? [])].filter(Boolean).join(' ') }} | ||
| 177 | - </span> | ||
| 178 | - </td> | ||
| 179 | - <td :class="TD"> | ||
| 180 | - <div class="flex flex-wrap gap-1"> | ||
| 181 | - <UBadge v-for="v in mcp(item).envVarNames.slice(0, MAX_ENV)" :key="v" :label="v" color="neutral" variant="outline" size="sm" class="font-mono" /> | ||
| 182 | - <UBadge v-if="mcp(item).envVarNames.length > MAX_ENV" :label="`+${mcp(item).envVarNames.length - MAX_ENV}`" color="neutral" variant="subtle" size="sm" /> | ||
| 183 | - </div> | ||
| 184 | - </td> | ||
| 185 | - <td :class="TD"><span class="text-xs text-muted">{{ mcp(item).agentScope ?? '-' }}</span></td> | ||
| 186 | - <td :class="TD"> | ||
| 187 | - <UBadge :label="mcp(item).enabled ? 'enabled' : 'disabled'" :color="mcp(item).enabled ? 'success' : 'neutral'" variant="subtle" size="sm" /> | ||
| 188 | - </td> | ||
| 189 | - </template> | ||
| 190 | - | ||
| 191 | - <!-- hooks --> | ||
| 192 | - <template v-else-if="type === 'hook'"> | ||
| 193 | - <td :class="TD"><span class="block truncate font-mono text-xs">{{ hook(item).matcher ?? '*' }}</span></td> | ||
| 194 | - <td :class="TD"> | ||
| 195 | - <span class="block truncate font-mono text-xs" :title="hook(item).command">{{ hook(item).command ?? '-' }}</span> | ||
| 196 | - </td> | ||
| 197 | - <td :class="TD"><UBadge :label="hook(item).handlerType" color="neutral" variant="subtle" size="sm" /></td> | ||
| 198 | - </template> | ||
| 199 | - | ||
| 200 | - <!-- agents --> | ||
| 201 | - <template v-else-if="type === 'agent'"> | ||
| 202 | - <td :class="TD"><span class="text-xs">{{ agent(item).model ?? 'inherit' }}</span></td> | ||
| 203 | - </template> | ||
| 204 | - | ||
| 205 | - <!-- instructions --> | ||
| 206 | - <template v-else> | ||
| 207 | - <td :class="TD"><span class="text-xs tabular-nums text-muted">{{ formatBytes(instr(item).sizeBytes) }}</span></td> | ||
| 208 | - <td :class="TD"> | ||
| 209 | - <UTooltip v-if="!instr(item).loaded && instr(item).notLoadedReason" :text="instr(item).notLoadedReason!"> | ||
| 210 | - <UBadge label="not loaded" color="warning" variant="subtle" size="sm" icon="i-lucide-circle-help" /> | ||
| 211 | - </UTooltip> | ||
| 212 | - <UBadge v-else :label="instr(item).loaded ? 'loaded' : 'not loaded'" :color="instr(item).loaded ? 'success' : 'warning'" variant="subtle" size="sm" /> | ||
| 213 | - </td> | ||
| 214 | - <td :class="TD"> | ||
| 215 | - <div class="flex flex-wrap gap-1"> | ||
| 216 | - <UBadge v-for="im in instr(item).imports" :key="im" :label="im" color="neutral" variant="outline" size="sm" class="font-mono" /> | ||
| 217 | - <span v-if="!instr(item).imports.length" class="text-xs text-dimmed">-</span> | ||
| 218 | - </div> | ||
| 219 | - </td> | ||
| 220 | - </template> | ||
| 221 | - | ||
| 222 | - <td v-if="actionable" :class="TD" @click.stop> | ||
| 223 | - <div class="flex justify-end gap-0.5"> | ||
| 224 | - <UTooltip v-if="canImport(item)" text="Add to catalog"> | ||
| 225 | - <UButton icon="i-lucide-library" size="xs" color="neutral" variant="ghost" aria-label="Add to catalog" @click="emit('addToCatalog', item)" /> | ||
| 226 | - </UTooltip> | ||
| 227 | - <UTooltip v-if="statuses?.[item.id]?.catalogRef" text="Open in catalog"> | ||
| 228 | - <UButton icon="i-lucide-external-link" size="xs" color="neutral" variant="ghost" aria-label="Open in catalog" @click="emit('openCatalog', statuses![item.id]!.catalogRef!)" /> | ||
| 229 | - </UTooltip> | ||
| 230 | - <UTooltip v-if="canRemoveGlobal(item)" text="Remove from global"> | ||
| 231 | - <UButton icon="i-lucide-globe-lock" size="xs" color="neutral" variant="ghost" aria-label="Remove from global" @click="emit('removeGlobal', item)" /> | ||
| 232 | - </UTooltip> | ||
| 233 | - </div> | ||
| 234 | - </td> | ||
| 235 | - </tr> | ||
| 236 | - </tbody> | ||
| 237 | - </table> | ||
| 238 | - </div> | ||
| 239 | -</template> | ||
added
packages/ui/app/components/MarkdownBody.vue +42 -0 | new file mode 100644 | ||
| @@ -0,0 +1,42 @@ | ||
| 1 | +<script setup lang="ts"> | |
| 2 | +/** | |
| 3 | + * Rendered Markdown (sanitised by utils/markdown.ts: raw HTML is escaped, only http(s)/mailto links). | |
| 4 | + * Pass either the Markdown `source` (frontmatter is NOT stripped: split it first) or ready-made `html`. | |
| 5 | + * The `.md-body` typography lives here (not in main.css): a raw `<div class="md-body">` elsewhere is unstyled. | |
| 6 | + */ | |
| 7 | +const props = defineProps<{ source?: string, html?: string }>() | |
| 8 | +const out = computed(() => props.html ?? renderMarkdown(props.source ?? '')) | |
| 9 | +</script> | |
| 10 | + | |
| 11 | +<template> | |
| 12 | + <!-- eslint-disable-next-line vue/no-v-html --> | |
| 13 | + <div class="md-body" v-html="out" /> | |
| 14 | +</template> | |
| 15 | + | |
| 16 | +<style> | |
| 17 | +.md-body { font-size: 13px; line-height: 1.55; color: var(--sk-muted); overflow-wrap: anywhere; text-wrap: pretty; } | |
| 18 | +.md-body > :first-child { margin-top: 0; } | |
| 19 | +.md-body > :last-child { margin-bottom: 0; } | |
| 20 | +.md-body h1 { font-size: 17px; font-weight: 600; letter-spacing: -0.01em; color: var(--sk-text); margin: 14px 0 10px; line-height: 1.3; } | |
| 21 | +.md-body h2 { font-size: 14px; font-weight: 600; color: var(--sk-text); margin: 16px 0 8px; line-height: 1.3; } | |
| 22 | +.md-body h3, .md-body h4 { font-size: 13px; font-weight: 600; color: var(--sk-text); margin: 12px 0 6px; } | |
| 23 | +.md-body p { margin: 8px 0; } | |
| 24 | +.md-body strong { color: var(--sk-text); font-weight: 600; } | |
| 25 | +.md-body ul { list-style: none; padding: 0; margin: 8px 0; } | |
| 26 | +.md-body ul > li { position: relative; padding-left: 16px; margin: 3px 0; } | |
| 27 | +.md-body ul > li::before { content: '–'; position: absolute; left: 2px; color: var(--sk-faint); } | |
| 28 | +.md-body ol { list-style: decimal; padding-left: 20px; margin: 8px 0; } | |
| 29 | +.md-body ol > li { margin: 3px 0; padding-left: 2px; } | |
| 30 | +.md-body ol > li::marker { color: var(--sk-faint); font-family: var(--font-mono); font-size: 12px; } | |
| 31 | +.md-body li > ul, .md-body li > ol { margin: 2px 0; } | |
| 32 | +.md-body a { color: var(--sk-text); text-decoration: underline; text-decoration-color: var(--sk-line); text-underline-offset: 3px; } | |
| 33 | +.md-body a:hover { color: var(--sk-accent-text); } | |
| 34 | +.md-body code { font-family: var(--font-mono); background: var(--sk-line2); color: var(--sk-text); padding: 1px 5px; border-radius: 5px; font-size: 12px; } | |
| 35 | +.md-body pre { background: var(--sk-bg); border: 1px solid var(--sk-line2); padding: 12px 14px; border-radius: 8px; overflow-x: auto; margin: 10px 0; } | |
| 36 | +.md-body pre code { background: transparent; padding: 0; border-radius: 0; color: var(--sk-text); font-size: 12px; line-height: 1.6; } | |
| 37 | +.md-body blockquote { border-left: 2px solid var(--sk-line); padding-left: 12px; color: var(--sk-muted); margin: 8px 0; } | |
| 38 | +.md-body table { display: block; max-width: 100%; overflow-x: auto; border-collapse: collapse; margin: 10px 0; } | |
| 39 | +.md-body th, .md-body td { border: 1px solid var(--sk-line2); padding: 4px 10px; text-align: left; vertical-align: top; } | |
| 40 | +.md-body th { background: var(--sk-hover); color: var(--sk-text); font-weight: 600; } | |
| 41 | +.md-body hr { border: 0; border-top: 1px solid var(--sk-line2); margin: 14px 0; } | |
| 42 | +</style> | |
| new file mode 100644 | |||
| @@ -0,0 +1,42 @@ | |||
| 1 | +<script setup lang="ts"> | ||
| 2 | +/** | ||
| 3 | + * Rendered Markdown (sanitised by utils/markdown.ts: raw HTML is escaped, only http(s)/mailto links). | ||
| 4 | + * Pass either the Markdown `source` (frontmatter is NOT stripped: split it first) or ready-made `html`. | ||
| 5 | + * The `.md-body` typography lives here (not in main.css): a raw `<div class="md-body">` elsewhere is unstyled. | ||
| 6 | + */ | ||
| 7 | +const props = defineProps<{ source?: string, html?: string }>() | ||
| 8 | +const out = computed(() => props.html ?? renderMarkdown(props.source ?? '')) | ||
| 9 | +</script> | ||
| 10 | + | ||
| 11 | +<template> | ||
| 12 | + <!-- eslint-disable-next-line vue/no-v-html --> | ||
| 13 | + <div class="md-body" v-html="out" /> | ||
| 14 | +</template> | ||
| 15 | + | ||
| 16 | +<style> | ||
| 17 | +.md-body { font-size: 13px; line-height: 1.55; color: var(--sk-muted); overflow-wrap: anywhere; text-wrap: pretty; } | ||
| 18 | +.md-body > :first-child { margin-top: 0; } | ||
| 19 | +.md-body > :last-child { margin-bottom: 0; } | ||
| 20 | +.md-body h1 { font-size: 17px; font-weight: 600; letter-spacing: -0.01em; color: var(--sk-text); margin: 14px 0 10px; line-height: 1.3; } | ||
| 21 | +.md-body h2 { font-size: 14px; font-weight: 600; color: var(--sk-text); margin: 16px 0 8px; line-height: 1.3; } | ||
| 22 | +.md-body h3, .md-body h4 { font-size: 13px; font-weight: 600; color: var(--sk-text); margin: 12px 0 6px; } | ||
| 23 | +.md-body p { margin: 8px 0; } | ||
| 24 | +.md-body strong { color: var(--sk-text); font-weight: 600; } | ||
| 25 | +.md-body ul { list-style: none; padding: 0; margin: 8px 0; } | ||
| 26 | +.md-body ul > li { position: relative; padding-left: 16px; margin: 3px 0; } | ||
| 27 | +.md-body ul > li::before { content: '–'; position: absolute; left: 2px; color: var(--sk-faint); } | ||
| 28 | +.md-body ol { list-style: decimal; padding-left: 20px; margin: 8px 0; } | ||
| 29 | +.md-body ol > li { margin: 3px 0; padding-left: 2px; } | ||
| 30 | +.md-body ol > li::marker { color: var(--sk-faint); font-family: var(--font-mono); font-size: 12px; } | ||
| 31 | +.md-body li > ul, .md-body li > ol { margin: 2px 0; } | ||
| 32 | +.md-body a { color: var(--sk-text); text-decoration: underline; text-decoration-color: var(--sk-line); text-underline-offset: 3px; } | ||
| 33 | +.md-body a:hover { color: var(--sk-accent-text); } | ||
| 34 | +.md-body code { font-family: var(--font-mono); background: var(--sk-line2); color: var(--sk-text); padding: 1px 5px; border-radius: 5px; font-size: 12px; } | ||
| 35 | +.md-body pre { background: var(--sk-bg); border: 1px solid var(--sk-line2); padding: 12px 14px; border-radius: 8px; overflow-x: auto; margin: 10px 0; } | ||
| 36 | +.md-body pre code { background: transparent; padding: 0; border-radius: 0; color: var(--sk-text); font-size: 12px; line-height: 1.6; } | ||
| 37 | +.md-body blockquote { border-left: 2px solid var(--sk-line); padding-left: 12px; color: var(--sk-muted); margin: 8px 0; } | ||
| 38 | +.md-body table { display: block; max-width: 100%; overflow-x: auto; border-collapse: collapse; margin: 10px 0; } | ||
| 39 | +.md-body th, .md-body td { border: 1px solid var(--sk-line2); padding: 4px 10px; text-align: left; vertical-align: top; } | ||
| 40 | +.md-body th { background: var(--sk-hover); color: var(--sk-text); font-weight: 600; } | ||
| 41 | +.md-body hr { border: 0; border-top: 1px solid var(--sk-line2); margin: 14px 0; } | ||
| 42 | +</style> | ||
modified
packages/ui/app/components/MarkdownView.vue +10 -23 | @@ -43,7 +43,7 @@ watch(() => props.path, load, { immediate: true }) | ||
| 43 | 43 | <template> |
| 44 | 44 | <div class="space-y-3"> |
| 45 | 45 | <div class="flex items-center justify-between gap-2"> |
| 46 | - <span dir="rtl" class="truncate text-left font-mono text-xs text-muted" :title="path"><bdi>{{ path }}</bdi></span> | |
| 46 | + <span dir="rtl" class="truncate text-left font-mono text-xs text-sk-faint" :title="path"><bdi>{{ path }}</bdi></span> | |
| 47 | 47 | <USwitch v-if="isMarkdown" v-model="raw" label="Raw" size="sm" class="shrink-0" /> |
| 48 | 48 | </div> |
| 49 | 49 | |
| @@ -52,31 +52,18 @@ watch(() => props.path, load, { immediate: true }) | ||
| 52 | 52 | <USkeleton class="h-4 w-full" /> |
| 53 | 53 | <USkeleton class="h-4 w-5/6" /> |
| 54 | 54 | </div> |
| 55 | - <UAlert v-else-if="error" color="error" variant="subtle" icon="i-lucide-circle-alert" title="Could not read the file" :description="error" /> | |
| 55 | + <SkNote v-else-if="error" tone="accent">Could not read the file: {{ error }}</SkNote> | |
| 56 | 56 | <template v-else-if="file"> |
| 57 | - <UAlert | |
| 58 | - v-if="file.truncated" | |
| 59 | - color="warning" | |
| 60 | - variant="subtle" | |
| 61 | - icon="i-lucide-scissors" | |
| 62 | - title="Large file" | |
| 63 | - :description="`Only the first part of this file is shown (${formatBytes(file.sizeBytes)} in total).`" | |
| 64 | - /> | |
| 57 | + <SkNote v-if="file.truncated" tone="accent"> | |
| 58 | + Large file: only the first part is shown ({{ formatBytes(file.sizeBytes) }} in total). | |
| 59 | + </SkNote> | |
| 65 | 60 | |
| 66 | - <pre v-if="raw || !isMarkdown" class="overflow-auto whitespace-pre-wrap rounded-lg bg-elevated p-3 font-mono text-xs">{{ file.content }}</pre> | |
| 61 | + <SkCode v-if="raw || !isMarkdown" :text="file.content" /> | |
| 67 | 62 | <template v-else> |
| 68 | - <div v-if="fmRows.length" class="overflow-hidden rounded-lg border border-default"> | |
| 69 | - <table class="w-full text-xs"> | |
| 70 | - <tbody class="divide-y divide-default"> | |
| 71 | - <tr v-for="[k, v] in fmRows" :key="k"> | |
| 72 | - <th class="w-1/4 bg-elevated/40 px-2 py-1.5 text-left align-top font-medium text-muted">{{ k }}</th> | |
| 73 | - <td class="px-2 py-1.5 break-words">{{ v }}</td> | |
| 74 | - </tr> | |
| 75 | - </tbody> | |
| 76 | - </table> | |
| 77 | - </div> | |
| 78 | - <!-- eslint-disable-next-line vue/no-v-html --> | |
| 79 | - <div class="md-body" v-html="html" /> | |
| 63 | + <SkMetaGrid v-if="fmRows.length" label-width="90px" class="rounded-[10px] border border-sk-line2 bg-sk-bg px-3.5 py-3"> | |
| 64 | + <SkMeta v-for="[k, v] in fmRows" :key="k" :label="k">{{ v }}</SkMeta> | |
| 65 | + </SkMetaGrid> | |
| 66 | + <MarkdownBody :html="html" /> | |
| 80 | 67 | </template> |
| 81 | 68 | </template> |
| 82 | 69 | </div> |
| @@ -43,7 +43,7 @@ watch(() => props.path, load, { immediate: true }) | |||
| 43 | <template> | 43 | <template> |
| 44 | <div class="space-y-3"> | 44 | <div class="space-y-3"> |
| 45 | <div class="flex items-center justify-between gap-2"> | 45 | <div class="flex items-center justify-between gap-2"> |
| 46 | - <span dir="rtl" class="truncate text-left font-mono text-xs text-muted" :title="path"><bdi>{{ path }}</bdi></span> | 46 | + <span dir="rtl" class="truncate text-left font-mono text-xs text-sk-faint" :title="path"><bdi>{{ path }}</bdi></span> |
| 47 | <USwitch v-if="isMarkdown" v-model="raw" label="Raw" size="sm" class="shrink-0" /> | 47 | <USwitch v-if="isMarkdown" v-model="raw" label="Raw" size="sm" class="shrink-0" /> |
| 48 | </div> | 48 | </div> |
| 49 | 49 | ||
| @@ -52,31 +52,18 @@ watch(() => props.path, load, { immediate: true }) | |||
| 52 | <USkeleton class="h-4 w-full" /> | 52 | <USkeleton class="h-4 w-full" /> |
| 53 | <USkeleton class="h-4 w-5/6" /> | 53 | <USkeleton class="h-4 w-5/6" /> |
| 54 | </div> | 54 | </div> |
| 55 | - <UAlert v-else-if="error" color="error" variant="subtle" icon="i-lucide-circle-alert" title="Could not read the file" :description="error" /> | 55 | + <SkNote v-else-if="error" tone="accent">Could not read the file: {{ error }}</SkNote> |
| 56 | <template v-else-if="file"> | 56 | <template v-else-if="file"> |
| 57 | - <UAlert | 57 | + <SkNote v-if="file.truncated" tone="accent"> |
| 58 | - v-if="file.truncated" | 58 | + Large file: only the first part is shown ({{ formatBytes(file.sizeBytes) }} in total). |
| 59 | - color="warning" | 59 | + </SkNote> |
| 60 | - variant="subtle" | ||
| 61 | - icon="i-lucide-scissors" | ||
| 62 | - title="Large file" | ||
| 63 | - :description="`Only the first part of this file is shown (${formatBytes(file.sizeBytes)} in total).`" | ||
| 64 | - /> | ||
| 65 | 60 | ||
| 66 | - <pre v-if="raw || !isMarkdown" class="overflow-auto whitespace-pre-wrap rounded-lg bg-elevated p-3 font-mono text-xs">{{ file.content }}</pre> | 61 | + <SkCode v-if="raw || !isMarkdown" :text="file.content" /> |
| 67 | <template v-else> | 62 | <template v-else> |
| 68 | - <div v-if="fmRows.length" class="overflow-hidden rounded-lg border border-default"> | 63 | + <SkMetaGrid v-if="fmRows.length" label-width="90px" class="rounded-[10px] border border-sk-line2 bg-sk-bg px-3.5 py-3"> |
| 69 | - <table class="w-full text-xs"> | 64 | + <SkMeta v-for="[k, v] in fmRows" :key="k" :label="k">{{ v }}</SkMeta> |
| 70 | - <tbody class="divide-y divide-default"> | 65 | + </SkMetaGrid> |
| 71 | - <tr v-for="[k, v] in fmRows" :key="k"> | 66 | + <MarkdownBody :html="html" /> |
| 72 | - <th class="w-1/4 bg-elevated/40 px-2 py-1.5 text-left align-top font-medium text-muted">{{ k }}</th> | ||
| 73 | - <td class="px-2 py-1.5 break-words">{{ v }}</td> | ||
| 74 | - </tr> | ||
| 75 | - </tbody> | ||
| 76 | - </table> | ||
| 77 | - </div> | ||
| 78 | - <!-- eslint-disable-next-line vue/no-v-html --> | ||
| 79 | - <div class="md-body" v-html="html" /> | ||
| 80 | </template> | 67 | </template> |
| 81 | </template> | 68 | </template> |
| 82 | </div> | 69 | </div> |
modified
packages/ui/app/components/McpForm.vue +27 -24 | @@ -5,8 +5,8 @@ import type { McpTemplate } from '@skilly/core' | ||
| 5 | 5 | const t = defineModel<McpTemplate>({ required: true }) |
| 6 | 6 | |
| 7 | 7 | const TRANSPORTS = [ |
| 8 | - { label: 'stdio (local command)', value: 'stdio' }, | |
| 9 | - { label: 'http (remote URL)', value: 'http' } | |
| 8 | + { label: 'stdio (local command)', value: 'stdio' as const }, | |
| 9 | + { label: 'http (remote URL)', value: 'http' as const } | |
| 10 | 10 | ] |
| 11 | 11 | |
| 12 | 12 | const args = computed({ |
| @@ -24,37 +24,40 @@ function patch(p: Partial<McpTemplate>) { | ||
| 24 | 24 | </script> |
| 25 | 25 | |
| 26 | 26 | <template> |
| 27 | - <div class="space-y-4"> | |
| 28 | - <UFormField label="Description"> | |
| 29 | - <UInput :model-value="t.description ?? ''" class="w-full" placeholder="What this server provides" @update:model-value="(v: string) => patch({ description: v })" /> | |
| 30 | - </UFormField> | |
| 27 | + <div class="flex flex-col gap-4"> | |
| 28 | + <CatalogField label="Description"> | |
| 29 | + <UInput :model-value="t.description ?? ''" class="w-full" placeholder="What this server provides" @update:model-value="(v: string) => patch({ description: v || undefined })" /> | |
| 30 | + </CatalogField> | |
| 31 | 31 | |
| 32 | - <UFormField label="Transport"> | |
| 33 | - <USelect :model-value="t.transport" :items="TRANSPORTS" class="w-64" @update:model-value="(v: string) => patch({ transport: v as 'stdio' | 'http' })" /> | |
| 34 | - </UFormField> | |
| 32 | + <div class="flex flex-col gap-1.5"> | |
| 33 | + <span class="font-medium">Transport</span> | |
| 34 | + <div><SkSeg :model-value="t.transport" :options="TRANSPORTS" aria-label="Transport" @update:model-value="(v) => v && patch({ transport: v })" /></div> | |
| 35 | + </div> | |
| 35 | 36 | |
| 36 | 37 | <template v-if="t.transport === 'stdio'"> |
| 37 | - <UFormField label="Command" required> | |
| 38 | - <UInput :model-value="t.command ?? ''" class="w-full" placeholder="npx" :ui="{ base: 'font-mono' }" @update:model-value="(v: string) => patch({ command: v })" /> | |
| 39 | - </UFormField> | |
| 40 | - <UFormField label="Arguments" description="Press Enter to add each argument."> | |
| 38 | + <CatalogField label="Command" required> | |
| 39 | + <UInput :model-value="t.command ?? ''" class="w-full" placeholder="npx" :ui="{ base: 'font-mono text-xs' }" @update:model-value="(v: string) => patch({ command: v })" /> | |
| 40 | + </CatalogField> | |
| 41 | + <CatalogField label="Arguments" hint="Press Enter to add each argument."> | |
| 41 | 42 | <UInputTags v-model="args" add-on-blur add-on-paste class="w-full" placeholder="-y, @upstash/context7-mcp" /> |
| 42 | - </UFormField> | |
| 43 | - <UFormField label="Environment variables" description="Variables passed to the server process."> | |
| 43 | + </CatalogField> | |
| 44 | + <div class="flex flex-col gap-1.5"> | |
| 45 | + <span class="flex flex-wrap items-baseline gap-x-2"><span class="font-medium">Environment variables</span><span class="text-xs text-sk-faint">Passed to the server process.</span></span> | |
| 44 | 46 | <EnvRows :model-value="t.env" key-label="Variable name" key-placeholder="API_KEY" @update:model-value="(v) => patch({ env: v })" /> |
| 45 | - </UFormField> | |
| 47 | + </div> | |
| 46 | 48 | </template> |
| 47 | 49 | |
| 48 | 50 | <template v-else> |
| 49 | - <UFormField label="URL" required> | |
| 50 | - <UInput :model-value="t.url ?? ''" class="w-full" placeholder="https://example.com/mcp" :ui="{ base: 'font-mono' }" @update:model-value="(v: string) => patch({ url: v })" /> | |
| 51 | - </UFormField> | |
| 52 | - <UFormField label="Bearer token env var" description="Name of the environment variable holding the token (never the token itself)."> | |
| 53 | - <UInput v-model="bearer" class="w-72" placeholder="GITHUB_TOKEN" :ui="{ base: 'font-mono' }" /> | |
| 54 | - </UFormField> | |
| 55 | - <UFormField label="Headers"> | |
| 51 | + <CatalogField label="URL" required> | |
| 52 | + <UInput :model-value="t.url ?? ''" class="w-full" placeholder="https://example.com/mcp" :ui="{ base: 'font-mono text-xs' }" @update:model-value="(v: string) => patch({ url: v })" /> | |
| 53 | + </CatalogField> | |
| 54 | + <CatalogField label="Bearer token variable" hint="Name of the environment variable holding the token, never the token itself."> | |
| 55 | + <UInput v-model="bearer" class="w-full max-w-sm" placeholder="GITHUB_TOKEN" :ui="{ base: 'font-mono text-xs' }" /> | |
| 56 | + </CatalogField> | |
| 57 | + <div class="flex flex-col gap-1.5"> | |
| 58 | + <span class="font-medium">Headers</span> | |
| 56 | 59 | <EnvRows :model-value="t.headers" key-label="Header name" key-placeholder="X-Api-Key" @update:model-value="(v) => patch({ headers: v })" /> |
| 57 | - </UFormField> | |
| 60 | + </div> | |
| 58 | 61 | </template> |
| 59 | 62 | </div> |
| 60 | 63 | </template> |
| @@ -5,8 +5,8 @@ import type { McpTemplate } from '@skilly/core' | |||
| 5 | const t = defineModel<McpTemplate>({ required: true }) | 5 | const t = defineModel<McpTemplate>({ required: true }) |
| 6 | 6 | ||
| 7 | const TRANSPORTS = [ | 7 | const TRANSPORTS = [ |
| 8 | - { label: 'stdio (local command)', value: 'stdio' }, | 8 | + { label: 'stdio (local command)', value: 'stdio' as const }, |
| 9 | - { label: 'http (remote URL)', value: 'http' } | 9 | + { label: 'http (remote URL)', value: 'http' as const } |
| 10 | ] | 10 | ] |
| 11 | 11 | ||
| 12 | const args = computed({ | 12 | const args = computed({ |
| @@ -24,37 +24,40 @@ function patch(p: Partial<McpTemplate>) { | |||
| 24 | </script> | 24 | </script> |
| 25 | 25 | ||
| 26 | <template> | 26 | <template> |
| 27 | - <div class="space-y-4"> | 27 | + <div class="flex flex-col gap-4"> |
| 28 | - <UFormField label="Description"> | 28 | + <CatalogField label="Description"> |
| 29 | - <UInput :model-value="t.description ?? ''" class="w-full" placeholder="What this server provides" @update:model-value="(v: string) => patch({ description: v })" /> | 29 | + <UInput :model-value="t.description ?? ''" class="w-full" placeholder="What this server provides" @update:model-value="(v: string) => patch({ description: v || undefined })" /> |
| 30 | - </UFormField> | 30 | + </CatalogField> |
| 31 | 31 | ||
| 32 | - <UFormField label="Transport"> | 32 | + <div class="flex flex-col gap-1.5"> |
| 33 | - <USelect :model-value="t.transport" :items="TRANSPORTS" class="w-64" @update:model-value="(v: string) => patch({ transport: v as 'stdio' | 'http' })" /> | 33 | + <span class="font-medium">Transport</span> |
| 34 | - </UFormField> | 34 | + <div><SkSeg :model-value="t.transport" :options="TRANSPORTS" aria-label="Transport" @update:model-value="(v) => v && patch({ transport: v })" /></div> |
| 35 | + </div> | ||
| 35 | 36 | ||
| 36 | <template v-if="t.transport === 'stdio'"> | 37 | <template v-if="t.transport === 'stdio'"> |
| 37 | - <UFormField label="Command" required> | 38 | + <CatalogField label="Command" required> |
| 38 | - <UInput :model-value="t.command ?? ''" class="w-full" placeholder="npx" :ui="{ base: 'font-mono' }" @update:model-value="(v: string) => patch({ command: v })" /> | 39 | + <UInput :model-value="t.command ?? ''" class="w-full" placeholder="npx" :ui="{ base: 'font-mono text-xs' }" @update:model-value="(v: string) => patch({ command: v })" /> |
| 39 | - </UFormField> | 40 | + </CatalogField> |
| 40 | - <UFormField label="Arguments" description="Press Enter to add each argument."> | 41 | + <CatalogField label="Arguments" hint="Press Enter to add each argument."> |
| 41 | <UInputTags v-model="args" add-on-blur add-on-paste class="w-full" placeholder="-y, @upstash/context7-mcp" /> | 42 | <UInputTags v-model="args" add-on-blur add-on-paste class="w-full" placeholder="-y, @upstash/context7-mcp" /> |
| 42 | - </UFormField> | 43 | + </CatalogField> |
| 43 | - <UFormField label="Environment variables" description="Variables passed to the server process."> | 44 | + <div class="flex flex-col gap-1.5"> |
| 45 | + <span class="flex flex-wrap items-baseline gap-x-2"><span class="font-medium">Environment variables</span><span class="text-xs text-sk-faint">Passed to the server process.</span></span> | ||
| 44 | <EnvRows :model-value="t.env" key-label="Variable name" key-placeholder="API_KEY" @update:model-value="(v) => patch({ env: v })" /> | 46 | <EnvRows :model-value="t.env" key-label="Variable name" key-placeholder="API_KEY" @update:model-value="(v) => patch({ env: v })" /> |
| 45 | - </UFormField> | 47 | + </div> |
| 46 | </template> | 48 | </template> |
| 47 | 49 | ||
| 48 | <template v-else> | 50 | <template v-else> |
| 49 | - <UFormField label="URL" required> | 51 | + <CatalogField label="URL" required> |
| 50 | - <UInput :model-value="t.url ?? ''" class="w-full" placeholder="https://example.com/mcp" :ui="{ base: 'font-mono' }" @update:model-value="(v: string) => patch({ url: v })" /> | 52 | + <UInput :model-value="t.url ?? ''" class="w-full" placeholder="https://example.com/mcp" :ui="{ base: 'font-mono text-xs' }" @update:model-value="(v: string) => patch({ url: v })" /> |
| 51 | - </UFormField> | 53 | + </CatalogField> |
| 52 | - <UFormField label="Bearer token env var" description="Name of the environment variable holding the token (never the token itself)."> | 54 | + <CatalogField label="Bearer token variable" hint="Name of the environment variable holding the token, never the token itself."> |
| 53 | - <UInput v-model="bearer" class="w-72" placeholder="GITHUB_TOKEN" :ui="{ base: 'font-mono' }" /> | 55 | + <UInput v-model="bearer" class="w-full max-w-sm" placeholder="GITHUB_TOKEN" :ui="{ base: 'font-mono text-xs' }" /> |
| 54 | - </UFormField> | 56 | + </CatalogField> |
| 55 | - <UFormField label="Headers"> | 57 | + <div class="flex flex-col gap-1.5"> |
| 58 | + <span class="font-medium">Headers</span> | ||
| 56 | <EnvRows :model-value="t.headers" key-label="Header name" key-placeholder="X-Api-Key" @update:model-value="(v) => patch({ headers: v })" /> | 59 | <EnvRows :model-value="t.headers" key-label="Header name" key-placeholder="X-Api-Key" @update:model-value="(v) => patch({ headers: v })" /> |
| 57 | - </UFormField> | 60 | + </div> |
| 58 | </template> | 61 | </template> |
| 59 | </div> | 62 | </div> |
| 60 | </template> | 63 | </template> |
modified
packages/ui/app/components/PresetEditor.vue +176 -113 | @@ -1,12 +1,11 @@ | ||
| 1 | 1 | <script setup lang="ts"> |
| 2 | 2 | import type { CatalogItemType, Preset } from '@skilly/core' |
| 3 | 3 | |
| 4 | -/** Preset editor. `name` is undefined for a new preset. */ | |
| 4 | +/** Preset editor: name, description, included presets, items; resolved preview on the right. `name` is undefined for a new preset. */ | |
| 5 | 5 | const props = defineProps<{ name?: string }>() |
| 6 | 6 | |
| 7 | 7 | const { request } = useApi() |
| 8 | 8 | const catalog = useCatalog() |
| 9 | -const scopeNames = useScopeNames() | |
| 10 | 9 | const toast = useToast() |
| 11 | 10 | |
| 12 | 11 | const isNew = computed(() => !props.name) |
| @@ -39,33 +38,77 @@ async function init() { | ||
| 39 | 38 | original.value = snapshot() |
| 40 | 39 | ready.value = true |
| 41 | 40 | } |
| 42 | -onMounted(init) | |
| 41 | +onMounted(() => { | |
| 42 | + init() | |
| 43 | + catalog.refresh() | |
| 44 | +}) | |
| 43 | 45 | |
| 44 | -const includeChoices = computed(() => catalog.presets.value.map(p => p.name).filter(n => n !== props.name && n !== form.name)) | |
| 45 | 46 | const renaming = computed(() => !isNew.value && form.name !== props.name) |
| 47 | +const others = computed(() => catalog.presets.value.filter(p => p.name !== props.name)) | |
| 46 | 48 | |
| 47 | -const problems = computed(() => { | |
| 48 | - const p: string[] = [] | |
| 49 | - if (!NAME_RE.test(form.name)) p.push('Name must be kebab-case: lowercase letters, digits, "." "_" "-".') | |
| 50 | - else if ((isNew.value || renaming.value) && catalog.presets.value.some(x => x.name === form.name)) p.push(`A preset named "${form.name}" already exists.`) | |
| 51 | - return p | |
| 49 | +/** Presets that (transitively) include `target`: including them here would create a cycle. */ | |
| 50 | +function dependsOn(p: Preset, target: string, seen = new Set<string>()): boolean { | |
| 51 | + if (seen.has(p.name)) return false | |
| 52 | + seen.add(p.name) | |
| 53 | + return p.includes.some(n => n === target || (others.value.find(x => x.name === n) ? dependsOn(others.value.find(x => x.name === n)!, target, seen) : false)) | |
| 54 | +} | |
| 55 | +const includeChoices = computed(() => others.value | |
| 56 | + .filter(p => p.name !== form.name) | |
| 57 | + .map(p => ({ | |
| 58 | + name: p.name, | |
| 59 | + count: resolveItems(catalog.presets.value, [p.name]).entries.length, | |
| 60 | + blocked: !!props.name && dependsOn(p, props.name) && !form.includes.includes(p.name) | |
| 61 | + }))) | |
| 62 | +function toggleInclude(n: string) { | |
| 63 | + form.includes = form.includes.includes(n) ? form.includes.filter(x => x !== n) : [...form.includes, n] | |
| 64 | +} | |
| 65 | + | |
| 66 | +const nameError = computed(() => { | |
| 67 | + if (!form.name) return isNew.value && !dirty.value ? null : 'Give the preset a name.' | |
| 68 | + if (!NAME_RE.test(form.name)) return 'Use kebab-case: lowercase letters, digits, "." "_" "-", starting with a letter or digit.' | |
| 69 | + if ((isNew.value || renaming.value) && catalog.presets.value.some(x => x.name === form.name)) return 'A preset with this name already exists.' | |
| 70 | + return null | |
| 52 | 71 | }) |
| 72 | +const canSave = computed(() => dirty.value && !!form.name && !nameError.value) | |
| 53 | 73 | |
| 74 | +const selfName = computed(() => form.name || props.name || '(this preset)') | |
| 54 | 75 | const resolved = computed(() => resolveItems( |
| 55 | - catalog.presets.value.filter(p => p.name !== props.name), | |
| 76 | + others.value, | |
| 56 | 77 | [], |
| 57 | 78 | {}, |
| 58 | - { name: form.name || props.name || '(this preset)', includes: form.includes, items: form.items } | |
| 79 | + { name: selfName.value, includes: form.includes, items: form.items } | |
| 59 | 80 | )) |
| 60 | -const resolvedByType = computed(() => CAT_TYPES.map(t => ({ | |
| 61 | - type: t, | |
| 62 | - rows: resolved.value.entries.filter(e => e.type === t).sort((a, b) => a.name.localeCompare(b.name)) | |
| 63 | -})).filter(g => g.rows.length)) | |
| 64 | -const selfName = computed(() => form.name || props.name || '(this preset)') | |
| 65 | -const missingItems = (t: CatalogItemType, n: string) => !catalog.findItem(t, n) | |
| 81 | +const inherited = computed(() => resolveItems(others.value, [], {}, { name: selfName.value, includes: form.includes, items: {} })) | |
| 82 | +/** Items provided by an included preset and not picked here: shown checked and locked in the picker. */ | |
| 83 | +const locked = computed(() => { | |
| 84 | + const out: Record<string, string> = {} | |
| 85 | + for (const e of inherited.value.entries) { | |
| 86 | + if (form.items[e.type]?.includes(e.name)) continue | |
| 87 | + out[itemKey(e.type, e.name)] = `via ${e.via.filter(v => v !== selfName.value).join(', ')}` | |
| 88 | + } | |
| 89 | + return out | |
| 90 | +}) | |
| 91 | + | |
| 92 | +const resolvedRows = computed(() => [...resolved.value.entries] | |
| 93 | + .sort((a, b) => CAT_ORDER.indexOf(a.type) - CAT_ORDER.indexOf(b.type) || a.name.localeCompare(b.name)) | |
| 94 | + .map(e => ({ ...e, from: e.via.filter(v => v !== selfName.value), own: e.via.includes(selfName.value) }))) | |
| 95 | +const PLURAL: Record<CatalogItemType, [string, string]> = { | |
| 96 | + skill: ['skill', 'skills'], rule: ['rule', 'rules'], mcp: ['MCP server', 'MCP servers'], hook: ['hook', 'hooks'], agent: ['subagent', 'subagents'] | |
| 97 | +} | |
| 98 | +const countText = computed(() => { | |
| 99 | + const n = resolvedRows.value.length | |
| 100 | + if (!n) return 'Nothing yet' | |
| 101 | + const parts = CAT_ORDER.map((t) => { | |
| 102 | + const c = resolvedRows.value.filter(r => r.type === t).length | |
| 103 | + return c ? `${c} ${PLURAL[t][c === 1 ? 0 : 1]}` : '' | |
| 104 | + }).filter(Boolean) | |
| 105 | + return `${n} item${n > 1 ? 's' : ''} · ${parts.join(', ')}` | |
| 106 | +}) | |
| 107 | +const isMissing = (t: CatalogItemType, n: string) => !catalog.findItem(t, n) | |
| 108 | +const usedBy = computed(() => (props.name ? catalog.presetUsedBy(props.name) : [])) | |
| 66 | 109 | |
| 67 | 110 | async function save() { |
| 68 | - if (problems.value.length) return | |
| 111 | + if (!canSave.value) return | |
| 69 | 112 | saving.value = true |
| 70 | 113 | saveError.value = null |
| 71 | 114 | const items: Partial<Record<CatalogItemType, string[]>> = {} |
| @@ -79,7 +122,13 @@ async function save() { | ||
| 79 | 122 | } |
| 80 | 123 | try { |
| 81 | 124 | await request(`/api/catalog/presets/${encodeURIComponent(form.name)}`, { method: 'PUT', body, silent: true }) |
| 82 | - toast.add({ title: isNew.value ? 'Preset created' : 'Preset saved', description: form.name, icon: 'i-lucide-check', color: 'success' }) | |
| 125 | + const n = usedBy.value.length | |
| 126 | + toast.add({ | |
| 127 | + title: isNew.value ? 'Preset created' : 'Preset saved', | |
| 128 | + description: !isNew.value && n ? `${form.name} · ${n} project${n > 1 ? 's' : ''} will show an update` : form.name, | |
| 129 | + icon: 'i-lucide-check', | |
| 130 | + color: 'success' | |
| 131 | + }) | |
| 83 | 132 | await catalog.refresh() |
| 84 | 133 | original.value = snapshot() |
| 85 | 134 | if (isNew.value || renaming.value) await navigateTo(`/catalog/presets/${encodeURIComponent(form.name)}`, { replace: true }) |
| @@ -90,9 +139,17 @@ async function save() { | ||
| 90 | 139 | } |
| 91 | 140 | } |
| 92 | 141 | |
| 142 | +function cancel() { | |
| 143 | + navigateTo('/catalog/presets') | |
| 144 | +} | |
| 145 | + | |
| 93 | 146 | onBeforeRouteLeave(() => !dirty.value || window.confirm('Discard unsaved changes?')) |
| 147 | +function beforeUnload(e: BeforeUnloadEvent) { | |
| 148 | + if (dirty.value) e.preventDefault() | |
| 149 | +} | |
| 150 | +onMounted(() => window.addEventListener('beforeunload', beforeUnload)) | |
| 151 | +onBeforeUnmount(() => window.removeEventListener('beforeunload', beforeUnload)) | |
| 94 | 152 | |
| 95 | -const usedBy = computed(() => (props.name ? catalog.presetUsedBy(props.name) : [])) | |
| 96 | 153 | const delOpen = ref(false) |
| 97 | 154 | const deleting = ref(false) |
| 98 | 155 | const delConflict = ref<string | null>(null) |
| @@ -121,105 +178,111 @@ function askDelete() { | ||
| 121 | 178 | </script> |
| 122 | 179 | |
| 123 | 180 | <template> |
| 124 | - <UDashboardPanel id="preset-editor"> | |
| 125 | - <template #header> | |
| 126 | - <UDashboardNavbar icon="i-lucide-layers"> | |
| 127 | - <template #title> | |
| 128 | - <NuxtLink to="/catalog/presets" class="text-muted hover:text-default">Presets</NuxtLink> | |
| 129 | - <UIcon name="i-lucide-chevron-right" class="size-4 text-dimmed" /> | |
| 130 | - <span class="truncate">{{ isNew ? 'New' : name }}</span> | |
| 131 | - </template> | |
| 132 | - <template #right> | |
| 133 | - <UBadge v-if="dirty" label="Unsaved" color="warning" variant="subtle" /> | |
| 134 | - <UButton v-if="!isNew" label="Delete" icon="i-lucide-trash-2" color="error" variant="soft" @click="askDelete" /> | |
| 135 | - <UButton label="Save" icon="i-lucide-save" :loading="saving" :disabled="!dirty || !!problems.length" @click="save" /> | |
| 136 | - </template> | |
| 137 | - </UDashboardNavbar> | |
| 138 | - </template> | |
| 139 | - | |
| 140 | - <template #body> | |
| 141 | - <USkeleton v-if="!ready" class="h-72 w-full" /> | |
| 142 | - <UAlert v-else-if="notFound" color="warning" variant="subtle" icon="i-lucide-circle-help" title="Preset not found" description="It may have been renamed or deleted." :actions="[{ label: 'Back to presets', to: '/catalog/presets', color: 'neutral', variant: 'outline' }]" /> | |
| 143 | - | |
| 144 | - <div v-else class="grid items-start gap-6 xl:grid-cols-2"> | |
| 145 | - <div class="min-w-0 space-y-4"> | |
| 146 | - <UAlert v-if="saveError" color="error" variant="subtle" icon="i-lucide-circle-alert" title="Save failed" :description="saveError" class="whitespace-pre-wrap" /> | |
| 147 | - <div class="grid gap-4 sm:grid-cols-2"> | |
| 148 | - <UFormField :label="renaming ? 'Name (renaming)' : 'Name'" required> | |
| 149 | - <UInput v-model="form.name" class="w-full" placeholder="kebab-case-name" :ui="{ base: 'font-mono' }" /> | |
| 150 | - </UFormField> | |
| 151 | - <UFormField label="Description"> | |
| 152 | - <UInput v-model="form.description" class="w-full" /> | |
| 153 | - </UFormField> | |
| 154 | - </div> | |
| 155 | - <UAlert v-if="problems.length && (dirty || !isNew)" color="warning" variant="subtle" icon="i-lucide-triangle-alert" :title="problems.join(' ')" /> | |
| 181 | + <SkPage wide> | |
| 182 | + <CatalogHeader tab="presets" /> | |
| 156 | 183 | |
| 157 | - <UFormField label="Included presets" description="Their items are added to this preset (resolved recursively)."> | |
| 158 | - <USelectMenu v-model="form.includes" :items="includeChoices" multiple placeholder="None" class="w-full" /> | |
| 159 | - </UFormField> | |
| 184 | + <USkeleton v-if="!ready" class="h-72 w-full rounded-[20px]" /> | |
| 185 | + <SkNote v-else-if="notFound" tone="accent"> | |
| 186 | + <p class="m-0 font-medium">Preset not found</p> | |
| 187 | + <p class="mt-1 mb-0 text-xs">It may have been renamed or deleted.</p> | |
| 188 | + <template #aside><SkButton size="sm" label="Back to presets" to="/catalog/presets" /></template> | |
| 189 | + </SkNote> | |
| 160 | 190 | |
| 161 | - <div class="space-y-2"> | |
| 162 | - <h3 class="text-sm font-semibold">Items</h3> | |
| 163 | - <ItemPicker v-model="form.items" :items="catalog.items.value" /> | |
| 164 | - </div> | |
| 165 | - </div> | |
| 191 | + <div v-else class="flex flex-col gap-3.5"> | |
| 192 | + <SkBreadcrumb :items="[{ label: 'Presets', to: '/catalog/presets' }, { label: isNew ? 'New preset' : name! }]" /> | |
| 193 | + | |
| 194 | + <div class="grid items-start gap-4 lg:grid-cols-[minmax(0,1fr)_minmax(260px,320px)]"> | |
| 195 | + <div class="flex min-w-0 flex-col gap-4"> | |
| 196 | + <SkNote v-if="saveError" tone="accent"> | |
| 197 | + <p class="m-0 font-medium">Save failed</p> | |
| 198 | + <p class="mt-1 mb-0 text-xs whitespace-pre-wrap">{{ saveError }}</p> | |
| 199 | + </SkNote> | |
| 200 | + | |
| 201 | + <SkPanel class="grid gap-3.5 px-5 py-[18px] [grid-template-columns:repeat(auto-fit,minmax(220px,1fr))]"> | |
| 202 | + <CatalogField :label="renaming ? 'Name (renaming)' : 'Name'" :error="nameError"> | |
| 203 | + <UInput v-model="form.name" class="w-full" placeholder="e.g. nuxt-saas" :highlight="!!nameError" :color="nameError ? 'error' : undefined" /> | |
| 204 | + </CatalogField> | |
| 205 | + <CatalogField label="Description"> | |
| 206 | + <UInput v-model="form.description" class="w-full" placeholder="What kind of project is this preset for?" /> | |
| 207 | + </CatalogField> | |
| 208 | + </SkPanel> | |
| 166 | 209 | |
| 167 | - <div class="min-w-0 space-y-4"> | |
| 168 | - <section class="space-y-2"> | |
| 169 | - <h3 class="text-sm font-semibold">Resolved content <span class="font-normal text-muted">({{ resolved.entries.length }} items)</span></h3> | |
| 170 | - <UAlert v-if="resolved.missingPresets.length" color="warning" variant="subtle" icon="i-lucide-triangle-alert" :title="`Unknown preset(s): ${resolved.missingPresets.join(', ')}`" /> | |
| 171 | - <UAlert v-if="resolved.cycles.length" color="warning" variant="subtle" icon="i-lucide-repeat" :title="`Include cycle ignored: ${resolved.cycles.join(', ')}`" /> | |
| 172 | - <p v-if="!resolvedByType.length" class="rounded-lg border border-dashed border-default p-6 text-center text-sm text-muted">Nothing selected yet.</p> | |
| 173 | - <div v-for="g in resolvedByType" :key="g.type" class="overflow-hidden rounded-lg border border-default"> | |
| 174 | - <div class="flex items-center gap-2 bg-elevated/40 px-3 py-1.5 text-xs font-medium text-muted"> | |
| 175 | - <UIcon :name="CAT_META[g.type].icon" class="size-3.5" />{{ CAT_META[g.type].label }} ({{ g.rows.length }}) | |
| 176 | - </div> | |
| 177 | - <ul class="divide-y divide-default text-sm"> | |
| 178 | - <li v-for="r in g.rows" :key="r.name" class="flex items-center gap-2 px-3 py-1.5"> | |
| 179 | - <NuxtLink v-if="!missingItems(r.type, r.name)" :to="`/catalog/${r.type}/${encodeURIComponent(r.name)}`" class="truncate hover:underline">{{ r.name }}</NuxtLink> | |
| 180 | - <span v-else class="truncate">{{ r.name }}</span> | |
| 181 | - <UBadge v-if="missingItems(r.type, r.name)" label="missing" size="sm" color="warning" variant="subtle" /> | |
| 182 | - <span class="ml-auto flex shrink-0 gap-1"> | |
| 183 | - <UBadge v-for="v in r.via.filter(v => v !== selfName)" :key="v" :label="`via ${v}`" size="sm" color="neutral" variant="subtle" /> | |
| 184 | - </span> | |
| 185 | - </li> | |
| 186 | - </ul> | |
| 210 | + <SkPanel class="flex flex-col gap-2.5 px-5 py-4"> | |
| 211 | + <div class="flex items-baseline justify-between gap-3"> | |
| 212 | + <span class="font-semibold">Include presets</span> | |
| 213 | + <span class="text-xs text-sk-faint">Their items are added to this preset</span> | |
| 187 | 214 | </div> |
| 188 | - </section> | |
| 189 | - | |
| 190 | - <section v-if="!isNew" class="space-y-1.5"> | |
| 191 | - <h3 class="text-sm font-semibold">Used by</h3> | |
| 192 | - <ul v-if="usedBy.length" class="space-y-1 text-sm"> | |
| 193 | - <li v-for="s in usedBy" :key="s"> | |
| 194 | - <NuxtLink :to="scopeLink(s)" class="flex items-center gap-1.5 text-primary hover:underline"> | |
| 195 | - <UIcon :name="s === 'home' ? 'i-lucide-house' : 'i-lucide-folder'" class="size-4" />{{ scopeLabel(s, scopeNames) }} | |
| 196 | - </NuxtLink> | |
| 197 | - </li> | |
| 198 | - </ul> | |
| 199 | - <p v-else class="text-xs text-muted">Not enabled in any scope.</p> | |
| 200 | - </section> | |
| 215 | + <div v-if="includeChoices.length" class="flex flex-wrap gap-1.5"> | |
| 216 | + <SkChip | |
| 217 | + v-for="c in includeChoices" | |
| 218 | + :key="c.name" | |
| 219 | + :active="form.includes.includes(c.name)" | |
| 220 | + :count="c.count" | |
| 221 | + :disabled="c.blocked" | |
| 222 | + :title="c.blocked ? `${c.name} already includes this preset` : undefined" | |
| 223 | + class="h-7 px-2.5 text-[13px]" | |
| 224 | + @click="toggleInclude(c.name)" | |
| 225 | + >{{ c.name }}</SkChip> | |
| 226 | + </div> | |
| 227 | + <p v-else class="m-0 text-sk-faint">No other preset to include yet.</p> | |
| 228 | + </SkPanel> | |
| 229 | + | |
| 230 | + <ItemPicker v-model="form.items" :items="catalog.items.value" :locked="locked" title="Items" /> | |
| 201 | 231 | </div> |
| 202 | - </div> | |
| 203 | 232 | |
| 204 | - <UModal v-model:open="delOpen" :title="`Delete ${name}`" description="This removes the preset from the catalog."> | |
| 205 | - <template #body> | |
| 206 | - <div class="space-y-3 text-sm"> | |
| 207 | - <p>Items of the preset are kept in the catalog. Scopes that enabled it will report the preset as missing.</p> | |
| 208 | - <UAlert v-if="delConflict || usedBy.length" color="warning" variant="subtle" icon="i-lucide-triangle-alert" :title="delConflict ? 'This preset is used' : 'Used by'"> | |
| 209 | - <template #description> | |
| 210 | - <p v-if="delConflict" class="mb-1">Still enabled in these scopes. If you delete it anyway, they will report it as missing from the catalog.</p> | |
| 211 | - <ul class="list-disc pl-4"><li v-for="s in usedBy" :key="s">{{ scopeLabel(s, scopeNames) }}</li></ul> | |
| 212 | - </template> | |
| 213 | - </UAlert> | |
| 233 | + <!-- Resolved preview --> | |
| 234 | + <SkPanel class="flex flex-col lg:sticky lg:top-[76px]"> | |
| 235 | + <div class="flex flex-col gap-1 border-b border-sk-line2 px-[18px] py-4"> | |
| 236 | + <SkSectionLabel>Resolved preset</SkSectionLabel> | |
| 237 | + <span class="text-[15px] font-semibold" :class="!form.name && 'text-sk-faint'">{{ form.name || 'Untitled preset' }}</span> | |
| 238 | + <span class="text-sk-muted">{{ countText }}</span> | |
| 214 | 239 | </div> |
| 215 | - </template> | |
| 216 | - <template #footer> | |
| 217 | - <div class="flex w-full justify-end gap-2"> | |
| 218 | - <UButton label="Cancel" color="neutral" variant="outline" @click="delOpen = false" /> | |
| 219 | - <UButton :label="delConflict ? 'Delete anyway' : 'Delete'" color="error" :loading="deleting" @click="doDelete(!!delConflict)" /> | |
| 240 | + | |
| 241 | + <div v-if="resolved.missingPresets.length || resolved.cycles.length" class="flex flex-col gap-2 border-b border-sk-line2 px-[18px] py-3"> | |
| 242 | + <SkNote v-if="resolved.missingPresets.length" tone="accent">Unknown preset{{ resolved.missingPresets.length > 1 ? 's' : '' }}: <span class="font-mono text-xs">{{ resolved.missingPresets.join(', ') }}</span></SkNote> | |
| 243 | + <SkNote v-if="resolved.cycles.length" tone="accent">Include cycle ignored: <span class="font-mono text-xs">{{ resolved.cycles.join(', ') }}</span></SkNote> | |
| 220 | 244 | </div> |
| 221 | - </template> | |
| 222 | - </UModal> | |
| 223 | - </template> | |
| 224 | - </UDashboardPanel> | |
| 245 | + | |
| 246 | + <div class="flex max-h-[calc(100vh-380px)] min-h-20 flex-col gap-0.5 overflow-auto px-[18px] py-2.5"> | |
| 247 | + <div v-for="r in resolvedRows" :key="`${r.type}/${r.name}`" class="flex items-baseline gap-2 py-1" :class="!r.own && 'text-sk-muted'"> | |
| 248 | + <NuxtLink | |
| 249 | + v-if="!isMissing(r.type, r.name)" | |
| 250 | + :to="`/catalog/${r.type}/${encodeURIComponent(r.name)}`" | |
| 251 | + class="min-w-0 flex-1 truncate hover:underline" | |
| 252 | + >{{ r.name }}</NuxtLink> | |
| 253 | + <span v-else class="min-w-0 flex-1 truncate">{{ r.name }}</span> | |
| 254 | + <span v-if="isMissing(r.type, r.name)" class="font-mono text-[11px] text-sk-accent-text">missing</span> | |
| 255 | + <span v-if="r.own" class="font-mono text-[11px] text-sk-faint">{{ TYPE_TAG[r.type] }}</span> | |
| 256 | + <span v-else class="font-mono text-[11px] text-sk-faint">via {{ r.from.join(', ') }}</span> | |
| 257 | + </div> | |
| 258 | + <span v-if="!resolvedRows.length" class="py-4 text-sk-faint">Pick items or include a preset.</span> | |
| 259 | + </div> | |
| 260 | + | |
| 261 | + <div v-if="!isNew" class="flex flex-col gap-1.5 border-t border-sk-line2 px-[18px] py-3"> | |
| 262 | + <SkSectionLabel>Used in</SkSectionLabel> | |
| 263 | + <CatalogUsedBy :scopes="usedBy" empty="Not enabled in any project yet" /> | |
| 264 | + </div> | |
| 265 | + | |
| 266 | + <div class="flex flex-col gap-2.5 border-t border-sk-line2 px-[18px] py-3"> | |
| 267 | + <span class="text-xs text-sk-faint">Projects choose automatic or on-demand mode, and can exclude single items.</span> | |
| 268 | + <div class="flex items-center justify-end gap-2"> | |
| 269 | + <SkButton v-if="!isNew" variant="ghost" label="Delete" class="mr-auto" @click="askDelete" /> | |
| 270 | + <SkButton label="Cancel" @click="cancel" /> | |
| 271 | + <SkButton variant="primary" :label="isNew ? 'Create preset' : 'Save changes'" :loading="saving" :disabled="!canSave" @click="save" /> | |
| 272 | + </div> | |
| 273 | + </div> | |
| 274 | + </SkPanel> | |
| 275 | + </div> | |
| 276 | + </div> | |
| 277 | + | |
| 278 | + <CatalogDeleteModal | |
| 279 | + v-model:open="delOpen" | |
| 280 | + kind="preset" | |
| 281 | + :name="name ?? ''" | |
| 282 | + :used-by="usedBy" | |
| 283 | + :conflict="delConflict" | |
| 284 | + :deleting="deleting" | |
| 285 | + @confirm="doDelete" | |
| 286 | + /> | |
| 287 | + </SkPage> | |
| 225 | 288 | </template> |
| @@ -1,12 +1,11 @@ | |||
| 1 | <script setup lang="ts"> | 1 | <script setup lang="ts"> |
| 2 | import type { CatalogItemType, Preset } from '@skilly/core' | 2 | import type { CatalogItemType, Preset } from '@skilly/core' |
| 3 | 3 | ||
| 4 | -/** Preset editor. `name` is undefined for a new preset. */ | 4 | +/** Preset editor: name, description, included presets, items; resolved preview on the right. `name` is undefined for a new preset. */ |
| 5 | const props = defineProps<{ name?: string }>() | 5 | const props = defineProps<{ name?: string }>() |
| 6 | 6 | ||
| 7 | const { request } = useApi() | 7 | const { request } = useApi() |
| 8 | const catalog = useCatalog() | 8 | const catalog = useCatalog() |
| 9 | -const scopeNames = useScopeNames() | ||
| 10 | const toast = useToast() | 9 | const toast = useToast() |
| 11 | 10 | ||
| 12 | const isNew = computed(() => !props.name) | 11 | const isNew = computed(() => !props.name) |
| @@ -39,33 +38,77 @@ async function init() { | |||
| 39 | original.value = snapshot() | 38 | original.value = snapshot() |
| 40 | ready.value = true | 39 | ready.value = true |
| 41 | } | 40 | } |
| 42 | -onMounted(init) | 41 | +onMounted(() => { |
| 42 | + init() | ||
| 43 | + catalog.refresh() | ||
| 44 | +}) | ||
| 43 | 45 | ||
| 44 | -const includeChoices = computed(() => catalog.presets.value.map(p => p.name).filter(n => n !== props.name && n !== form.name)) | ||
| 45 | const renaming = computed(() => !isNew.value && form.name !== props.name) | 46 | const renaming = computed(() => !isNew.value && form.name !== props.name) |
| 47 | +const others = computed(() => catalog.presets.value.filter(p => p.name !== props.name)) | ||
| 46 | 48 | ||
| 47 | -const problems = computed(() => { | 49 | +/** Presets that (transitively) include `target`: including them here would create a cycle. */ |
| 48 | - const p: string[] = [] | 50 | +function dependsOn(p: Preset, target: string, seen = new Set<string>()): boolean { |
| 49 | - if (!NAME_RE.test(form.name)) p.push('Name must be kebab-case: lowercase letters, digits, "." "_" "-".') | 51 | + if (seen.has(p.name)) return false |
| 50 | - else if ((isNew.value || renaming.value) && catalog.presets.value.some(x => x.name === form.name)) p.push(`A preset named "${form.name}" already exists.`) | 52 | + seen.add(p.name) |
| 51 | - return p | 53 | + return p.includes.some(n => n === target || (others.value.find(x => x.name === n) ? dependsOn(others.value.find(x => x.name === n)!, target, seen) : false)) |
| 54 | +} | ||
| 55 | +const includeChoices = computed(() => others.value | ||
| 56 | + .filter(p => p.name !== form.name) | ||
| 57 | + .map(p => ({ | ||
| 58 | + name: p.name, | ||
| 59 | + count: resolveItems(catalog.presets.value, [p.name]).entries.length, | ||
| 60 | + blocked: !!props.name && dependsOn(p, props.name) && !form.includes.includes(p.name) | ||
| 61 | + }))) | ||
| 62 | +function toggleInclude(n: string) { | ||
| 63 | + form.includes = form.includes.includes(n) ? form.includes.filter(x => x !== n) : [...form.includes, n] | ||
| 64 | +} | ||
| 65 | + | ||
| 66 | +const nameError = computed(() => { | ||
| 67 | + if (!form.name) return isNew.value && !dirty.value ? null : 'Give the preset a name.' | ||
| 68 | + if (!NAME_RE.test(form.name)) return 'Use kebab-case: lowercase letters, digits, "." "_" "-", starting with a letter or digit.' | ||
| 69 | + if ((isNew.value || renaming.value) && catalog.presets.value.some(x => x.name === form.name)) return 'A preset with this name already exists.' | ||
| 70 | + return null | ||
| 52 | }) | 71 | }) |
| 72 | +const canSave = computed(() => dirty.value && !!form.name && !nameError.value) | ||
| 53 | 73 | ||
| 74 | +const selfName = computed(() => form.name || props.name || '(this preset)') | ||
| 54 | const resolved = computed(() => resolveItems( | 75 | const resolved = computed(() => resolveItems( |
| 55 | - catalog.presets.value.filter(p => p.name !== props.name), | 76 | + others.value, |
| 56 | [], | 77 | [], |
| 57 | {}, | 78 | {}, |
| 58 | - { name: form.name || props.name || '(this preset)', includes: form.includes, items: form.items } | 79 | + { name: selfName.value, includes: form.includes, items: form.items } |
| 59 | )) | 80 | )) |
| 60 | -const resolvedByType = computed(() => CAT_TYPES.map(t => ({ | 81 | +const inherited = computed(() => resolveItems(others.value, [], {}, { name: selfName.value, includes: form.includes, items: {} })) |
| 61 | - type: t, | 82 | +/** Items provided by an included preset and not picked here: shown checked and locked in the picker. */ |
| 62 | - rows: resolved.value.entries.filter(e => e.type === t).sort((a, b) => a.name.localeCompare(b.name)) | 83 | +const locked = computed(() => { |
| 63 | -})).filter(g => g.rows.length)) | 84 | + const out: Record<string, string> = {} |
| 64 | -const selfName = computed(() => form.name || props.name || '(this preset)') | 85 | + for (const e of inherited.value.entries) { |
| 65 | -const missingItems = (t: CatalogItemType, n: string) => !catalog.findItem(t, n) | 86 | + if (form.items[e.type]?.includes(e.name)) continue |
| 87 | + out[itemKey(e.type, e.name)] = `via ${e.via.filter(v => v !== selfName.value).join(', ')}` | ||
| 88 | + } | ||
| 89 | + return out | ||
| 90 | +}) | ||
| 91 | + | ||
| 92 | +const resolvedRows = computed(() => [...resolved.value.entries] | ||
| 93 | + .sort((a, b) => CAT_ORDER.indexOf(a.type) - CAT_ORDER.indexOf(b.type) || a.name.localeCompare(b.name)) | ||
| 94 | + .map(e => ({ ...e, from: e.via.filter(v => v !== selfName.value), own: e.via.includes(selfName.value) }))) | ||
| 95 | +const PLURAL: Record<CatalogItemType, [string, string]> = { | ||
| 96 | + skill: ['skill', 'skills'], rule: ['rule', 'rules'], mcp: ['MCP server', 'MCP servers'], hook: ['hook', 'hooks'], agent: ['subagent', 'subagents'] | ||
| 97 | +} | ||
| 98 | +const countText = computed(() => { | ||
| 99 | + const n = resolvedRows.value.length | ||
| 100 | + if (!n) return 'Nothing yet' | ||
| 101 | + const parts = CAT_ORDER.map((t) => { | ||
| 102 | + const c = resolvedRows.value.filter(r => r.type === t).length | ||
| 103 | + return c ? `${c} ${PLURAL[t][c === 1 ? 0 : 1]}` : '' | ||
| 104 | + }).filter(Boolean) | ||
| 105 | + return `${n} item${n > 1 ? 's' : ''} · ${parts.join(', ')}` | ||
| 106 | +}) | ||
| 107 | +const isMissing = (t: CatalogItemType, n: string) => !catalog.findItem(t, n) | ||
| 108 | +const usedBy = computed(() => (props.name ? catalog.presetUsedBy(props.name) : [])) | ||
| 66 | 109 | ||
| 67 | async function save() { | 110 | async function save() { |
| 68 | - if (problems.value.length) return | 111 | + if (!canSave.value) return |
| 69 | saving.value = true | 112 | saving.value = true |
| 70 | saveError.value = null | 113 | saveError.value = null |
| 71 | const items: Partial<Record<CatalogItemType, string[]>> = {} | 114 | const items: Partial<Record<CatalogItemType, string[]>> = {} |
| @@ -79,7 +122,13 @@ async function save() { | |||
| 79 | } | 122 | } |
| 80 | try { | 123 | try { |
| 81 | await request(`/api/catalog/presets/${encodeURIComponent(form.name)}`, { method: 'PUT', body, silent: true }) | 124 | await request(`/api/catalog/presets/${encodeURIComponent(form.name)}`, { method: 'PUT', body, silent: true }) |
| 82 | - toast.add({ title: isNew.value ? 'Preset created' : 'Preset saved', description: form.name, icon: 'i-lucide-check', color: 'success' }) | 125 | + const n = usedBy.value.length |
| 126 | + toast.add({ | ||
| 127 | + title: isNew.value ? 'Preset created' : 'Preset saved', | ||
| 128 | + description: !isNew.value && n ? `${form.name} · ${n} project${n > 1 ? 's' : ''} will show an update` : form.name, | ||
| 129 | + icon: 'i-lucide-check', | ||
| 130 | + color: 'success' | ||
| 131 | + }) | ||
| 83 | await catalog.refresh() | 132 | await catalog.refresh() |
| 84 | original.value = snapshot() | 133 | original.value = snapshot() |
| 85 | if (isNew.value || renaming.value) await navigateTo(`/catalog/presets/${encodeURIComponent(form.name)}`, { replace: true }) | 134 | if (isNew.value || renaming.value) await navigateTo(`/catalog/presets/${encodeURIComponent(form.name)}`, { replace: true }) |
| @@ -90,9 +139,17 @@ async function save() { | |||
| 90 | } | 139 | } |
| 91 | } | 140 | } |
| 92 | 141 | ||
| 142 | +function cancel() { | ||
| 143 | + navigateTo('/catalog/presets') | ||
| 144 | +} | ||
| 145 | + | ||
| 93 | onBeforeRouteLeave(() => !dirty.value || window.confirm('Discard unsaved changes?')) | 146 | onBeforeRouteLeave(() => !dirty.value || window.confirm('Discard unsaved changes?')) |
| 147 | +function beforeUnload(e: BeforeUnloadEvent) { | ||
| 148 | + if (dirty.value) e.preventDefault() | ||
| 149 | +} | ||
| 150 | +onMounted(() => window.addEventListener('beforeunload', beforeUnload)) | ||
| 151 | +onBeforeUnmount(() => window.removeEventListener('beforeunload', beforeUnload)) | ||
| 94 | 152 | ||
| 95 | -const usedBy = computed(() => (props.name ? catalog.presetUsedBy(props.name) : [])) | ||
| 96 | const delOpen = ref(false) | 153 | const delOpen = ref(false) |
| 97 | const deleting = ref(false) | 154 | const deleting = ref(false) |
| 98 | const delConflict = ref<string | null>(null) | 155 | const delConflict = ref<string | null>(null) |
| @@ -121,105 +178,111 @@ function askDelete() { | |||
| 121 | </script> | 178 | </script> |
| 122 | 179 | ||
| 123 | <template> | 180 | <template> |
| 124 | - <UDashboardPanel id="preset-editor"> | 181 | + <SkPage wide> |
| 125 | - <template #header> | 182 | + <CatalogHeader tab="presets" /> |
| 126 | - <UDashboardNavbar icon="i-lucide-layers"> | ||
| 127 | - <template #title> | ||
| 128 | - <NuxtLink to="/catalog/presets" class="text-muted hover:text-default">Presets</NuxtLink> | ||
| 129 | - <UIcon name="i-lucide-chevron-right" class="size-4 text-dimmed" /> | ||
| 130 | - <span class="truncate">{{ isNew ? 'New' : name }}</span> | ||
| 131 | - </template> | ||
| 132 | - <template #right> | ||
| 133 | - <UBadge v-if="dirty" label="Unsaved" color="warning" variant="subtle" /> | ||
| 134 | - <UButton v-if="!isNew" label="Delete" icon="i-lucide-trash-2" color="error" variant="soft" @click="askDelete" /> | ||
| 135 | - <UButton label="Save" icon="i-lucide-save" :loading="saving" :disabled="!dirty || !!problems.length" @click="save" /> | ||
| 136 | - </template> | ||
| 137 | - </UDashboardNavbar> | ||
| 138 | - </template> | ||
| 139 | - | ||
| 140 | - <template #body> | ||
| 141 | - <USkeleton v-if="!ready" class="h-72 w-full" /> | ||
| 142 | - <UAlert v-else-if="notFound" color="warning" variant="subtle" icon="i-lucide-circle-help" title="Preset not found" description="It may have been renamed or deleted." :actions="[{ label: 'Back to presets', to: '/catalog/presets', color: 'neutral', variant: 'outline' }]" /> | ||
| 143 | - | ||
| 144 | - <div v-else class="grid items-start gap-6 xl:grid-cols-2"> | ||
| 145 | - <div class="min-w-0 space-y-4"> | ||
| 146 | - <UAlert v-if="saveError" color="error" variant="subtle" icon="i-lucide-circle-alert" title="Save failed" :description="saveError" class="whitespace-pre-wrap" /> | ||
| 147 | - <div class="grid gap-4 sm:grid-cols-2"> | ||
| 148 | - <UFormField :label="renaming ? 'Name (renaming)' : 'Name'" required> | ||
| 149 | - <UInput v-model="form.name" class="w-full" placeholder="kebab-case-name" :ui="{ base: 'font-mono' }" /> | ||
| 150 | - </UFormField> | ||
| 151 | - <UFormField label="Description"> | ||
| 152 | - <UInput v-model="form.description" class="w-full" /> | ||
| 153 | - </UFormField> | ||
| 154 | - </div> | ||
| 155 | - <UAlert v-if="problems.length && (dirty || !isNew)" color="warning" variant="subtle" icon="i-lucide-triangle-alert" :title="problems.join(' ')" /> | ||
| 156 | 183 | ||
| 157 | - <UFormField label="Included presets" description="Their items are added to this preset (resolved recursively)."> | 184 | + <USkeleton v-if="!ready" class="h-72 w-full rounded-[20px]" /> |
| 158 | - <USelectMenu v-model="form.includes" :items="includeChoices" multiple placeholder="None" class="w-full" /> | 185 | + <SkNote v-else-if="notFound" tone="accent"> |
| 159 | - </UFormField> | 186 | + <p class="m-0 font-medium">Preset not found</p> |
| 187 | + <p class="mt-1 mb-0 text-xs">It may have been renamed or deleted.</p> | ||
| 188 | + <template #aside><SkButton size="sm" label="Back to presets" to="/catalog/presets" /></template> | ||
| 189 | + </SkNote> | ||
| 160 | 190 | ||
| 161 | - <div class="space-y-2"> | 191 | + <div v-else class="flex flex-col gap-3.5"> |
| 162 | - <h3 class="text-sm font-semibold">Items</h3> | 192 | + <SkBreadcrumb :items="[{ label: 'Presets', to: '/catalog/presets' }, { label: isNew ? 'New preset' : name! }]" /> |
| 163 | - <ItemPicker v-model="form.items" :items="catalog.items.value" /> | 193 | + |
| 164 | - </div> | 194 | + <div class="grid items-start gap-4 lg:grid-cols-[minmax(0,1fr)_minmax(260px,320px)]"> |
| 165 | - </div> | 195 | + <div class="flex min-w-0 flex-col gap-4"> |
| 196 | + <SkNote v-if="saveError" tone="accent"> | ||
| 197 | + <p class="m-0 font-medium">Save failed</p> | ||
| 198 | + <p class="mt-1 mb-0 text-xs whitespace-pre-wrap">{{ saveError }}</p> | ||
| 199 | + </SkNote> | ||
| 200 | + | ||
| 201 | + <SkPanel class="grid gap-3.5 px-5 py-[18px] [grid-template-columns:repeat(auto-fit,minmax(220px,1fr))]"> | ||
| 202 | + <CatalogField :label="renaming ? 'Name (renaming)' : 'Name'" :error="nameError"> | ||
| 203 | + <UInput v-model="form.name" class="w-full" placeholder="e.g. nuxt-saas" :highlight="!!nameError" :color="nameError ? 'error' : undefined" /> | ||
| 204 | + </CatalogField> | ||
| 205 | + <CatalogField label="Description"> | ||
| 206 | + <UInput v-model="form.description" class="w-full" placeholder="What kind of project is this preset for?" /> | ||
| 207 | + </CatalogField> | ||
| 208 | + </SkPanel> | ||
| 166 | 209 | ||
| 167 | - <div class="min-w-0 space-y-4"> | 210 | + <SkPanel class="flex flex-col gap-2.5 px-5 py-4"> |
| 168 | - <section class="space-y-2"> | 211 | + <div class="flex items-baseline justify-between gap-3"> |
| 169 | - <h3 class="text-sm font-semibold">Resolved content <span class="font-normal text-muted">({{ resolved.entries.length }} items)</span></h3> | 212 | + <span class="font-semibold">Include presets</span> |
| 170 | - <UAlert v-if="resolved.missingPresets.length" color="warning" variant="subtle" icon="i-lucide-triangle-alert" :title="`Unknown preset(s): ${resolved.missingPresets.join(', ')}`" /> | 213 | + <span class="text-xs text-sk-faint">Their items are added to this preset</span> |
| 171 | - <UAlert v-if="resolved.cycles.length" color="warning" variant="subtle" icon="i-lucide-repeat" :title="`Include cycle ignored: ${resolved.cycles.join(', ')}`" /> | ||
| 172 | - <p v-if="!resolvedByType.length" class="rounded-lg border border-dashed border-default p-6 text-center text-sm text-muted">Nothing selected yet.</p> | ||
| 173 | - <div v-for="g in resolvedByType" :key="g.type" class="overflow-hidden rounded-lg border border-default"> | ||
| 174 | - <div class="flex items-center gap-2 bg-elevated/40 px-3 py-1.5 text-xs font-medium text-muted"> | ||
| 175 | - <UIcon :name="CAT_META[g.type].icon" class="size-3.5" />{{ CAT_META[g.type].label }} ({{ g.rows.length }}) | ||
| 176 | - </div> | ||
| 177 | - <ul class="divide-y divide-default text-sm"> | ||
| 178 | - <li v-for="r in g.rows" :key="r.name" class="flex items-center gap-2 px-3 py-1.5"> | ||
| 179 | - <NuxtLink v-if="!missingItems(r.type, r.name)" :to="`/catalog/${r.type}/${encodeURIComponent(r.name)}`" class="truncate hover:underline">{{ r.name }}</NuxtLink> | ||
| 180 | - <span v-else class="truncate">{{ r.name }}</span> | ||
| 181 | - <UBadge v-if="missingItems(r.type, r.name)" label="missing" size="sm" color="warning" variant="subtle" /> | ||
| 182 | - <span class="ml-auto flex shrink-0 gap-1"> | ||
| 183 | - <UBadge v-for="v in r.via.filter(v => v !== selfName)" :key="v" :label="`via ${v}`" size="sm" color="neutral" variant="subtle" /> | ||
| 184 | - </span> | ||
| 185 | - </li> | ||
| 186 | - </ul> | ||
| 187 | </div> | 214 | </div> |
| 188 | - </section> | 215 | + <div v-if="includeChoices.length" class="flex flex-wrap gap-1.5"> |
| 189 | - | 216 | + <SkChip |
| 190 | - <section v-if="!isNew" class="space-y-1.5"> | 217 | + v-for="c in includeChoices" |
| 191 | - <h3 class="text-sm font-semibold">Used by</h3> | 218 | + :key="c.name" |
| 192 | - <ul v-if="usedBy.length" class="space-y-1 text-sm"> | 219 | + :active="form.includes.includes(c.name)" |
| 193 | - <li v-for="s in usedBy" :key="s"> | 220 | + :count="c.count" |
| 194 | - <NuxtLink :to="scopeLink(s)" class="flex items-center gap-1.5 text-primary hover:underline"> | 221 | + :disabled="c.blocked" |
| 195 | - <UIcon :name="s === 'home' ? 'i-lucide-house' : 'i-lucide-folder'" class="size-4" />{{ scopeLabel(s, scopeNames) }} | 222 | + :title="c.blocked ? `${c.name} already includes this preset` : undefined" |
| 196 | - </NuxtLink> | 223 | + class="h-7 px-2.5 text-[13px]" |
| 197 | - </li> | 224 | + @click="toggleInclude(c.name)" |
| 198 | - </ul> | 225 | + >{{ c.name }}</SkChip> |
| 199 | - <p v-else class="text-xs text-muted">Not enabled in any scope.</p> | 226 | + </div> |
| 200 | - </section> | 227 | + <p v-else class="m-0 text-sk-faint">No other preset to include yet.</p> |
| 228 | + </SkPanel> | ||
| 229 | + | ||
| 230 | + <ItemPicker v-model="form.items" :items="catalog.items.value" :locked="locked" title="Items" /> | ||
| 201 | </div> | 231 | </div> |
| 202 | - </div> | ||
| 203 | 232 | ||
| 204 | - <UModal v-model:open="delOpen" :title="`Delete ${name}`" description="This removes the preset from the catalog."> | 233 | + <!-- Resolved preview --> |
| 205 | - <template #body> | 234 | + <SkPanel class="flex flex-col lg:sticky lg:top-[76px]"> |
| 206 | - <div class="space-y-3 text-sm"> | 235 | + <div class="flex flex-col gap-1 border-b border-sk-line2 px-[18px] py-4"> |
| 207 | - <p>Items of the preset are kept in the catalog. Scopes that enabled it will report the preset as missing.</p> | 236 | + <SkSectionLabel>Resolved preset</SkSectionLabel> |
| 208 | - <UAlert v-if="delConflict || usedBy.length" color="warning" variant="subtle" icon="i-lucide-triangle-alert" :title="delConflict ? 'This preset is used' : 'Used by'"> | 237 | + <span class="text-[15px] font-semibold" :class="!form.name && 'text-sk-faint'">{{ form.name || 'Untitled preset' }}</span> |
| 209 | - <template #description> | 238 | + <span class="text-sk-muted">{{ countText }}</span> |
| 210 | - <p v-if="delConflict" class="mb-1">Still enabled in these scopes. If you delete it anyway, they will report it as missing from the catalog.</p> | ||
| 211 | - <ul class="list-disc pl-4"><li v-for="s in usedBy" :key="s">{{ scopeLabel(s, scopeNames) }}</li></ul> | ||
| 212 | - </template> | ||
| 213 | - </UAlert> | ||
| 214 | </div> | 239 | </div> |
| 215 | - </template> | 240 | + |
| 216 | - <template #footer> | 241 | + <div v-if="resolved.missingPresets.length || resolved.cycles.length" class="flex flex-col gap-2 border-b border-sk-line2 px-[18px] py-3"> |
| 217 | - <div class="flex w-full justify-end gap-2"> | 242 | + <SkNote v-if="resolved.missingPresets.length" tone="accent">Unknown preset{{ resolved.missingPresets.length > 1 ? 's' : '' }}: <span class="font-mono text-xs">{{ resolved.missingPresets.join(', ') }}</span></SkNote> |
| 218 | - <UButton label="Cancel" color="neutral" variant="outline" @click="delOpen = false" /> | 243 | + <SkNote v-if="resolved.cycles.length" tone="accent">Include cycle ignored: <span class="font-mono text-xs">{{ resolved.cycles.join(', ') }}</span></SkNote> |
| 219 | - <UButton :label="delConflict ? 'Delete anyway' : 'Delete'" color="error" :loading="deleting" @click="doDelete(!!delConflict)" /> | ||
| 220 | </div> | 244 | </div> |
| 221 | - </template> | 245 | + |
| 222 | - </UModal> | 246 | + <div class="flex max-h-[calc(100vh-380px)] min-h-20 flex-col gap-0.5 overflow-auto px-[18px] py-2.5"> |
| 223 | - </template> | 247 | + <div v-for="r in resolvedRows" :key="`${r.type}/${r.name}`" class="flex items-baseline gap-2 py-1" :class="!r.own && 'text-sk-muted'"> |
| 224 | - </UDashboardPanel> | 248 | + <NuxtLink |
| 249 | + v-if="!isMissing(r.type, r.name)" | ||
| 250 | + :to="`/catalog/${r.type}/${encodeURIComponent(r.name)}`" | ||
| 251 | + class="min-w-0 flex-1 truncate hover:underline" | ||
| 252 | + >{{ r.name }}</NuxtLink> | ||
| 253 | + <span v-else class="min-w-0 flex-1 truncate">{{ r.name }}</span> | ||
| 254 | + <span v-if="isMissing(r.type, r.name)" class="font-mono text-[11px] text-sk-accent-text">missing</span> | ||
| 255 | + <span v-if="r.own" class="font-mono text-[11px] text-sk-faint">{{ TYPE_TAG[r.type] }}</span> | ||
| 256 | + <span v-else class="font-mono text-[11px] text-sk-faint">via {{ r.from.join(', ') }}</span> | ||
| 257 | + </div> | ||
| 258 | + <span v-if="!resolvedRows.length" class="py-4 text-sk-faint">Pick items or include a preset.</span> | ||
| 259 | + </div> | ||
| 260 | + | ||
| 261 | + <div v-if="!isNew" class="flex flex-col gap-1.5 border-t border-sk-line2 px-[18px] py-3"> | ||
| 262 | + <SkSectionLabel>Used in</SkSectionLabel> | ||
| 263 | + <CatalogUsedBy :scopes="usedBy" empty="Not enabled in any project yet" /> | ||
| 264 | + </div> | ||
| 265 | + | ||
| 266 | + <div class="flex flex-col gap-2.5 border-t border-sk-line2 px-[18px] py-3"> | ||
| 267 | + <span class="text-xs text-sk-faint">Projects choose automatic or on-demand mode, and can exclude single items.</span> | ||
| 268 | + <div class="flex items-center justify-end gap-2"> | ||
| 269 | + <SkButton v-if="!isNew" variant="ghost" label="Delete" class="mr-auto" @click="askDelete" /> | ||
| 270 | + <SkButton label="Cancel" @click="cancel" /> | ||
| 271 | + <SkButton variant="primary" :label="isNew ? 'Create preset' : 'Save changes'" :loading="saving" :disabled="!canSave" @click="save" /> | ||
| 272 | + </div> | ||
| 273 | + </div> | ||
| 274 | + </SkPanel> | ||
| 275 | + </div> | ||
| 276 | + </div> | ||
| 277 | + | ||
| 278 | + <CatalogDeleteModal | ||
| 279 | + v-model:open="delOpen" | ||
| 280 | + kind="preset" | ||
| 281 | + :name="name ?? ''" | ||
| 282 | + :used-by="usedBy" | ||
| 283 | + :conflict="delConflict" | ||
| 284 | + :deleting="deleting" | ||
| 285 | + @confirm="doDelete" | ||
| 286 | + /> | ||
| 287 | + </SkPage> | ||
| 225 | </template> | 288 | </template> |
modified
packages/ui/app/components/RemoveFromGlobalModal.vue +99 -56 | @@ -1,50 +1,77 @@ | ||
| 1 | 1 | <script setup lang="ts"> |
| 2 | -import type { InventoryItem, RemoveFromGlobalResult } from '@skilly/core' | |
| 2 | +import type { InventoryItem, ItemStatus, RemoveFromGlobalResult } from '@skilly/core' | |
| 3 | 3 | |
| 4 | -const props = defineProps<{ items: InventoryItem[] }>() | |
| 4 | +/** | |
| 5 | + * "Remove {name} from global" (mockup 7): 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. | |
| 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). | |
| 10 | + */ | |
| 11 | +const props = defineProps<{ items: InventoryItem[], statuses: Record<string, ItemStatus> }>() | |
| 5 | 12 | const open = defineModel<boolean>('open', { default: false }) |
| 6 | 13 | const emit = defineEmits<{ done: [] }>() |
| 7 | 14 | |
| 8 | 15 | const { request } = useApi() |
| 9 | 16 | const { projects } = useProjects() |
| 17 | +const catalog = useCatalog() | |
| 10 | 18 | const toast = useToast() |
| 19 | +const { home } = useHomeInventory() | |
| 11 | 20 | |
| 12 | 21 | const chosen = ref<string[]>([]) |
| 13 | -const search = ref('') | |
| 14 | 22 | const busy = ref(false) |
| 15 | 23 | const result = ref<RemoveFromGlobalResult | null>(null) |
| 16 | 24 | const error = ref<string | null>(null) |
| 17 | 25 | |
| 18 | -const list = computed(() => { | |
| 19 | - const q = search.value.trim().toLowerCase() | |
| 20 | - return projects.value.filter(p => p.exists && (!q || `${p.name} ${p.path}`.toLowerCase().includes(q))) | |
| 21 | -}) | |
| 26 | +const live = computed(() => projects.value.filter(p => p.exists)) | |
| 27 | +const stateOf = (i: InventoryItem) => props.statuses[i.id]?.state | |
| 28 | +const blocked = computed(() => props.items.filter(i => stateOf(i) === 'differs-from-catalog')) | |
| 29 | +const movable = computed(() => props.items.filter(i => stateOf(i) !== 'differs-from-catalog')) | |
| 30 | +const root = computed(() => home.value?.root ?? '') | |
| 31 | + | |
| 32 | +const FOLDERS: Record<string, string> = { skill: 'skills', rule: 'rules', agent: 'agents' } | |
| 33 | +const folderOf = (i: InventoryItem) => FOLDERS[i.type] ?? i.type | |
| 34 | +function catalogLine(i: InventoryItem): string { | |
| 35 | + const st = stateOf(i) | |
| 36 | + if (st === 'in-sync') return 'Already in the catalog. The catalog version will be used.' | |
| 37 | + if (st === 'differs-from-catalog') return `A different ${i.type} named ${i.name} is already in the catalog. Add this one to the catalog with "overwrite" first, then move it.` | |
| 38 | + return `It will be copied to the catalog as ${folderOf(i)}/${i.name}.` | |
| 39 | +} | |
| 22 | 40 | |
| 23 | 41 | watch(open, (v) => { |
| 24 | - if (v) { | |
| 25 | - chosen.value = [] | |
| 26 | - search.value = '' | |
| 27 | - result.value = null | |
| 28 | - error.value = null | |
| 42 | + if (!v) return | |
| 43 | + result.value = null | |
| 44 | + error.value = null | |
| 45 | + // Pre-check the projects that already use the item. | |
| 46 | + const using = new Set<string>() | |
| 47 | + for (const i of props.items) { | |
| 48 | + const ref = props.statuses[i.id]?.catalogRef ?? { type: i.type, name: i.name } | |
| 49 | + for (const id of catalog.usedBy(ref.type, ref.name)) if (id !== 'home') using.add(id) | |
| 29 | 50 | } |
| 51 | + chosen.value = live.value.filter(p => using.has(p.id)).map(p => p.id) | |
| 30 | 52 | }) |
| 31 | 53 | |
| 32 | 54 | function toggle(id: string, on: boolean | 'indeterminate') { |
| 33 | 55 | chosen.value = on === true ? [...new Set([...chosen.value, id])] : chosen.value.filter(x => x !== id) |
| 34 | 56 | } |
| 35 | 57 | |
| 58 | +const title = computed(() => (props.items.length === 1 ? `Remove ${props.items[0]!.name} from global` : `Remove ${props.items.length} items from global`)) | |
| 59 | + | |
| 36 | 60 | async function submit() { |
| 37 | 61 | busy.value = true |
| 38 | 62 | error.value = null |
| 39 | 63 | try { |
| 40 | - result.value = await request<RemoveFromGlobalResult>('/api/home/remove-from-global', { | |
| 64 | + const res = await request<RemoveFromGlobalResult>('/api/home/remove-from-global', { | |
| 41 | 65 | method: 'POST', |
| 42 | - body: { itemIds: props.items.map(i => i.id), enableIn: chosen.value }, | |
| 66 | + body: { itemIds: movable.value.map(i => i.id), enableIn: chosen.value }, | |
| 43 | 67 | silent: true |
| 44 | 68 | }) |
| 69 | + result.value = res | |
| 70 | + const first = props.items.find(i => i.id === res.removed[0]) | |
| 45 | 71 | toast.add({ |
| 46 | - title: `${result.value.removed.length} removed from Home`, | |
| 47 | - color: result.value.errors.length ? 'warning' : 'success', | |
| 72 | + title: res.removed.length === 1 && first ? `${first.name} removed from global` : `${res.removed.length} item${res.removed.length === 1 ? '' : 's'} removed from global`, | |
| 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', | |
| 48 | 75 | icon: 'i-lucide-check' |
| 49 | 76 | }) |
| 50 | 77 | emit('done') |
| @@ -59,55 +86,71 @@ const nameOf = (id: string) => props.items.find(i => i.id === id)?.name ?? id | ||
| 59 | 86 | </script> |
| 60 | 87 | |
| 61 | 88 | <template> |
| 62 | - <UModal v-model:open="open" title="Remove from global" description="Move these items out of Home and into the catalog." :ui="{ content: 'max-w-xl' }"> | |
| 63 | - <template #body> | |
| 64 | - <div v-if="result" class="space-y-3 text-sm"> | |
| 65 | - <UAlert v-if="result.removed.length" color="success" variant="subtle" icon="i-lucide-check" :title="`${result.removed.length} item(s) removed from Home`" :description="result.removed.map(nameOf).join(', ')" /> | |
| 66 | - <UAlert v-if="result.imported.length" color="info" variant="subtle" icon="i-lucide-library" :title="`${result.imported.length} item(s) in the catalog`" :description="result.imported.map(i => `${i.type}/${i.name}`).join(', ')" /> | |
| 67 | - <UAlert v-if="result.errors.length" color="error" variant="subtle" icon="i-lucide-circle-alert" :title="`${result.errors.length} error(s)`"> | |
| 68 | - <template #description> | |
| 69 | - <ul class="list-disc pl-4"><li v-for="e in result.errors" :key="e.id"><span class="font-medium">{{ nameOf(e.id) }}</span>: {{ e.message }}</li></ul> | |
| 70 | - </template> | |
| 71 | - </UAlert> | |
| 72 | - </div> | |
| 73 | - <div v-else class="space-y-4"> | |
| 74 | - <UAlert v-if="error" color="error" variant="subtle" icon="i-lucide-circle-alert" title="Request failed" :description="error" /> | |
| 75 | - <ol class="list-decimal space-y-1 pl-5 text-sm text-muted"> | |
| 76 | - <li>Each item is <strong class="text-default">copied to the catalog</strong> (unless an identical one is already there).</li> | |
| 77 | - <li>It is <strong class="text-default">enabled in the projects you choose</strong> below and written into them.</li> | |
| 78 | - <li>It is then <strong class="text-default">removed from Home</strong>. Symbolic links are only unlinked: their target is never deleted.</li> | |
| 79 | - </ol> | |
| 80 | - | |
| 81 | - <div> | |
| 82 | - <p class="mb-1 text-xs font-medium text-muted">Items ({{ items.length }})</p> | |
| 83 | - <ul class="max-h-28 divide-y divide-default overflow-auto rounded-lg border border-default text-sm"> | |
| 84 | - <li v-for="i in items" :key="i.id" class="flex items-center gap-2 px-3 py-1"> | |
| 85 | - <AgentBadge :agent="i.agent" compact /><span class="truncate font-medium">{{ i.name }}</span><span class="text-xs text-muted">{{ i.type }}</span> | |
| 86 | - <UBadge v-if="i.source.symlinkTarget" label="symlink" size="sm" color="neutral" variant="subtle" class="ml-auto" /> | |
| 89 | + <SkModal v-model:open="open" :title="title" :description="result ? undefined : 'It will stop loading in every project, and only run where you enable it.'"> | |
| 90 | + <!-- Result --> | |
| 91 | + <div v-if="result" class="flex flex-col gap-3"> | |
| 92 | + <SkNote v-if="result.removed.length" tone="ok"> | |
| 93 | + <span class="font-medium">{{ result.removed.length }} removed from Home</span> | |
| 94 | + <span class="block font-mono text-xs text-sk-muted">{{ result.removed.map(nameOf).join(', ') }}</span> | |
| 95 | + </SkNote> | |
| 96 | + <SkNote v-if="result.imported.length" tone="info"> | |
| 97 | + <span class="font-medium">In the catalog</span> | |
| 98 | + <span class="block font-mono text-xs text-sk-muted">{{ result.imported.map(i => `${i.type}/${i.name}`).join(', ') }}</span> | |
| 99 | + </SkNote> | |
| 100 | + <SkNote v-for="e in result.errors" :key="e.id" tone="accent"><span class="font-medium">{{ nameOf(e.id) }}</span>: {{ e.message }}</SkNote> | |
| 101 | + </div> | |
| 102 | + | |
| 103 | + <div v-else class="flex flex-col gap-5"> | |
| 104 | + <SkNote v-if="error" tone="accent"><span class="font-medium">Request failed.</span> {{ error }}</SkNote> | |
| 105 | + | |
| 106 | + <!-- 1 Catalog --> | |
| 107 | + <div class="flex gap-3"> | |
| 108 | + <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">1</span> | |
| 109 | + <div class="flex min-w-0 flex-1 flex-col gap-1"> | |
| 110 | + <span class="font-medium">Catalog</span> | |
| 111 | + <template v-if="items.length === 1"><span class="text-sk-muted" :class="blocked.length ? 'text-sk-accent-text' : ''">{{ catalogLine(items[0]!) }}</span></template> | |
| 112 | + <ul v-else class="m-0 flex list-none flex-col gap-1 p-0"> | |
| 113 | + <li v-for="i in items" :key="i.id" class="text-xs" :class="stateOf(i) === 'differs-from-catalog' ? 'text-sk-accent-text' : 'text-sk-muted'"> | |
| 114 | + <span class="font-medium text-sk-text">{{ i.name }}</span> · {{ catalogLine(i) }} | |
| 87 | 115 | </li> |
| 88 | 116 | </ul> |
| 89 | 117 | </div> |
| 118 | + </div> | |
| 90 | 119 | |
| 91 | - <div class="space-y-1.5"> | |
| 92 | - <p class="text-xs font-medium text-muted">Enable in these projects ({{ chosen.length }} selected)</p> | |
| 93 | - <UInput v-if="projects.length > 6" v-model="search" icon="i-lucide-search" size="sm" placeholder="Search projects..." class="w-full" /> | |
| 94 | - <ul v-if="list.length" class="max-h-48 divide-y divide-default overflow-auto rounded-lg border border-default text-sm"> | |
| 95 | - <li v-for="p in list" :key="p.id" class="flex items-center gap-2 px-3 py-1.5 hover:bg-elevated/50"> | |
| 120 | + <!-- 2 Projects --> | |
| 121 | + <div class="flex gap-3"> | |
| 122 | + <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">2</span> | |
| 123 | + <div class="flex min-w-0 flex-1 flex-col gap-1.5"> | |
| 124 | + <span class="font-medium">Enable in projects</span> | |
| 125 | + <div v-if="live.length" class="max-h-48 overflow-y-auto rounded-[10px] border border-sk-line"> | |
| 126 | + <label v-for="p in live" :key="p.id" class="flex cursor-pointer items-center gap-2.5 border-t border-sk-line2 px-3 py-2 first:border-t-0 hover:bg-sk-hover"> | |
| 96 | 127 | <UCheckbox :model-value="chosen.includes(p.id)" :aria-label="p.name" @update:model-value="(v) => toggle(p.id, v)" /> |
| 97 | - <span class="font-medium">{{ p.name }}</span> | |
| 98 | - <span class="truncate font-mono text-xs text-muted" :title="p.path">{{ p.path }}</span> | |
| 128 | + <span class="shrink-0">{{ p.name }}</span> | |
| 129 | + <span class="ml-auto min-w-0 truncate font-mono text-[11px] text-sk-faint" :title="p.path">{{ p.path }}</span> | |
| 130 | + </label> | |
| 131 | + </div> | |
| 132 | + <p v-else class="m-0 text-sk-muted">No project yet: the item will only move to the catalog.</p> | |
| 133 | + <p v-if="live.length && !chosen.length" class="m-0 text-xs text-sk-accent-text">No project selected: the item will only be available from the catalog.</p> | |
| 134 | + </div> | |
| 135 | + </div> | |
| 136 | + | |
| 137 | + <!-- 3 Home --> | |
| 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> | |
| 140 | + <div class="flex min-w-0 flex-1 flex-col gap-1"> | |
| 141 | + <span class="font-medium">Remove from Home</span> | |
| 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"> | |
| 144 | + {{ itemLocation(i, root, '~/') }} <span class="font-sans">will be {{ i.source.symlinkTarget ? 'unlinked (only the link, never its target)' : 'removed' }}</span> | |
| 99 | 145 | </li> |
| 100 | 146 | </ul> |
| 101 | - <p v-else class="rounded-lg border border-dashed border-default p-4 text-center text-xs text-muted">No project available. The items will only be moved to the catalog.</p> | |
| 102 | - <p v-if="!chosen.length" class="text-xs text-warning">No project selected: the items will disappear from Home and be available in the catalog only.</p> | |
| 103 | 147 | </div> |
| 104 | 148 | </div> |
| 105 | - </template> | |
| 149 | + </div> | |
| 150 | + | |
| 106 | 151 | <template #footer> |
| 107 | - <div class="flex w-full justify-end gap-2"> | |
| 108 | - <UButton :label="result ? 'Close' : 'Cancel'" color="neutral" variant="outline" @click="open = false" /> | |
| 109 | - <UButton v-if="!result" label="Remove from global" icon="i-lucide-globe-lock" color="error" :loading="busy" @click="submit" /> | |
| 110 | - </div> | |
| 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" /> | |
| 111 | 154 | </template> |
| 112 | - </UModal> | |
| 155 | + </SkModal> | |
| 113 | 156 | </template> |
| @@ -1,50 +1,77 @@ | |||
| 1 | <script setup lang="ts"> | 1 | <script setup lang="ts"> |
| 2 | -import type { InventoryItem, RemoveFromGlobalResult } from '@skilly/core' | 2 | +import type { InventoryItem, ItemStatus, RemoveFromGlobalResult } from '@skilly/core' |
| 3 | 3 | ||
| 4 | -const props = defineProps<{ items: InventoryItem[] }>() | 4 | +/** |
| 5 | + * "Remove {name} from global" (mockup 7): 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. | ||
| 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). | ||
| 10 | + */ | ||
| 11 | +const props = defineProps<{ items: InventoryItem[], statuses: Record<string, ItemStatus> }>() | ||
| 5 | const open = defineModel<boolean>('open', { default: false }) | 12 | const open = defineModel<boolean>('open', { default: false }) |
| 6 | const emit = defineEmits<{ done: [] }>() | 13 | const emit = defineEmits<{ done: [] }>() |
| 7 | 14 | ||
| 8 | const { request } = useApi() | 15 | const { request } = useApi() |
| 9 | const { projects } = useProjects() | 16 | const { projects } = useProjects() |
| 17 | +const catalog = useCatalog() | ||
| 10 | const toast = useToast() | 18 | const toast = useToast() |
| 19 | +const { home } = useHomeInventory() | ||
| 11 | 20 | ||
| 12 | const chosen = ref<string[]>([]) | 21 | const chosen = ref<string[]>([]) |
| 13 | -const search = ref('') | ||
| 14 | const busy = ref(false) | 22 | const busy = ref(false) |
| 15 | const result = ref<RemoveFromGlobalResult | null>(null) | 23 | const result = ref<RemoveFromGlobalResult | null>(null) |
| 16 | const error = ref<string | null>(null) | 24 | const error = ref<string | null>(null) |
| 17 | 25 | ||
| 18 | -const list = computed(() => { | 26 | +const live = computed(() => projects.value.filter(p => p.exists)) |
| 19 | - const q = search.value.trim().toLowerCase() | 27 | +const stateOf = (i: InventoryItem) => props.statuses[i.id]?.state |
| 20 | - return projects.value.filter(p => p.exists && (!q || `${p.name} ${p.path}`.toLowerCase().includes(q))) | 28 | +const blocked = computed(() => props.items.filter(i => stateOf(i) === 'differs-from-catalog')) |
| 21 | -}) | 29 | +const movable = computed(() => props.items.filter(i => stateOf(i) !== 'differs-from-catalog')) |
| 30 | +const root = computed(() => home.value?.root ?? '') | ||
| 31 | + | ||
| 32 | +const FOLDERS: Record<string, string> = { skill: 'skills', rule: 'rules', agent: 'agents' } | ||
| 33 | +const folderOf = (i: InventoryItem) => FOLDERS[i.type] ?? i.type | ||
| 34 | +function catalogLine(i: InventoryItem): string { | ||
| 35 | + const st = stateOf(i) | ||
| 36 | + if (st === 'in-sync') return 'Already in the catalog. The catalog version will be used.' | ||
| 37 | + if (st === 'differs-from-catalog') return `A different ${i.type} named ${i.name} is already in the catalog. Add this one to the catalog with "overwrite" first, then move it.` | ||
| 38 | + return `It will be copied to the catalog as ${folderOf(i)}/${i.name}.` | ||
| 39 | +} | ||
| 22 | 40 | ||
| 23 | watch(open, (v) => { | 41 | watch(open, (v) => { |
| 24 | - if (v) { | 42 | + if (!v) return |
| 25 | - chosen.value = [] | 43 | + result.value = null |
| 26 | - search.value = '' | 44 | + error.value = null |
| 27 | - result.value = null | 45 | + // Pre-check the projects that already use the item. |
| 28 | - error.value = null | 46 | + const using = new Set<string>() |
| 47 | + for (const i of props.items) { | ||
| 48 | + const ref = props.statuses[i.id]?.catalogRef ?? { type: i.type, name: i.name } | ||
| 49 | + for (const id of catalog.usedBy(ref.type, ref.name)) if (id !== 'home') using.add(id) | ||
| 29 | } | 50 | } |
| 51 | + chosen.value = live.value.filter(p => using.has(p.id)).map(p => p.id) | ||
| 30 | }) | 52 | }) |
| 31 | 53 | ||
| 32 | function toggle(id: string, on: boolean | 'indeterminate') { | 54 | function toggle(id: string, on: boolean | 'indeterminate') { |
| 33 | 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) |
| 34 | } | 56 | } |
| 35 | 57 | ||
| 58 | +const title = computed(() => (props.items.length === 1 ? `Remove ${props.items[0]!.name} from global` : `Remove ${props.items.length} items from global`)) | ||
| 59 | + | ||
| 36 | async function submit() { | 60 | async function submit() { |
| 37 | busy.value = true | 61 | busy.value = true |
| 38 | error.value = null | 62 | error.value = null |
| 39 | try { | 63 | try { |
| 40 | - result.value = await request<RemoveFromGlobalResult>('/api/home/remove-from-global', { | 64 | + const res = await request<RemoveFromGlobalResult>('/api/home/remove-from-global', { |
| 41 | method: 'POST', | 65 | method: 'POST', |
| 42 | - body: { itemIds: props.items.map(i => i.id), enableIn: chosen.value }, | 66 | + body: { itemIds: movable.value.map(i => i.id), enableIn: chosen.value }, |
| 43 | silent: true | 67 | silent: true |
| 44 | }) | 68 | }) |
| 69 | + result.value = res | ||
| 70 | + const first = props.items.find(i => i.id === res.removed[0]) | ||
| 45 | toast.add({ | 71 | toast.add({ |
| 46 | - title: `${result.value.removed.length} removed from Home`, | 72 | + title: res.removed.length === 1 && first ? `${first.name} removed from global` : `${res.removed.length} item${res.removed.length === 1 ? '' : 's'} removed from global`, |
| 47 | - color: result.value.errors.length ? 'warning' : 'success', | 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', | ||
| 48 | icon: 'i-lucide-check' | 75 | icon: 'i-lucide-check' |
| 49 | }) | 76 | }) |
| 50 | emit('done') | 77 | emit('done') |
| @@ -59,55 +86,71 @@ const nameOf = (id: string) => props.items.find(i => i.id === id)?.name ?? id | |||
| 59 | </script> | 86 | </script> |
| 60 | 87 | ||
| 61 | <template> | 88 | <template> |
| 62 | - <UModal v-model:open="open" title="Remove from global" description="Move these items out of Home and into the catalog." :ui="{ content: 'max-w-xl' }"> | 89 | + <SkModal v-model:open="open" :title="title" :description="result ? undefined : 'It will stop loading in every project, and only run where you enable it.'"> |
| 63 | - <template #body> | 90 | + <!-- Result --> |
| 64 | - <div v-if="result" class="space-y-3 text-sm"> | 91 | + <div v-if="result" class="flex flex-col gap-3"> |
| 65 | - <UAlert v-if="result.removed.length" color="success" variant="subtle" icon="i-lucide-check" :title="`${result.removed.length} item(s) removed from Home`" :description="result.removed.map(nameOf).join(', ')" /> | 92 | + <SkNote v-if="result.removed.length" tone="ok"> |
| 66 | - <UAlert v-if="result.imported.length" color="info" variant="subtle" icon="i-lucide-library" :title="`${result.imported.length} item(s) in the catalog`" :description="result.imported.map(i => `${i.type}/${i.name}`).join(', ')" /> | 93 | + <span class="font-medium">{{ result.removed.length }} removed from Home</span> |
| 67 | - <UAlert v-if="result.errors.length" color="error" variant="subtle" icon="i-lucide-circle-alert" :title="`${result.errors.length} error(s)`"> | 94 | + <span class="block font-mono text-xs text-sk-muted">{{ result.removed.map(nameOf).join(', ') }}</span> |
| 68 | - <template #description> | 95 | + </SkNote> |
| 69 | - <ul class="list-disc pl-4"><li v-for="e in result.errors" :key="e.id"><span class="font-medium">{{ nameOf(e.id) }}</span>: {{ e.message }}</li></ul> | 96 | + <SkNote v-if="result.imported.length" tone="info"> |
| 70 | - </template> | 97 | + <span class="font-medium">In the catalog</span> |
| 71 | - </UAlert> | 98 | + <span class="block font-mono text-xs text-sk-muted">{{ result.imported.map(i => `${i.type}/${i.name}`).join(', ') }}</span> |
| 72 | - </div> | 99 | + </SkNote> |
| 73 | - <div v-else class="space-y-4"> | 100 | + <SkNote v-for="e in result.errors" :key="e.id" tone="accent"><span class="font-medium">{{ nameOf(e.id) }}</span>: {{ e.message }}</SkNote> |
| 74 | - <UAlert v-if="error" color="error" variant="subtle" icon="i-lucide-circle-alert" title="Request failed" :description="error" /> | 101 | + </div> |
| 75 | - <ol class="list-decimal space-y-1 pl-5 text-sm text-muted"> | 102 | + |
| 76 | - <li>Each item is <strong class="text-default">copied to the catalog</strong> (unless an identical one is already there).</li> | 103 | + <div v-else class="flex flex-col gap-5"> |
| 77 | - <li>It is <strong class="text-default">enabled in the projects you choose</strong> below and written into them.</li> | 104 | + <SkNote v-if="error" tone="accent"><span class="font-medium">Request failed.</span> {{ error }}</SkNote> |
| 78 | - <li>It is then <strong class="text-default">removed from Home</strong>. Symbolic links are only unlinked: their target is never deleted.</li> | 105 | + |
| 79 | - </ol> | 106 | + <!-- 1 Catalog --> |
| 80 | - | 107 | + <div class="flex gap-3"> |
| 81 | - <div> | 108 | + <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">1</span> |
| 82 | - <p class="mb-1 text-xs font-medium text-muted">Items ({{ items.length }})</p> | 109 | + <div class="flex min-w-0 flex-1 flex-col gap-1"> |
| 83 | - <ul class="max-h-28 divide-y divide-default overflow-auto rounded-lg border border-default text-sm"> | 110 | + <span class="font-medium">Catalog</span> |
| 84 | - <li v-for="i in items" :key="i.id" class="flex items-center gap-2 px-3 py-1"> | 111 | + <template v-if="items.length === 1"><span class="text-sk-muted" :class="blocked.length ? 'text-sk-accent-text' : ''">{{ catalogLine(items[0]!) }}</span></template> |
| 85 | - <AgentBadge :agent="i.agent" compact /><span class="truncate font-medium">{{ i.name }}</span><span class="text-xs text-muted">{{ i.type }}</span> | 112 | + <ul v-else class="m-0 flex list-none flex-col gap-1 p-0"> |
| 86 | - <UBadge v-if="i.source.symlinkTarget" label="symlink" size="sm" color="neutral" variant="subtle" class="ml-auto" /> | 113 | + <li v-for="i in items" :key="i.id" class="text-xs" :class="stateOf(i) === 'differs-from-catalog' ? 'text-sk-accent-text' : 'text-sk-muted'"> |
| 114 | + <span class="font-medium text-sk-text">{{ i.name }}</span> · {{ catalogLine(i) }} | ||
| 87 | </li> | 115 | </li> |
| 88 | </ul> | 116 | </ul> |
| 89 | </div> | 117 | </div> |
| 118 | + </div> | ||
| 90 | 119 | ||
| 91 | - <div class="space-y-1.5"> | 120 | + <!-- 2 Projects --> |
| 92 | - <p class="text-xs font-medium text-muted">Enable in these projects ({{ chosen.length }} selected)</p> | 121 | + <div class="flex gap-3"> |
| 93 | - <UInput v-if="projects.length > 6" v-model="search" icon="i-lucide-search" size="sm" placeholder="Search projects..." class="w-full" /> | 122 | + <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">2</span> |
| 94 | - <ul v-if="list.length" class="max-h-48 divide-y divide-default overflow-auto rounded-lg border border-default text-sm"> | 123 | + <div class="flex min-w-0 flex-1 flex-col gap-1.5"> |
| 95 | - <li v-for="p in list" :key="p.id" class="flex items-center gap-2 px-3 py-1.5 hover:bg-elevated/50"> | 124 | + <span class="font-medium">Enable in projects</span> |
| 125 | + <div v-if="live.length" class="max-h-48 overflow-y-auto rounded-[10px] border border-sk-line"> | ||
| 126 | + <label v-for="p in live" :key="p.id" class="flex cursor-pointer items-center gap-2.5 border-t border-sk-line2 px-3 py-2 first:border-t-0 hover:bg-sk-hover"> | ||
| 96 | <UCheckbox :model-value="chosen.includes(p.id)" :aria-label="p.name" @update:model-value="(v) => toggle(p.id, v)" /> | 127 | <UCheckbox :model-value="chosen.includes(p.id)" :aria-label="p.name" @update:model-value="(v) => toggle(p.id, v)" /> |
| 97 | - <span class="font-medium">{{ p.name }}</span> | 128 | + <span class="shrink-0">{{ p.name }}</span> |
| 98 | - <span class="truncate font-mono text-xs text-muted" :title="p.path">{{ p.path }}</span> | 129 | + <span class="ml-auto min-w-0 truncate font-mono text-[11px] text-sk-faint" :title="p.path">{{ p.path }}</span> |
| 130 | + </label> | ||
| 131 | + </div> | ||
| 132 | + <p v-else class="m-0 text-sk-muted">No project yet: the item will only move to the catalog.</p> | ||
| 133 | + <p v-if="live.length && !chosen.length" class="m-0 text-xs text-sk-accent-text">No project selected: the item will only be available from the catalog.</p> | ||
| 134 | + </div> | ||
| 135 | + </div> | ||
| 136 | + | ||
| 137 | + <!-- 3 Home --> | ||
| 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> | ||
| 140 | + <div class="flex min-w-0 flex-1 flex-col gap-1"> | ||
| 141 | + <span class="font-medium">Remove from Home</span> | ||
| 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"> | ||
| 144 | + {{ itemLocation(i, root, '~/') }} <span class="font-sans">will be {{ i.source.symlinkTarget ? 'unlinked (only the link, never its target)' : 'removed' }}</span> | ||
| 99 | </li> | 145 | </li> |
| 100 | </ul> | 146 | </ul> |
| 101 | - <p v-else class="rounded-lg border border-dashed border-default p-4 text-center text-xs text-muted">No project available. The items will only be moved to the catalog.</p> | ||
| 102 | - <p v-if="!chosen.length" class="text-xs text-warning">No project selected: the items will disappear from Home and be available in the catalog only.</p> | ||
| 103 | </div> | 147 | </div> |
| 104 | </div> | 148 | </div> |
| 105 | - </template> | 149 | + </div> |
| 150 | + | ||
| 106 | <template #footer> | 151 | <template #footer> |
| 107 | - <div class="flex w-full justify-end gap-2"> | 152 | + <SkButton :label="result ? 'Close' : 'Cancel'" @click="open = false" /> |
| 108 | - <UButton :label="result ? 'Close' : 'Cancel'" color="neutral" variant="outline" @click="open = false" /> | 153 | + <SkButton v-if="!result" variant="primary" label="Remove from global" :loading="busy" :disabled="!movable.length" @click="submit" /> |
| 109 | - <UButton v-if="!result" label="Remove from global" icon="i-lucide-globe-lock" color="error" :loading="busy" @click="submit" /> | ||
| 110 | - </div> | ||
| 111 | </template> | 154 | </template> |
| 112 | - </UModal> | 155 | + </SkModal> |
| 113 | </template> | 156 | </template> |
deleted
packages/ui/app/components/ScopeConfigBar.vue +0 -69 | deleted file mode 100644 | ||
| @@ -1,69 +0,0 @@ | ||
| 1 | -<script setup lang="ts"> | |
| 2 | -import type { AgentId, Preset, ScopeConfig } from '@skilly/core' | |
| 3 | - | |
| 4 | -/** Targets and preset toggles, always visible above the tabs. Changes are emitted as a new config. */ | |
| 5 | -const props = defineProps<{ config: ScopeConfig, presets: Preset[], catalogExists: boolean, saving: boolean }>() | |
| 6 | -const emit = defineEmits<{ 'update:config': [c: ScopeConfig] }>() | |
| 7 | - | |
| 8 | -const AGENT_LIST: AgentId[] = ['claude', 'codex'] | |
| 9 | - | |
| 10 | -function toggleTarget(a: AgentId, on: boolean | 'indeterminate') { | |
| 11 | - const c = cloneConfig(props.config) | |
| 12 | - c.targets = on === true ? [...new Set([...c.targets, a])] : c.targets.filter(t => t !== a) | |
| 13 | - emit('update:config', c) | |
| 14 | -} | |
| 15 | -function togglePreset(name: string) { | |
| 16 | - const c = cloneConfig(props.config) | |
| 17 | - c.presets = toggleIn(c.presets, name, !c.presets.includes(name)) | |
| 18 | - emit('update:config', c) | |
| 19 | -} | |
| 20 | - | |
| 21 | -const known = computed(() => new Set(props.presets.map(p => p.name))) | |
| 22 | -const unknown = computed(() => props.config.presets.filter(p => !known.value.has(p))) | |
| 23 | -const count = (p: Preset) => CAT_TYPES.reduce((n, t) => n + (p.items[t]?.length ?? 0), 0) | |
| 24 | -</script> | |
| 25 | - | |
| 26 | -<template> | |
| 27 | - <div class="space-y-3 rounded-xl border border-default p-3"> | |
| 28 | - <div class="flex flex-wrap items-center gap-x-6 gap-y-2"> | |
| 29 | - <div class="flex items-center gap-3"> | |
| 30 | - <span class="text-xs font-medium uppercase tracking-wide text-muted">Targets</span> | |
| 31 | - <UCheckbox | |
| 32 | - v-for="a in AGENT_LIST" | |
| 33 | - :key="a" | |
| 34 | - :model-value="config.targets.includes(a)" | |
| 35 | - :label="AGENT_LABELS[a]" | |
| 36 | - :aria-label="`Write files for ${AGENT_LABELS[a]}`" | |
| 37 | - @update:model-value="(v) => toggleTarget(a, v)" | |
| 38 | - /> | |
| 39 | - </div> | |
| 40 | - <span v-if="!config.targets.length" class="text-xs text-warning">No target agent: nothing will be written.</span> | |
| 41 | - <span class="ml-auto flex items-center gap-1.5 text-xs text-muted"> | |
| 42 | - <template v-if="saving"><UIcon name="i-lucide-loader-circle" class="size-3.5 animate-spin" />Saving...</template> | |
| 43 | - <template v-else><UIcon name="i-lucide-check" class="size-3.5" />Saved</template> | |
| 44 | - </span> | |
| 45 | - </div> | |
| 46 | - | |
| 47 | - <div class="flex flex-wrap items-center gap-2"> | |
| 48 | - <span class="mr-1 text-xs font-medium uppercase tracking-wide text-muted">Presets</span> | |
| 49 | - <UTooltip v-for="p in presets" :key="p.name" :text="p.description || `${count(p)} items`"> | |
| 50 | - <UButton | |
| 51 | - :label="p.name" | |
| 52 | - :icon="config.presets.includes(p.name) ? 'i-lucide-check' : 'i-lucide-plus'" | |
| 53 | - size="xs" | |
| 54 | - :color="config.presets.includes(p.name) ? 'primary' : 'neutral'" | |
| 55 | - :variant="config.presets.includes(p.name) ? 'soft' : 'outline'" | |
| 56 | - :aria-pressed="config.presets.includes(p.name)" | |
| 57 | - @click="togglePreset(p.name)" | |
| 58 | - /> | |
| 59 | - </UTooltip> | |
| 60 | - <UTooltip v-for="p in unknown" :key="p" text="Enabled in the configuration but not found in the catalog"> | |
| 61 | - <UButton :label="p" icon="i-lucide-triangle-alert" size="xs" color="warning" variant="soft" @click="togglePreset(p)" /> | |
| 62 | - </UTooltip> | |
| 63 | - <span v-if="!presets.length && !unknown.length" class="text-xs text-muted"> | |
| 64 | - {{ catalogExists ? 'No preset yet.' : 'No catalog yet.' }} | |
| 65 | - <NuxtLink :to="catalogExists ? '/catalog/presets/new' : '/catalog'" class="text-primary hover:underline">{{ catalogExists ? 'Create one' : 'Create the catalog' }}</NuxtLink> | |
| 66 | - </span> | |
| 67 | - </div> | |
| 68 | - </div> | |
| 69 | -</template> | |
| deleted file mode 100644 | |||
| @@ -1,69 +0,0 @@ | |||
| 1 | -<script setup lang="ts"> | ||
| 2 | -import type { AgentId, Preset, ScopeConfig } from '@skilly/core' | ||
| 3 | - | ||
| 4 | -/** Targets and preset toggles, always visible above the tabs. Changes are emitted as a new config. */ | ||
| 5 | -const props = defineProps<{ config: ScopeConfig, presets: Preset[], catalogExists: boolean, saving: boolean }>() | ||
| 6 | -const emit = defineEmits<{ 'update:config': [c: ScopeConfig] }>() | ||
| 7 | - | ||
| 8 | -const AGENT_LIST: AgentId[] = ['claude', 'codex'] | ||
| 9 | - | ||
| 10 | -function toggleTarget(a: AgentId, on: boolean | 'indeterminate') { | ||
| 11 | - const c = cloneConfig(props.config) | ||
| 12 | - c.targets = on === true ? [...new Set([...c.targets, a])] : c.targets.filter(t => t !== a) | ||
| 13 | - emit('update:config', c) | ||
| 14 | -} | ||
| 15 | -function togglePreset(name: string) { | ||
| 16 | - const c = cloneConfig(props.config) | ||
| 17 | - c.presets = toggleIn(c.presets, name, !c.presets.includes(name)) | ||
| 18 | - emit('update:config', c) | ||
| 19 | -} | ||
| 20 | - | ||
| 21 | -const known = computed(() => new Set(props.presets.map(p => p.name))) | ||
| 22 | -const unknown = computed(() => props.config.presets.filter(p => !known.value.has(p))) | ||
| 23 | -const count = (p: Preset) => CAT_TYPES.reduce((n, t) => n + (p.items[t]?.length ?? 0), 0) | ||
| 24 | -</script> | ||
| 25 | - | ||
| 26 | -<template> | ||
| 27 | - <div class="space-y-3 rounded-xl border border-default p-3"> | ||
| 28 | - <div class="flex flex-wrap items-center gap-x-6 gap-y-2"> | ||
| 29 | - <div class="flex items-center gap-3"> | ||
| 30 | - <span class="text-xs font-medium uppercase tracking-wide text-muted">Targets</span> | ||
| 31 | - <UCheckbox | ||
| 32 | - v-for="a in AGENT_LIST" | ||
| 33 | - :key="a" | ||
| 34 | - :model-value="config.targets.includes(a)" | ||
| 35 | - :label="AGENT_LABELS[a]" | ||
| 36 | - :aria-label="`Write files for ${AGENT_LABELS[a]}`" | ||
| 37 | - @update:model-value="(v) => toggleTarget(a, v)" | ||
| 38 | - /> | ||
| 39 | - </div> | ||
| 40 | - <span v-if="!config.targets.length" class="text-xs text-warning">No target agent: nothing will be written.</span> | ||
| 41 | - <span class="ml-auto flex items-center gap-1.5 text-xs text-muted"> | ||
| 42 | - <template v-if="saving"><UIcon name="i-lucide-loader-circle" class="size-3.5 animate-spin" />Saving...</template> | ||
| 43 | - <template v-else><UIcon name="i-lucide-check" class="size-3.5" />Saved</template> | ||
| 44 | - </span> | ||
| 45 | - </div> | ||
| 46 | - | ||
| 47 | - <div class="flex flex-wrap items-center gap-2"> | ||
| 48 | - <span class="mr-1 text-xs font-medium uppercase tracking-wide text-muted">Presets</span> | ||
| 49 | - <UTooltip v-for="p in presets" :key="p.name" :text="p.description || `${count(p)} items`"> | ||
| 50 | - <UButton | ||
| 51 | - :label="p.name" | ||
| 52 | - :icon="config.presets.includes(p.name) ? 'i-lucide-check' : 'i-lucide-plus'" | ||
| 53 | - size="xs" | ||
| 54 | - :color="config.presets.includes(p.name) ? 'primary' : 'neutral'" | ||
| 55 | - :variant="config.presets.includes(p.name) ? 'soft' : 'outline'" | ||
| 56 | - :aria-pressed="config.presets.includes(p.name)" | ||
| 57 | - @click="togglePreset(p.name)" | ||
| 58 | - /> | ||
| 59 | - </UTooltip> | ||
| 60 | - <UTooltip v-for="p in unknown" :key="p" text="Enabled in the configuration but not found in the catalog"> | ||
| 61 | - <UButton :label="p" icon="i-lucide-triangle-alert" size="xs" color="warning" variant="soft" @click="togglePreset(p)" /> | ||
| 62 | - </UTooltip> | ||
| 63 | - <span v-if="!presets.length && !unknown.length" class="text-xs text-muted"> | ||
| 64 | - {{ catalogExists ? 'No preset yet.' : 'No catalog yet.' }} | ||
| 65 | - <NuxtLink :to="catalogExists ? '/catalog/presets/new' : '/catalog'" class="text-primary hover:underline">{{ catalogExists ? 'Create one' : 'Create the catalog' }}</NuxtLink> | ||
| 66 | - </span> | ||
| 67 | - </div> | ||
| 68 | - </div> | ||
| 69 | -</template> | ||
deleted
packages/ui/app/components/ScopeConfigPanel.vue +0 -282 | deleted file mode 100644 | ||
| @@ -1,282 +0,0 @@ | ||
| 1 | -<script setup lang="ts"> | |
| 2 | -import type { AgentId, CatalogItem, CatalogItemType, Preset, ScopeConfig } from '@skilly/core' | |
| 3 | - | |
| 4 | -/** "Configuration" tab: enabled items (exclude, automatic / on demand) and hidden global items. */ | |
| 5 | -const props = defineProps<{ config: ScopeConfig, items: CatalogItem[], presets: Preset[], home: boolean }>() | |
| 6 | -const emit = defineEmits<{ 'update:config': [c: ScopeConfig] }>() | |
| 7 | - | |
| 8 | -const { home: homeInv, refresh: refreshHome, loading: homeLoading } = useHomeInventory() | |
| 9 | - | |
| 10 | -// Home manages skills, rules and agents (its MCP servers and hooks are read-only); projects manage every type. | |
| 11 | -const ADD_TYPES = computed<CatalogItemType[]>(() => (props.home ? ['skill', 'rule', 'agent'] : ['skill', 'rule', 'agent', 'mcp', 'hook'])) | |
| 12 | - | |
| 13 | -const resolved = computed(() => resolveItems(props.presets, props.config.presets, props.config.add)) | |
| 14 | -const rows = computed(() => resolved.value.entries | |
| 15 | - .slice() | |
| 16 | - .sort((a, b) => CAT_TYPES.indexOf(a.type) - CAT_TYPES.indexOf(b.type) || a.name.localeCompare(b.name))) | |
| 17 | -const inCatalog = (t: CatalogItemType, n: string) => props.items.some(i => i.type === t && i.name === n) | |
| 18 | - | |
| 19 | -function matchOf(list: string[] | undefined, name: string): { exact: boolean, pattern?: string } | null { | |
| 20 | - const l = list ?? [] | |
| 21 | - if (l.includes(name)) return { exact: true } | |
| 22 | - const g = l.find(p => p.includes('*') && globMatch(p, name)) | |
| 23 | - return g ? { exact: false, pattern: g } : null | |
| 24 | -} | |
| 25 | - | |
| 26 | -function mutate(fn: (c: ScopeConfig) => void) { | |
| 27 | - const c = cloneConfig(props.config) | |
| 28 | - fn(c) | |
| 29 | - emit('update:config', c) | |
| 30 | -} | |
| 31 | - | |
| 32 | -function setIncluded(t: CatalogItemType, name: string, included: boolean) { | |
| 33 | - mutate((c) => { | |
| 34 | - const list = toggleIn(c.exclude[t] ?? [], name, !included) | |
| 35 | - if (list.length) c.exclude[t] = list | |
| 36 | - else delete c.exclude[t] | |
| 37 | - }) | |
| 38 | -} | |
| 39 | -function removeAdded(t: CatalogItemType, name: string) { | |
| 40 | - mutate((c) => { | |
| 41 | - const list = (c.add[t] ?? []).filter(x => x !== name) | |
| 42 | - if (list.length) c.add[t] = list | |
| 43 | - else delete c.add[t] | |
| 44 | - }) | |
| 45 | -} | |
| 46 | -function setOnDemand(name: string, on: boolean) { | |
| 47 | - mutate((c) => { c.onDemand.skills = toggleIn(c.onDemand.skills, name, on) }) | |
| 48 | -} | |
| 49 | - | |
| 50 | -/* ------------------------------------------------ Add items */ | |
| 51 | - | |
| 52 | -const pickerOpen = ref(false) | |
| 53 | -const pickerSel = ref<Partial<Record<CatalogItemType, string[]>>>({}) | |
| 54 | -const locked = computed(() => { | |
| 55 | - const m: Record<string, string> = {} | |
| 56 | - for (const e of resolved.value.entries) { | |
| 57 | - const preset = e.via.find(v => v !== 'add') | |
| 58 | - if (preset) m[`${e.type}/${e.name}`] = `via ${preset}` | |
| 59 | - } | |
| 60 | - return m | |
| 61 | -}) | |
| 62 | -const pickable = computed(() => props.items.filter(i => ADD_TYPES.value.includes(i.type))) | |
| 63 | - | |
| 64 | -function openPicker() { | |
| 65 | - pickerSel.value = structuredClone(toRaw(props.config.add)) | |
| 66 | - pickerOpen.value = true | |
| 67 | -} | |
| 68 | -function confirmPicker() { | |
| 69 | - mutate((c) => { | |
| 70 | - for (const t of ADD_TYPES.value) { | |
| 71 | - const l = pickerSel.value[t] ?? [] | |
| 72 | - if (l.length) c.add[t] = l | |
| 73 | - else delete c.add[t] | |
| 74 | - } | |
| 75 | - }) | |
| 76 | - pickerOpen.value = false | |
| 77 | -} | |
| 78 | - | |
| 79 | -/* ------------------------------------------------ Hide global (projects) */ | |
| 80 | - | |
| 81 | -const hideTab = ref<'skills' | 'mcp'>('skills') | |
| 82 | -const hideSearch = ref('') | |
| 83 | -const HIDE_LIMIT = 100 | |
| 84 | - | |
| 85 | -onMounted(() => { | |
| 86 | - if (!props.home && !homeInv.value) refreshHome() | |
| 87 | -}) | |
| 88 | - | |
| 89 | -const homeNames = computed(() => { | |
| 90 | - const out: Record<'skills' | 'mcp', Map<string, Set<AgentId>>> = { skills: new Map(), mcp: new Map() } | |
| 91 | - for (const i of homeInv.value?.items ?? []) { | |
| 92 | - const bucket = i.type === 'skill' ? out.skills : i.type === 'mcp' ? out.mcp : null | |
| 93 | - if (!bucket) continue | |
| 94 | - bucket.set(i.name, (bucket.get(i.name) ?? new Set()).add(i.agent)) | |
| 95 | - } | |
| 96 | - return out | |
| 97 | -}) | |
| 98 | -const hideRows = computed(() => { | |
| 99 | - const q = hideSearch.value.trim().toLowerCase() | |
| 100 | - return [...homeNames.value[hideTab.value].entries()] | |
| 101 | - .filter(([n]) => !q || n.toLowerCase().includes(q)) | |
| 102 | - .sort((a, b) => a[0].localeCompare(b[0])) | |
| 103 | -}) | |
| 104 | -const hideTabs = computed(() => [ | |
| 105 | - { label: 'Skills', value: 'skills', icon: TYPE_META.skill.icon, badge: { label: String(homeNames.value.skills.size), color: 'neutral' as const, variant: 'subtle' as const } }, | |
| 106 | - { label: 'MCP servers', value: 'mcp', icon: TYPE_META.mcp.icon, badge: { label: String(homeNames.value.mcp.size), color: 'neutral' as const, variant: 'subtle' as const } } | |
| 107 | -]) | |
| 108 | -function setHidden(name: string, on: boolean | 'indeterminate') { | |
| 109 | - mutate((c) => { c.hideGlobal[hideTab.value] = toggleIn(c.hideGlobal[hideTab.value], name, on === true) }) | |
| 110 | -} | |
| 111 | - | |
| 112 | -const patterns = computed(() => { | |
| 113 | - const out: { where: string, pattern: string }[] = [] | |
| 114 | - for (const [t, l] of Object.entries(props.config.exclude)) for (const p of l ?? []) if (p.includes('*')) out.push({ where: `exclude ${t}`, pattern: p }) | |
| 115 | - for (const p of props.config.onDemand.skills) if (p.includes('*')) out.push({ where: 'on demand', pattern: p }) | |
| 116 | - if (!props.home) { | |
| 117 | - for (const p of props.config.hideGlobal.skills) if (p.includes('*')) out.push({ where: 'hide skills', pattern: p }) | |
| 118 | - for (const p of props.config.hideGlobal.mcp) if (p.includes('*')) out.push({ where: 'hide mcp', pattern: p }) | |
| 119 | - } | |
| 120 | - return out | |
| 121 | -}) | |
| 122 | -</script> | |
| 123 | - | |
| 124 | -<template> | |
| 125 | - <div class="space-y-8"> | |
| 126 | - <!-- Enabled items --> | |
| 127 | - <section class="space-y-3"> | |
| 128 | - <div class="flex items-center gap-3"> | |
| 129 | - <div> | |
| 130 | - <h2 class="font-semibold">Enabled items <span class="font-normal text-muted">({{ rows.length }})</span></h2> | |
| 131 | - <p class="text-xs text-muted">From enabled presets and items added one by one. Files are written when you apply the changes.</p> | |
| 132 | - </div> | |
| 133 | - <UButton class="ml-auto" label="Add items" icon="i-lucide-plus" :disabled="!items.length" @click="openPicker" /> | |
| 134 | - </div> | |
| 135 | - <UAlert v-if="resolved.missingPresets.length" color="warning" variant="subtle" icon="i-lucide-triangle-alert" :title="`Preset(s) not found in the catalog: ${resolved.missingPresets.join(', ')}`" /> | |
| 136 | - <UAlert v-if="home" color="neutral" variant="subtle" icon="i-lucide-info" title="Home MCP servers and hooks are not managed in v1" description="Their entries in your user configuration are listed read-only in the MCP and Hooks tabs." /> | |
| 137 | - | |
| 138 | - <div v-if="!rows.length" class="flex flex-col items-center gap-2 rounded-xl border border-dashed border-default py-10 text-center"> | |
| 139 | - <UIcon name="i-lucide-layers" class="size-8 text-dimmed" /> | |
| 140 | - <p class="text-sm text-muted">Nothing enabled yet. Toggle a preset above, or add items one by one.</p> | |
| 141 | - </div> | |
| 142 | - <div v-else class="overflow-x-auto rounded-xl border border-default"> | |
| 143 | - <table class="w-full min-w-[720px] table-fixed text-sm"> | |
| 144 | - <thead class="border-b border-default bg-elevated/40"> | |
| 145 | - <tr> | |
| 146 | - <th class="w-[26%] px-3 py-2 text-left text-xs font-medium uppercase tracking-wide text-muted">Name</th> | |
| 147 | - <th class="px-3 py-2 text-left text-xs font-medium uppercase tracking-wide text-muted">Source</th> | |
| 148 | - <th class="w-52 px-3 py-2 text-left text-xs font-medium uppercase tracking-wide text-muted"> | |
| 149 | - <span class="inline-flex items-center gap-1"> | |
| 150 | - Mode | |
| 151 | - <UTooltip :content="{ side: 'top' }"> | |
| 152 | - <UIcon name="i-lucide-circle-help" class="size-3.5" /> | |
| 153 | - <template #content> | |
| 154 | - <p class="max-w-xs whitespace-normal text-xs normal-case"> | |
| 155 | - Automatic: the agent sees the skill and uses it when relevant. On demand: installed but hidden from the agent; | |
| 156 | - you invoke it yourself with /name in Claude Code or $name in Codex. | |
| 157 | - </p> | |
| 158 | - </template> | |
| 159 | - </UTooltip> | |
| 160 | - </span> | |
| 161 | - </th> | |
| 162 | - <th class="w-36 px-3 py-2 text-left text-xs font-medium uppercase tracking-wide text-muted">Include</th> | |
| 163 | - </tr> | |
| 164 | - </thead> | |
| 165 | - <tbody class="divide-y divide-default"> | |
| 166 | - <tr v-for="r in rows" :key="`${r.type}/${r.name}`" :class="matchOf(config.exclude[r.type], r.name) ? 'opacity-60' : ''"> | |
| 167 | - <td class="px-3 py-2 align-middle"> | |
| 168 | - <div class="flex items-center gap-2"> | |
| 169 | - <UIcon :name="CAT_META[r.type].icon" class="size-4 shrink-0 text-muted" /> | |
| 170 | - <NuxtLink v-if="inCatalog(r.type, r.name)" :to="`/catalog/${r.type}/${encodeURIComponent(r.name)}`" class="truncate font-medium hover:underline">{{ r.name }}</NuxtLink> | |
| 171 | - <span v-else class="truncate font-medium">{{ r.name }}</span> | |
| 172 | - <UTooltip v-if="!inCatalog(r.type, r.name)" text="Not found in the catalog: it will be skipped"> | |
| 173 | - <UBadge label="missing" size="sm" color="warning" variant="subtle" /> | |
| 174 | - </UTooltip> | |
| 175 | - </div> | |
| 176 | - </td> | |
| 177 | - <td class="px-3 py-2 align-middle"> | |
| 178 | - <div class="flex flex-wrap gap-1"> | |
| 179 | - <UBadge v-for="v in r.via" :key="v" :label="v === 'add' ? 'added' : `preset: ${v}`" size="sm" :color="v === 'add' ? 'primary' : 'neutral'" variant="subtle" /> | |
| 180 | - </div> | |
| 181 | - </td> | |
| 182 | - <td class="px-3 py-2 align-middle"> | |
| 183 | - <template v-if="r.type === 'skill'"> | |
| 184 | - <UFieldGroup size="xs"> | |
| 185 | - <UButton | |
| 186 | - label="Automatic" | |
| 187 | - :color="matchOf(config.onDemand.skills, r.name) ? 'neutral' : 'primary'" | |
| 188 | - :variant="matchOf(config.onDemand.skills, r.name) ? 'outline' : 'soft'" | |
| 189 | - :disabled="matchOf(config.onDemand.skills, r.name)?.exact === false" | |
| 190 | - @click="setOnDemand(r.name, false)" | |
| 191 | - /> | |
| 192 | - <UButton | |
| 193 | - label="On demand" | |
| 194 | - :color="matchOf(config.onDemand.skills, r.name) ? 'primary' : 'neutral'" | |
| 195 | - :variant="matchOf(config.onDemand.skills, r.name) ? 'soft' : 'outline'" | |
| 196 | - @click="setOnDemand(r.name, true)" | |
| 197 | - /> | |
| 198 | - </UFieldGroup> | |
| 199 | - <p v-if="matchOf(config.onDemand.skills, r.name)?.exact === false" class="mt-0.5 font-mono text-[11px] text-muted">matches {{ matchOf(config.onDemand.skills, r.name)!.pattern }}</p> | |
| 200 | - </template> | |
| 201 | - <span v-else class="text-xs text-dimmed">-</span> | |
| 202 | - </td> | |
| 203 | - <td class="px-3 py-2 align-middle"> | |
| 204 | - <div class="flex items-center gap-2"> | |
| 205 | - <template v-if="r.via.some(v => v !== 'add')"> | |
| 206 | - <USwitch | |
| 207 | - size="sm" | |
| 208 | - :model-value="!matchOf(config.exclude[r.type], r.name)" | |
| 209 | - :disabled="matchOf(config.exclude[r.type], r.name)?.exact === false" | |
| 210 | - :aria-label="`Include ${r.name}`" | |
| 211 | - @update:model-value="(v: boolean) => setIncluded(r.type, r.name, v)" | |
| 212 | - /> | |
| 213 | - <span class="text-xs text-muted">{{ matchOf(config.exclude[r.type], r.name) ? (matchOf(config.exclude[r.type], r.name)!.exact ? 'excluded' : matchOf(config.exclude[r.type], r.name)!.pattern) : 'included' }}</span> | |
| 214 | - </template> | |
| 215 | - <UButton v-if="r.via.includes('add')" icon="i-lucide-x" size="xs" color="neutral" variant="ghost" aria-label="Remove from this scope" class="ml-auto" @click="removeAdded(r.type, r.name)" /> | |
| 216 | - </div> | |
| 217 | - </td> | |
| 218 | - </tr> | |
| 219 | - </tbody> | |
| 220 | - </table> | |
| 221 | - </div> | |
| 222 | - <p v-if="patterns.length" class="flex flex-wrap items-center gap-1.5 text-xs text-muted"> | |
| 223 | - Patterns kept as is: | |
| 224 | - <UBadge v-for="(p, i) in patterns" :key="i" :label="`${p.where}: ${p.pattern}`" size="sm" color="neutral" variant="outline" class="font-mono" /> | |
| 225 | - </p> | |
| 226 | - </section> | |
| 227 | - | |
| 228 | - <!-- Hide global (projects only) --> | |
| 229 | - <section v-if="!home" class="space-y-3"> | |
| 230 | - <div> | |
| 231 | - <h2 class="font-semibold">Hide global items</h2> | |
| 232 | - <p class="text-xs text-muted">Hide Home skills and MCP servers in this project so they do not clutter its context.</p> | |
| 233 | - </div> | |
| 234 | - <UAlert | |
| 235 | - color="neutral" | |
| 236 | - variant="subtle" | |
| 237 | - icon="i-lucide-info" | |
| 238 | - title="Agent support" | |
| 239 | - description="Claude Code can hide global skills (skillOverrides) and MCP servers (deniedMcpServers) per project. Codex cannot hide them per project: it can only disable them globally in ~/.codex/config.toml." | |
| 240 | - /> | |
| 241 | - <div class="flex flex-wrap items-center gap-3"> | |
| 242 | - <UTabs v-model="hideTab" :items="hideTabs" :content="false" variant="pill" color="neutral" size="sm" class="w-auto" /> | |
| 243 | - <UInput v-model="hideSearch" icon="i-lucide-search" size="sm" placeholder="Search..." class="w-64" /> | |
| 244 | - </div> | |
| 245 | - <USkeleton v-if="homeLoading && !homeInv" class="h-24 w-full" /> | |
| 246 | - <p v-else-if="!hideRows.length" class="rounded-lg border border-dashed border-default p-6 text-center text-sm text-muted">No global {{ hideTab === 'skills' ? 'skill' : 'MCP server' }} found in Home.</p> | |
| 247 | - <ul v-else class="divide-y divide-default overflow-hidden rounded-xl border border-default"> | |
| 248 | - <li v-for="[name, agents] in hideRows.slice(0, HIDE_LIMIT)" :key="name" class="flex items-center gap-3 px-3 py-1.5"> | |
| 249 | - <USwitch | |
| 250 | - size="sm" | |
| 251 | - :model-value="!!matchOf(config.hideGlobal[hideTab], name)" | |
| 252 | - :disabled="matchOf(config.hideGlobal[hideTab], name)?.exact === false" | |
| 253 | - :aria-label="`Hide ${name}`" | |
| 254 | - @update:model-value="(v: boolean) => setHidden(name, v)" | |
| 255 | - /> | |
| 256 | - <span class="min-w-0 flex-1 truncate text-sm font-medium">{{ name }}</span> | |
| 257 | - <span v-if="matchOf(config.hideGlobal[hideTab], name)?.exact === false" class="font-mono text-[11px] text-muted">matches {{ matchOf(config.hideGlobal[hideTab], name)!.pattern }}</span> | |
| 258 | - <div class="flex gap-1"> | |
| 259 | - <UTooltip v-for="a in [...agents]" :key="a" :text="a === 'claude' ? 'Can be hidden' : 'Codex cannot hide it per project'"> | |
| 260 | - <span :class="a === 'claude' ? '' : 'opacity-50'"><AgentBadge :agent="a" compact /></span> | |
| 261 | - </UTooltip> | |
| 262 | - </div> | |
| 263 | - </li> | |
| 264 | - <li v-if="hideRows.length > HIDE_LIMIT" class="p-2 text-center text-xs text-muted">{{ hideRows.length - HIDE_LIMIT }} more: refine the search.</li> | |
| 265 | - </ul> | |
| 266 | - </section> | |
| 267 | - | |
| 268 | - <!-- Add items --> | |
| 269 | - <UModal v-model:open="pickerOpen" title="Add items" description="Pick items from the catalog to enable in this scope." :ui="{ content: 'max-w-2xl' }"> | |
| 270 | - <template #body> | |
| 271 | - <ItemPicker v-model="pickerSel" :items="pickable" :types="ADD_TYPES" :locked="locked" height="h-80" /> | |
| 272 | - <p v-if="home" class="mt-2 text-xs text-muted">Home can receive skills, rules and agents. Its MCP servers and hooks are not managed in v1.</p> | |
| 273 | - </template> | |
| 274 | - <template #footer> | |
| 275 | - <div class="flex w-full justify-end gap-2"> | |
| 276 | - <UButton label="Cancel" color="neutral" variant="outline" @click="pickerOpen = false" /> | |
| 277 | - <UButton label="Apply selection" icon="i-lucide-check" @click="confirmPicker" /> | |
| 278 | - </div> | |
| 279 | - </template> | |
| 280 | - </UModal> | |
| 281 | - </div> | |
| 282 | -</template> | |
| deleted file mode 100644 | |||
| @@ -1,282 +0,0 @@ | |||
| 1 | -<script setup lang="ts"> | ||
| 2 | -import type { AgentId, CatalogItem, CatalogItemType, Preset, ScopeConfig } from '@skilly/core' | ||
| 3 | - | ||
| 4 | -/** "Configuration" tab: enabled items (exclude, automatic / on demand) and hidden global items. */ | ||
| 5 | -const props = defineProps<{ config: ScopeConfig, items: CatalogItem[], presets: Preset[], home: boolean }>() | ||
| 6 | -const emit = defineEmits<{ 'update:config': [c: ScopeConfig] }>() | ||
| 7 | - | ||
| 8 | -const { home: homeInv, refresh: refreshHome, loading: homeLoading } = useHomeInventory() | ||
| 9 | - | ||
| 10 | -// Home manages skills, rules and agents (its MCP servers and hooks are read-only); projects manage every type. | ||
| 11 | -const ADD_TYPES = computed<CatalogItemType[]>(() => (props.home ? ['skill', 'rule', 'agent'] : ['skill', 'rule', 'agent', 'mcp', 'hook'])) | ||
| 12 | - | ||
| 13 | -const resolved = computed(() => resolveItems(props.presets, props.config.presets, props.config.add)) | ||
| 14 | -const rows = computed(() => resolved.value.entries | ||
| 15 | - .slice() | ||
| 16 | - .sort((a, b) => CAT_TYPES.indexOf(a.type) - CAT_TYPES.indexOf(b.type) || a.name.localeCompare(b.name))) | ||
| 17 | -const inCatalog = (t: CatalogItemType, n: string) => props.items.some(i => i.type === t && i.name === n) | ||
| 18 | - | ||
| 19 | -function matchOf(list: string[] | undefined, name: string): { exact: boolean, pattern?: string } | null { | ||
| 20 | - const l = list ?? [] | ||
| 21 | - if (l.includes(name)) return { exact: true } | ||
| 22 | - const g = l.find(p => p.includes('*') && globMatch(p, name)) | ||
| 23 | - return g ? { exact: false, pattern: g } : null | ||
| 24 | -} | ||
| 25 | - | ||
| 26 | -function mutate(fn: (c: ScopeConfig) => void) { | ||
| 27 | - const c = cloneConfig(props.config) | ||
| 28 | - fn(c) | ||
| 29 | - emit('update:config', c) | ||
| 30 | -} | ||
| 31 | - | ||
| 32 | -function setIncluded(t: CatalogItemType, name: string, included: boolean) { | ||
| 33 | - mutate((c) => { | ||
| 34 | - const list = toggleIn(c.exclude[t] ?? [], name, !included) | ||
| 35 | - if (list.length) c.exclude[t] = list | ||
| 36 | - else delete c.exclude[t] | ||
| 37 | - }) | ||
| 38 | -} | ||
| 39 | -function removeAdded(t: CatalogItemType, name: string) { | ||
| 40 | - mutate((c) => { | ||
| 41 | - const list = (c.add[t] ?? []).filter(x => x !== name) | ||
| 42 | - if (list.length) c.add[t] = list | ||
| 43 | - else delete c.add[t] | ||
| 44 | - }) | ||
| 45 | -} | ||
| 46 | -function setOnDemand(name: string, on: boolean) { | ||
| 47 | - mutate((c) => { c.onDemand.skills = toggleIn(c.onDemand.skills, name, on) }) | ||
| 48 | -} | ||
| 49 | - | ||
| 50 | -/* ------------------------------------------------ Add items */ | ||
| 51 | - | ||
| 52 | -const pickerOpen = ref(false) | ||
| 53 | -const pickerSel = ref<Partial<Record<CatalogItemType, string[]>>>({}) | ||
| 54 | -const locked = computed(() => { | ||
| 55 | - const m: Record<string, string> = {} | ||
| 56 | - for (const e of resolved.value.entries) { | ||
| 57 | - const preset = e.via.find(v => v !== 'add') | ||
| 58 | - if (preset) m[`${e.type}/${e.name}`] = `via ${preset}` | ||
| 59 | - } | ||
| 60 | - return m | ||
| 61 | -}) | ||
| 62 | -const pickable = computed(() => props.items.filter(i => ADD_TYPES.value.includes(i.type))) | ||
| 63 | - | ||
| 64 | -function openPicker() { | ||
| 65 | - pickerSel.value = structuredClone(toRaw(props.config.add)) | ||
| 66 | - pickerOpen.value = true | ||
| 67 | -} | ||
| 68 | -function confirmPicker() { | ||
| 69 | - mutate((c) => { | ||
| 70 | - for (const t of ADD_TYPES.value) { | ||
| 71 | - const l = pickerSel.value[t] ?? [] | ||
| 72 | - if (l.length) c.add[t] = l | ||
| 73 | - else delete c.add[t] | ||
| 74 | - } | ||
| 75 | - }) | ||
| 76 | - pickerOpen.value = false | ||
| 77 | -} | ||
| 78 | - | ||
| 79 | -/* ------------------------------------------------ Hide global (projects) */ | ||
| 80 | - | ||
| 81 | -const hideTab = ref<'skills' | 'mcp'>('skills') | ||
| 82 | -const hideSearch = ref('') | ||
| 83 | -const HIDE_LIMIT = 100 | ||
| 84 | - | ||
| 85 | -onMounted(() => { | ||
| 86 | - if (!props.home && !homeInv.value) refreshHome() | ||
| 87 | -}) | ||
| 88 | - | ||
| 89 | -const homeNames = computed(() => { | ||
| 90 | - const out: Record<'skills' | 'mcp', Map<string, Set<AgentId>>> = { skills: new Map(), mcp: new Map() } | ||
| 91 | - for (const i of homeInv.value?.items ?? []) { | ||
| 92 | - const bucket = i.type === 'skill' ? out.skills : i.type === 'mcp' ? out.mcp : null | ||
| 93 | - if (!bucket) continue | ||
| 94 | - bucket.set(i.name, (bucket.get(i.name) ?? new Set()).add(i.agent)) | ||
| 95 | - } | ||
| 96 | - return out | ||
| 97 | -}) | ||
| 98 | -const hideRows = computed(() => { | ||
| 99 | - const q = hideSearch.value.trim().toLowerCase() | ||
| 100 | - return [...homeNames.value[hideTab.value].entries()] | ||
| 101 | - .filter(([n]) => !q || n.toLowerCase().includes(q)) | ||
| 102 | - .sort((a, b) => a[0].localeCompare(b[0])) | ||
| 103 | -}) | ||
| 104 | -const hideTabs = computed(() => [ | ||
| 105 | - { label: 'Skills', value: 'skills', icon: TYPE_META.skill.icon, badge: { label: String(homeNames.value.skills.size), color: 'neutral' as const, variant: 'subtle' as const } }, | ||
| 106 | - { label: 'MCP servers', value: 'mcp', icon: TYPE_META.mcp.icon, badge: { label: String(homeNames.value.mcp.size), color: 'neutral' as const, variant: 'subtle' as const } } | ||
| 107 | -]) | ||
| 108 | -function setHidden(name: string, on: boolean | 'indeterminate') { | ||
| 109 | - mutate((c) => { c.hideGlobal[hideTab.value] = toggleIn(c.hideGlobal[hideTab.value], name, on === true) }) | ||
| 110 | -} | ||
| 111 | - | ||
| 112 | -const patterns = computed(() => { | ||
| 113 | - const out: { where: string, pattern: string }[] = [] | ||
| 114 | - for (const [t, l] of Object.entries(props.config.exclude)) for (const p of l ?? []) if (p.includes('*')) out.push({ where: `exclude ${t}`, pattern: p }) | ||
| 115 | - for (const p of props.config.onDemand.skills) if (p.includes('*')) out.push({ where: 'on demand', pattern: p }) | ||
| 116 | - if (!props.home) { | ||
| 117 | - for (const p of props.config.hideGlobal.skills) if (p.includes('*')) out.push({ where: 'hide skills', pattern: p }) | ||
| 118 | - for (const p of props.config.hideGlobal.mcp) if (p.includes('*')) out.push({ where: 'hide mcp', pattern: p }) | ||
| 119 | - } | ||
| 120 | - return out | ||
| 121 | -}) | ||
| 122 | -</script> | ||
| 123 | - | ||
| 124 | -<template> | ||
| 125 | - <div class="space-y-8"> | ||
| 126 | - <!-- Enabled items --> | ||
| 127 | - <section class="space-y-3"> | ||
| 128 | - <div class="flex items-center gap-3"> | ||
| 129 | - <div> | ||
| 130 | - <h2 class="font-semibold">Enabled items <span class="font-normal text-muted">({{ rows.length }})</span></h2> | ||
| 131 | - <p class="text-xs text-muted">From enabled presets and items added one by one. Files are written when you apply the changes.</p> | ||
| 132 | - </div> | ||
| 133 | - <UButton class="ml-auto" label="Add items" icon="i-lucide-plus" :disabled="!items.length" @click="openPicker" /> | ||
| 134 | - </div> | ||
| 135 | - <UAlert v-if="resolved.missingPresets.length" color="warning" variant="subtle" icon="i-lucide-triangle-alert" :title="`Preset(s) not found in the catalog: ${resolved.missingPresets.join(', ')}`" /> | ||
| 136 | - <UAlert v-if="home" color="neutral" variant="subtle" icon="i-lucide-info" title="Home MCP servers and hooks are not managed in v1" description="Their entries in your user configuration are listed read-only in the MCP and Hooks tabs." /> | ||
| 137 | - | ||
| 138 | - <div v-if="!rows.length" class="flex flex-col items-center gap-2 rounded-xl border border-dashed border-default py-10 text-center"> | ||
| 139 | - <UIcon name="i-lucide-layers" class="size-8 text-dimmed" /> | ||
| 140 | - <p class="text-sm text-muted">Nothing enabled yet. Toggle a preset above, or add items one by one.</p> | ||
| 141 | - </div> | ||
| 142 | - <div v-else class="overflow-x-auto rounded-xl border border-default"> | ||
| 143 | - <table class="w-full min-w-[720px] table-fixed text-sm"> | ||
| 144 | - <thead class="border-b border-default bg-elevated/40"> | ||
| 145 | - <tr> | ||
| 146 | - <th class="w-[26%] px-3 py-2 text-left text-xs font-medium uppercase tracking-wide text-muted">Name</th> | ||
| 147 | - <th class="px-3 py-2 text-left text-xs font-medium uppercase tracking-wide text-muted">Source</th> | ||
| 148 | - <th class="w-52 px-3 py-2 text-left text-xs font-medium uppercase tracking-wide text-muted"> | ||
| 149 | - <span class="inline-flex items-center gap-1"> | ||
| 150 | - Mode | ||
| 151 | - <UTooltip :content="{ side: 'top' }"> | ||
| 152 | - <UIcon name="i-lucide-circle-help" class="size-3.5" /> | ||
| 153 | - <template #content> | ||
| 154 | - <p class="max-w-xs whitespace-normal text-xs normal-case"> | ||
| 155 | - Automatic: the agent sees the skill and uses it when relevant. On demand: installed but hidden from the agent; | ||
| 156 | - you invoke it yourself with /name in Claude Code or $name in Codex. | ||
| 157 | - </p> | ||
| 158 | - </template> | ||
| 159 | - </UTooltip> | ||
| 160 | - </span> | ||
| 161 | - </th> | ||
| 162 | - <th class="w-36 px-3 py-2 text-left text-xs font-medium uppercase tracking-wide text-muted">Include</th> | ||
| 163 | - </tr> | ||
| 164 | - </thead> | ||
| 165 | - <tbody class="divide-y divide-default"> | ||
| 166 | - <tr v-for="r in rows" :key="`${r.type}/${r.name}`" :class="matchOf(config.exclude[r.type], r.name) ? 'opacity-60' : ''"> | ||
| 167 | - <td class="px-3 py-2 align-middle"> | ||
| 168 | - <div class="flex items-center gap-2"> | ||
| 169 | - <UIcon :name="CAT_META[r.type].icon" class="size-4 shrink-0 text-muted" /> | ||
| 170 | - <NuxtLink v-if="inCatalog(r.type, r.name)" :to="`/catalog/${r.type}/${encodeURIComponent(r.name)}`" class="truncate font-medium hover:underline">{{ r.name }}</NuxtLink> | ||
| 171 | - <span v-else class="truncate font-medium">{{ r.name }}</span> | ||
| 172 | - <UTooltip v-if="!inCatalog(r.type, r.name)" text="Not found in the catalog: it will be skipped"> | ||
| 173 | - <UBadge label="missing" size="sm" color="warning" variant="subtle" /> | ||
| 174 | - </UTooltip> | ||
| 175 | - </div> | ||
| 176 | - </td> | ||
| 177 | - <td class="px-3 py-2 align-middle"> | ||
| 178 | - <div class="flex flex-wrap gap-1"> | ||
| 179 | - <UBadge v-for="v in r.via" :key="v" :label="v === 'add' ? 'added' : `preset: ${v}`" size="sm" :color="v === 'add' ? 'primary' : 'neutral'" variant="subtle" /> | ||
| 180 | - </div> | ||
| 181 | - </td> | ||
| 182 | - <td class="px-3 py-2 align-middle"> | ||
| 183 | - <template v-if="r.type === 'skill'"> | ||
| 184 | - <UFieldGroup size="xs"> | ||
| 185 | - <UButton | ||
| 186 | - label="Automatic" | ||
| 187 | - :color="matchOf(config.onDemand.skills, r.name) ? 'neutral' : 'primary'" | ||
| 188 | - :variant="matchOf(config.onDemand.skills, r.name) ? 'outline' : 'soft'" | ||
| 189 | - :disabled="matchOf(config.onDemand.skills, r.name)?.exact === false" | ||
| 190 | - @click="setOnDemand(r.name, false)" | ||
| 191 | - /> | ||
| 192 | - <UButton | ||
| 193 | - label="On demand" | ||
| 194 | - :color="matchOf(config.onDemand.skills, r.name) ? 'primary' : 'neutral'" | ||
| 195 | - :variant="matchOf(config.onDemand.skills, r.name) ? 'soft' : 'outline'" | ||
| 196 | - @click="setOnDemand(r.name, true)" | ||
| 197 | - /> | ||
| 198 | - </UFieldGroup> | ||
| 199 | - <p v-if="matchOf(config.onDemand.skills, r.name)?.exact === false" class="mt-0.5 font-mono text-[11px] text-muted">matches {{ matchOf(config.onDemand.skills, r.name)!.pattern }}</p> | ||
| 200 | - </template> | ||
| 201 | - <span v-else class="text-xs text-dimmed">-</span> | ||
| 202 | - </td> | ||
| 203 | - <td class="px-3 py-2 align-middle"> | ||
| 204 | - <div class="flex items-center gap-2"> | ||
| 205 | - <template v-if="r.via.some(v => v !== 'add')"> | ||
| 206 | - <USwitch | ||
| 207 | - size="sm" | ||
| 208 | - :model-value="!matchOf(config.exclude[r.type], r.name)" | ||
| 209 | - :disabled="matchOf(config.exclude[r.type], r.name)?.exact === false" | ||
| 210 | - :aria-label="`Include ${r.name}`" | ||
| 211 | - @update:model-value="(v: boolean) => setIncluded(r.type, r.name, v)" | ||
| 212 | - /> | ||
| 213 | - <span class="text-xs text-muted">{{ matchOf(config.exclude[r.type], r.name) ? (matchOf(config.exclude[r.type], r.name)!.exact ? 'excluded' : matchOf(config.exclude[r.type], r.name)!.pattern) : 'included' }}</span> | ||
| 214 | - </template> | ||
| 215 | - <UButton v-if="r.via.includes('add')" icon="i-lucide-x" size="xs" color="neutral" variant="ghost" aria-label="Remove from this scope" class="ml-auto" @click="removeAdded(r.type, r.name)" /> | ||
| 216 | - </div> | ||
| 217 | - </td> | ||
| 218 | - </tr> | ||
| 219 | - </tbody> | ||
| 220 | - </table> | ||
| 221 | - </div> | ||
| 222 | - <p v-if="patterns.length" class="flex flex-wrap items-center gap-1.5 text-xs text-muted"> | ||
| 223 | - Patterns kept as is: | ||
| 224 | - <UBadge v-for="(p, i) in patterns" :key="i" :label="`${p.where}: ${p.pattern}`" size="sm" color="neutral" variant="outline" class="font-mono" /> | ||
| 225 | - </p> | ||
| 226 | - </section> | ||
| 227 | - | ||
| 228 | - <!-- Hide global (projects only) --> | ||
| 229 | - <section v-if="!home" class="space-y-3"> | ||
| 230 | - <div> | ||
| 231 | - <h2 class="font-semibold">Hide global items</h2> | ||
| 232 | - <p class="text-xs text-muted">Hide Home skills and MCP servers in this project so they do not clutter its context.</p> | ||
| 233 | - </div> | ||
| 234 | - <UAlert | ||
| 235 | - color="neutral" | ||
| 236 | - variant="subtle" | ||
| 237 | - icon="i-lucide-info" | ||
| 238 | - title="Agent support" | ||
| 239 | - description="Claude Code can hide global skills (skillOverrides) and MCP servers (deniedMcpServers) per project. Codex cannot hide them per project: it can only disable them globally in ~/.codex/config.toml." | ||
| 240 | - /> | ||
| 241 | - <div class="flex flex-wrap items-center gap-3"> | ||
| 242 | - <UTabs v-model="hideTab" :items="hideTabs" :content="false" variant="pill" color="neutral" size="sm" class="w-auto" /> | ||
| 243 | - <UInput v-model="hideSearch" icon="i-lucide-search" size="sm" placeholder="Search..." class="w-64" /> | ||
| 244 | - </div> | ||
| 245 | - <USkeleton v-if="homeLoading && !homeInv" class="h-24 w-full" /> | ||
| 246 | - <p v-else-if="!hideRows.length" class="rounded-lg border border-dashed border-default p-6 text-center text-sm text-muted">No global {{ hideTab === 'skills' ? 'skill' : 'MCP server' }} found in Home.</p> | ||
| 247 | - <ul v-else class="divide-y divide-default overflow-hidden rounded-xl border border-default"> | ||
| 248 | - <li v-for="[name, agents] in hideRows.slice(0, HIDE_LIMIT)" :key="name" class="flex items-center gap-3 px-3 py-1.5"> | ||
| 249 | - <USwitch | ||
| 250 | - size="sm" | ||
| 251 | - :model-value="!!matchOf(config.hideGlobal[hideTab], name)" | ||
| 252 | - :disabled="matchOf(config.hideGlobal[hideTab], name)?.exact === false" | ||
| 253 | - :aria-label="`Hide ${name}`" | ||
| 254 | - @update:model-value="(v: boolean) => setHidden(name, v)" | ||
| 255 | - /> | ||
| 256 | - <span class="min-w-0 flex-1 truncate text-sm font-medium">{{ name }}</span> | ||
| 257 | - <span v-if="matchOf(config.hideGlobal[hideTab], name)?.exact === false" class="font-mono text-[11px] text-muted">matches {{ matchOf(config.hideGlobal[hideTab], name)!.pattern }}</span> | ||
| 258 | - <div class="flex gap-1"> | ||
| 259 | - <UTooltip v-for="a in [...agents]" :key="a" :text="a === 'claude' ? 'Can be hidden' : 'Codex cannot hide it per project'"> | ||
| 260 | - <span :class="a === 'claude' ? '' : 'opacity-50'"><AgentBadge :agent="a" compact /></span> | ||
| 261 | - </UTooltip> | ||
| 262 | - </div> | ||
| 263 | - </li> | ||
| 264 | - <li v-if="hideRows.length > HIDE_LIMIT" class="p-2 text-center text-xs text-muted">{{ hideRows.length - HIDE_LIMIT }} more: refine the search.</li> | ||
| 265 | - </ul> | ||
| 266 | - </section> | ||
| 267 | - | ||
| 268 | - <!-- Add items --> | ||
| 269 | - <UModal v-model:open="pickerOpen" title="Add items" description="Pick items from the catalog to enable in this scope." :ui="{ content: 'max-w-2xl' }"> | ||
| 270 | - <template #body> | ||
| 271 | - <ItemPicker v-model="pickerSel" :items="pickable" :types="ADD_TYPES" :locked="locked" height="h-80" /> | ||
| 272 | - <p v-if="home" class="mt-2 text-xs text-muted">Home can receive skills, rules and agents. Its MCP servers and hooks are not managed in v1.</p> | ||
| 273 | - </template> | ||
| 274 | - <template #footer> | ||
| 275 | - <div class="flex w-full justify-end gap-2"> | ||
| 276 | - <UButton label="Cancel" color="neutral" variant="outline" @click="pickerOpen = false" /> | ||
| 277 | - <UButton label="Apply selection" icon="i-lucide-check" @click="confirmPicker" /> | ||
| 278 | - </div> | ||
| 279 | - </template> | ||
| 280 | - </UModal> | ||
| 281 | - </div> | ||
| 282 | -</template> | ||
deleted
packages/ui/app/components/ScopeView.vue +0 -483 | deleted file mode 100644 | ||
| @@ -1,483 +0,0 @@ | ||
| 1 | -<script setup lang="ts"> | |
| 2 | -import type { AgentId, CatalogRef, InventoryItem, ItemType, ScopeConfig, ScopeState } from '@skilly/core' | |
| 3 | - | |
| 4 | -const props = defineProps<{ kind: 'home' | 'project', projectId?: string }>() | |
| 5 | - | |
| 6 | -const { request } = useApi() | |
| 7 | -const { projects, remove, refresh: refreshProjects } = useProjects() | |
| 8 | -const catalog = useCatalog() | |
| 9 | -const toast = useToast() | |
| 10 | -const router = useRouter() | |
| 11 | - | |
| 12 | -const scopeId = computed(() => (props.kind === 'home' ? 'home' : props.projectId!)) | |
| 13 | -const state = ref<ScopeState | null>(null) | |
| 14 | -const inventory = computed(() => state.value?.inventory ?? null) | |
| 15 | -const statuses = computed(() => state.value?.statuses ?? {}) | |
| 16 | -const plan = computed(() => state.value?.plan ?? null) | |
| 17 | -const loading = ref(true) | |
| 18 | -const error = ref<string | null>(null) | |
| 19 | - | |
| 20 | -// Local editable copy of the config: edits are shown at once and saved (debounced) in the background. | |
| 21 | -const config = ref<ScopeConfig | null>(null) | |
| 22 | -const saving = ref(false) | |
| 23 | - | |
| 24 | -const agentFilter = ref<'all' | AgentId>('all') | |
| 25 | -const search = ref('') | |
| 26 | -const tab = ref<ItemType | 'config'>('skill') | |
| 27 | -const selected = ref<InventoryItem | null>(null) | |
| 28 | -const detailOpen = ref(false) | |
| 29 | -const removeOpen = ref(false) | |
| 30 | -const removing = ref(false) | |
| 31 | -const errorsOpen = ref(false) | |
| 32 | - | |
| 33 | -const project = computed(() => (props.kind === 'project' ? projects.value.find(p => p.id === props.projectId) : undefined)) | |
| 34 | -const title = computed(() => (props.kind === 'home' ? 'Home' : project.value?.name ?? props.projectId ?? 'Project')) | |
| 35 | -const rootPath = computed(() => inventory.value?.root ?? project.value?.path ?? '') | |
| 36 | -const folderMissing = computed(() => props.kind === 'project' && project.value && !project.value.exists) | |
| 37 | - | |
| 38 | -async function load(showLoading = true) { | |
| 39 | - if (showLoading) loading.value = true | |
| 40 | - try { | |
| 41 | - state.value = await request<ScopeState>(`/api/scopes/${encodeURIComponent(scopeId.value)}/state`, { silent: true }) | |
| 42 | - config.value = state.value.config ? cloneConfig(state.value.config) : null | |
| 43 | - error.value = null | |
| 44 | - } catch (e) { | |
| 45 | - error.value = (e as Error).message | |
| 46 | - // The folder may have disappeared: refresh the summaries so `exists` is up to date. | |
| 47 | - if (props.kind === 'project') await refreshProjects() | |
| 48 | - } finally { | |
| 49 | - loading.value = false | |
| 50 | - } | |
| 51 | -} | |
| 52 | - | |
| 53 | -async function rescan() { | |
| 54 | - await load(false) | |
| 55 | - if (!error.value) toast.add({ title: 'Rescan complete', icon: 'i-lucide-check', color: 'success' }) | |
| 56 | -} | |
| 57 | - | |
| 58 | -onMounted(async () => { | |
| 59 | - if (!catalog.data.value) catalog.refresh() | |
| 60 | - await load() | |
| 61 | - if (config.value) tab.value = 'config' | |
| 62 | -}) | |
| 63 | - | |
| 64 | -/* ------------------------------------------------ Config */ | |
| 65 | - | |
| 66 | -let timer: ReturnType<typeof setTimeout> | undefined | |
| 67 | -let seq = 0 | |
| 68 | - | |
| 69 | -function saveConfig(next: ScopeConfig, immediate = false) { | |
| 70 | - config.value = next | |
| 71 | - const mine = ++seq | |
| 72 | - saving.value = true | |
| 73 | - clearTimeout(timer) | |
| 74 | - const run = async () => { | |
| 75 | - try { | |
| 76 | - const res = await request<ScopeState>(`/api/scopes/${encodeURIComponent(scopeId.value)}/config`, { method: 'PUT', body: next, silent: true }) | |
| 77 | - state.value = res | |
| 78 | - // Only adopt the server copy when no newer edit happened meanwhile. | |
| 79 | - if (mine === seq) config.value = res.config ? cloneConfig(res.config) : next | |
| 80 | - } catch (e) { | |
| 81 | - toast.add({ title: 'Could not save the configuration', description: (e as Error).message, color: 'error', icon: 'i-lucide-circle-alert' }) | |
| 82 | - if (mine === seq) await load(false) | |
| 83 | - } finally { | |
| 84 | - if (mine === seq) saving.value = false | |
| 85 | - } | |
| 86 | - } | |
| 87 | - if (immediate) run() | |
| 88 | - else timer = setTimeout(run, 400) | |
| 89 | -} | |
| 90 | -onBeforeUnmount(() => clearTimeout(timer)) | |
| 91 | - | |
| 92 | -const setupTargets = ref<AgentId[]>([]) | |
| 93 | -watch(inventory, (inv) => { | |
| 94 | - if (!setupTargets.value.length) setupTargets.value = inv?.agentsDetected.length ? [...inv.agentsDetected] : ['claude', 'codex'] | |
| 95 | -}, { immediate: true }) | |
| 96 | -function toggleSetupTarget(a: AgentId, on: boolean | 'indeterminate') { | |
| 97 | - setupTargets.value = on === true ? [...new Set([...setupTargets.value, a])] : setupTargets.value.filter(x => x !== a) | |
| 98 | -} | |
| 99 | -async function enableManagement() { | |
| 100 | - saveConfig(emptyConfig(setupTargets.value), true) | |
| 101 | - tab.value = 'config' | |
| 102 | -} | |
| 103 | - | |
| 104 | -/* ------------------------------------------------ Inventory */ | |
| 105 | - | |
| 106 | -const matches = (item: InventoryItem, q: string) => { | |
| 107 | - const d = item.details | |
| 108 | - const hay = [ | |
| 109 | - item.name, item.description, item.source.path, item.source.plugin, | |
| 110 | - d.type === 'mcp' ? `${d.command ?? ''} ${d.url ?? ''} ${d.transport}` : '', | |
| 111 | - d.type === 'hook' ? `${d.event} ${d.matcher ?? ''} ${d.command ?? ''}` : '', | |
| 112 | - d.type === 'agent' ? d.model : '' | |
| 113 | - ].join(' ').toLowerCase() | |
| 114 | - return hay.includes(q) | |
| 115 | -} | |
| 116 | - | |
| 117 | -const filtered = computed(() => { | |
| 118 | - const q = search.value.trim().toLowerCase() | |
| 119 | - return (inventory.value?.items ?? []).filter(i => | |
| 120 | - (agentFilter.value === 'all' || i.agent === agentFilter.value) && (!q || matches(i, q)) | |
| 121 | - ) | |
| 122 | -}) | |
| 123 | - | |
| 124 | -const byType = computed(() => { | |
| 125 | - const m = Object.fromEntries(TYPE_ORDER.map(t => [t, [] as InventoryItem[]])) as Record<ItemType, InventoryItem[]> | |
| 126 | - for (const i of filtered.value) m[i.type].push(i) | |
| 127 | - // Sort by name, then agent; hooks and instruction files keep their scan order (it is meaningful). | |
| 128 | - for (const t of ['skill', 'rule', 'mcp', 'agent'] as const) { | |
| 129 | - m[t].sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }) || a.agent.localeCompare(b.agent)) | |
| 130 | - } | |
| 131 | - return m | |
| 132 | -}) | |
| 133 | - | |
| 134 | -const enabledCount = computed(() => (config.value ? resolveItems(catalog.presets.value, config.value.presets, config.value.add).entries.length : 0)) | |
| 135 | - | |
| 136 | -const tabs = computed(() => [ | |
| 137 | - ...(config.value | |
| 138 | - ? [{ label: 'Configuration', icon: 'i-lucide-sliders-horizontal', value: 'config', badge: { label: String(enabledCount.value), color: 'neutral' as const, variant: 'subtle' as const } }] | |
| 139 | - : []), | |
| 140 | - ...TYPE_ORDER.map(t => ({ | |
| 141 | - label: TYPE_META[t].label, | |
| 142 | - icon: TYPE_META[t].icon, | |
| 143 | - value: t, | |
| 144 | - badge: { label: String(byType.value[t].length), color: 'neutral' as const, variant: 'subtle' as const } | |
| 145 | - })) | |
| 146 | -]) | |
| 147 | - | |
| 148 | -const agentOptions = [ | |
| 149 | - { label: 'All', value: 'all' }, | |
| 150 | - { label: 'Claude Code', value: 'claude' }, | |
| 151 | - { label: 'Codex', value: 'codex' } | |
| 152 | -] | |
| 153 | - | |
| 154 | -function open(item: InventoryItem) { | |
| 155 | - selected.value = item | |
| 156 | - detailOpen.value = true | |
| 157 | -} | |
| 158 | - | |
| 159 | -async function copyPath() { | |
| 160 | - try { | |
| 161 | - await navigator.clipboard.writeText(rootPath.value) | |
| 162 | - toast.add({ title: 'Path copied', icon: 'i-lucide-check', color: 'success' }) | |
| 163 | - } catch { | |
| 164 | - toast.add({ title: 'Could not copy', color: 'error' }) | |
| 165 | - } | |
| 166 | -} | |
| 167 | - | |
| 168 | -async function confirmRemove() { | |
| 169 | - if (!props.projectId) return | |
| 170 | - removing.value = true | |
| 171 | - try { | |
| 172 | - await remove(props.projectId) | |
| 173 | - removeOpen.value = false | |
| 174 | - toast.add({ title: 'Project removed from skilly', description: 'No file was deleted.', icon: 'i-lucide-check', color: 'success' }) | |
| 175 | - await router.push('/') | |
| 176 | - } catch { | |
| 177 | - // toast already shown by useApi | |
| 178 | - } finally { | |
| 179 | - removing.value = false | |
| 180 | - } | |
| 181 | -} | |
| 182 | - | |
| 183 | -/* ------------------------------------------------ Selection & actions */ | |
| 184 | - | |
| 185 | -const selection = ref<string[]>([]) | |
| 186 | -watch(tab, () => { selection.value = [] }) | |
| 187 | -const selectedItems = computed(() => (inventory.value?.items ?? []).filter(i => selection.value.includes(i.id))) | |
| 188 | -const importable = computed(() => selectedItems.value.filter(i => ['not-in-catalog', 'differs-from-catalog'].includes(statuses.value[i.id]?.state ?? ''))) | |
| 189 | -const removable = computed(() => (props.kind === 'home' ? selectedItems.value.filter(i => ['skill', 'rule', 'agent'].includes(i.type)) : [])) | |
| 190 | - | |
| 191 | -const importItems = ref<InventoryItem[]>([]) | |
| 192 | -const importOpen = ref(false) | |
| 193 | -function askImport(items: InventoryItem[]) { | |
| 194 | - importItems.value = items | |
| 195 | - importOpen.value = true | |
| 196 | -} | |
| 197 | -const globalItems = ref<InventoryItem[]>([]) | |
| 198 | -const globalOpen = ref(false) | |
| 199 | -function askRemoveGlobal(items: InventoryItem[]) { | |
| 200 | - globalItems.value = items | |
| 201 | - globalOpen.value = true | |
| 202 | -} | |
| 203 | -function openInCatalog(ref: CatalogRef) { | |
| 204 | - navigateTo(`/catalog/${ref.type}/${encodeURIComponent(ref.name)}`) | |
| 205 | -} | |
| 206 | -async function afterCatalogChange() { | |
| 207 | - selection.value = [] | |
| 208 | - await Promise.all([load(false), catalog.refresh()]) | |
| 209 | -} | |
| 210 | - | |
| 211 | -/* ------------------------------------------------ Plan */ | |
| 212 | - | |
| 213 | -const pending = computed(() => (plan.value?.actions ?? []).filter(a => a.kind !== 'unchanged')) | |
| 214 | -const conflictCount = computed(() => pending.value.filter(a => a.kind === 'conflict').length) | |
| 215 | -const applyOpen = ref(false) | |
| 216 | -function onApplied(next: ScopeState) { | |
| 217 | - state.value = next | |
| 218 | - config.value = next.config ? cloneConfig(next.config) : null | |
| 219 | - selection.value = [] | |
| 220 | - if (props.kind === 'project') refreshProjects() | |
| 221 | -} | |
| 222 | - | |
| 223 | -watch(config, (c) => { if (!c && tab.value === 'config') tab.value = 'skill' }) | |
| 224 | - | |
| 225 | -const homeReadOnly = computed(() => props.kind === 'home' && (tab.value === 'mcp' || tab.value === 'hook')) | |
| 226 | -const AGENT_LIST: AgentId[] = ['claude', 'codex'] | |
| 227 | -</script> | |
| 228 | - | |
| 229 | -<template> | |
| 230 | - <UDashboardPanel :id="`scope-${kind}`"> | |
| 231 | - <template #header> | |
| 232 | - <UDashboardNavbar :icon="kind === 'home' ? 'i-lucide-house' : 'i-lucide-folder'"> | |
| 233 | - <template #title> | |
| 234 | - <!-- The default title is a flex box, which cannot show an ellipsis for long project names --> | |
| 235 | - <span class="truncate" :title="title">{{ title }}</span> | |
| 236 | - </template> | |
| 237 | - <template #right> | |
| 238 | - <UButton | |
| 239 | - icon="i-lucide-refresh-cw" | |
| 240 | - label="Rescan" | |
| 241 | - color="neutral" | |
| 242 | - variant="outline" | |
| 243 | - :loading="loading && !!inventory" | |
| 244 | - @click="rescan" | |
| 245 | - /> | |
| 246 | - <UButton | |
| 247 | - v-if="kind === 'project'" | |
| 248 | - icon="i-lucide-trash-2" | |
| 249 | - label="Remove from skilly" | |
| 250 | - color="error" | |
| 251 | - variant="soft" | |
| 252 | - @click="removeOpen = true" | |
| 253 | - /> | |
| 254 | - </template> | |
| 255 | - </UDashboardNavbar> | |
| 256 | - </template> | |
| 257 | - | |
| 258 | - <template #body> | |
| 259 | - <!-- Loading --> | |
| 260 | - <div v-if="loading && !inventory" class="space-y-3"> | |
| 261 | - <USkeleton class="h-6 w-1/2" /> | |
| 262 | - <USkeleton class="h-10 w-full" /> | |
| 263 | - <USkeleton v-for="i in 5" :key="i" class="h-12 w-full" /> | |
| 264 | - </div> | |
| 265 | - | |
| 266 | - <!-- Error --> | |
| 267 | - <div v-else-if="error && !inventory" class="mx-auto max-w-lg py-12"> | |
| 268 | - <UAlert | |
| 269 | - v-if="folderMissing" | |
| 270 | - color="warning" | |
| 271 | - variant="subtle" | |
| 272 | - icon="i-lucide-folder-x" | |
| 273 | - title="This folder no longer exists on disk" | |
| 274 | - :description="`${rootPath} was moved or deleted. You can remove the project from skilly, or retry once the folder is back.`" | |
| 275 | - :actions="[ | |
| 276 | - { label: 'Retry', color: 'neutral', variant: 'outline', onClick: () => load() }, | |
| 277 | - { label: 'Remove from skilly', color: 'error', variant: 'soft', onClick: () => { removeOpen = true } } | |
| 278 | - ]" | |
| 279 | - /> | |
| 280 | - <UAlert | |
| 281 | - v-else | |
| 282 | - color="error" | |
| 283 | - variant="subtle" | |
| 284 | - icon="i-lucide-circle-alert" | |
| 285 | - title="Could not load this scope" | |
| 286 | - :description="error" | |
| 287 | - :actions="[{ label: 'Retry', color: 'neutral', variant: 'outline', onClick: () => load() }]" | |
| 288 | - /> | |
| 289 | - </div> | |
| 290 | - | |
| 291 | - <div v-else-if="inventory" class="flex flex-col gap-4"> | |
| 292 | - <!-- Header info --> | |
| 293 | - <div class="flex flex-wrap items-center gap-x-4 gap-y-2 text-sm"> | |
| 294 | - <div class="flex min-w-0 items-center gap-1"> | |
| 295 | - <span class="truncate font-mono text-xs text-muted" :title="rootPath">{{ rootPath }}</span> | |
| 296 | - <UTooltip text="Copy path"> | |
| 297 | - <UButton icon="i-lucide-copy" size="xs" color="neutral" variant="ghost" aria-label="Copy path" @click="copyPath" /> | |
| 298 | - </UTooltip> | |
| 299 | - </div> | |
| 300 | - <div class="flex items-center gap-1.5"> | |
| 301 | - <AgentBadge v-for="a in inventory.agentsDetected" :key="a" :agent="a" /> | |
| 302 | - <span v-if="!inventory.agentsDetected.length" class="text-muted">No agent detected</span> | |
| 303 | - </div> | |
| 304 | - <span class="text-xs text-muted">Scanned {{ formatDate(inventory.scannedAt) }}</span> | |
| 305 | - </div> | |
| 306 | - | |
| 307 | - <UAlert | |
| 308 | - v-if="folderMissing" | |
| 309 | - color="warning" | |
| 310 | - variant="subtle" | |
| 311 | - icon="i-lucide-triangle-alert" | |
| 312 | - title="This folder no longer exists on disk" | |
| 313 | - description="The inventory below may be empty or outdated. You can remove the project from skilly." | |
| 314 | - /> | |
| 315 | - | |
| 316 | - <!-- Scan errors --> | |
| 317 | - <UAlert | |
| 318 | - v-if="inventory.errors.length" | |
| 319 | - color="warning" | |
| 320 | - variant="subtle" | |
| 321 | - icon="i-lucide-triangle-alert" | |
| 322 | - :title="`${inventory.errors.length} scan error${inventory.errors.length > 1 ? 's' : ''}`" | |
| 323 | - > | |
| 324 | - <template #description> | |
| 325 | - <UCollapsible v-model:open="errorsOpen"> | |
| 326 | - <UButton | |
| 327 | - :label="errorsOpen ? 'Hide details' : 'Show details'" | |
| 328 | - :trailing-icon="errorsOpen ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'" | |
| 329 | - color="neutral" | |
| 330 | - variant="link" | |
| 331 | - size="xs" | |
| 332 | - class="px-0" | |
| 333 | - /> | |
| 334 | - <template #content> | |
| 335 | - <ul class="mt-2 space-y-1.5 text-xs"> | |
| 336 | - <li v-for="(e, i) in inventory.errors" :key="i"> | |
| 337 | - <AgentBadge v-if="e.agent" :agent="e.agent" compact class="mr-1" /> | |
| 338 | - <span class="font-mono break-all" :title="e.path">{{ relativeTo(rootPath, e.path) }}</span> | |
| 339 | - <span class="block text-muted">{{ e.message }}</span> | |
| 340 | - </li> | |
| 341 | - </ul> | |
| 342 | - </template> | |
| 343 | - </UCollapsible> | |
| 344 | - </template> | |
| 345 | - </UAlert> | |
| 346 | - | |
| 347 | - <!-- Not managed yet --> | |
| 348 | - <div v-if="!config" class="flex flex-wrap items-center gap-x-6 gap-y-3 rounded-xl border border-dashed border-default p-4"> | |
| 349 | - <div class="min-w-0 flex-1 basis-72"> | |
| 350 | - <h2 class="flex items-center gap-2 font-semibold"><UIcon name="i-lucide-wand-sparkles" class="size-4 text-primary" />{{ kind === 'home' ? 'Manage Home with skilly' : 'Manage this project with skilly' }}</h2> | |
| 351 | - <p class="text-sm text-muted"> | |
| 352 | - Enable presets and catalog items, switch skills to on demand and keep files in sync. | |
| 353 | - {{ kind === 'home' ? 'Settings are stored in ~/.skilly/home.json.' : 'A skilly.json file is created at the project root; commit it with the project.' }} | |
| 354 | - </p> | |
| 355 | - </div> | |
| 356 | - <div class="flex items-center gap-4"> | |
| 357 | - <span class="text-xs font-medium uppercase tracking-wide text-muted">Targets</span> | |
| 358 | - <UCheckbox v-for="a in AGENT_LIST" :key="a" :model-value="setupTargets.includes(a)" :label="AGENT_LABELS[a]" @update:model-value="(v) => toggleSetupTarget(a, v)" /> | |
| 359 | - </div> | |
| 360 | - <UButton label="Start managing" icon="i-lucide-check" :loading="saving" :disabled="!setupTargets.length" @click="enableManagement" /> | |
| 361 | - </div> | |
| 362 | - | |
| 363 | - <!-- Targets and presets --> | |
| 364 | - <ScopeConfigBar | |
| 365 | - v-else | |
| 366 | - :config="config" | |
| 367 | - :presets="catalog.presets.value" | |
| 368 | - :catalog-exists="catalog.exists.value" | |
| 369 | - :saving="saving" | |
| 370 | - @update:config="saveConfig" | |
| 371 | - /> | |
| 372 | - | |
| 373 | - <!-- Type tabs --> | |
| 374 | - <UTabs v-model="tab" :items="tabs" :content="false" variant="link" class="w-full" :ui="{ list: 'overflow-x-auto overflow-y-hidden [scrollbar-width:thin]', trigger: 'shrink-0 max-xl:px-1.5 max-xl:gap-1 max-xl:text-xs' }" /> | |
| 375 | - | |
| 376 | - <!-- Configuration tab --> | |
| 377 | - <ScopeConfigPanel | |
| 378 | - v-if="tab === 'config' && config" | |
| 379 | - :config="config" | |
| 380 | - :items="catalog.items.value" | |
| 381 | - :presets="catalog.presets.value" | |
| 382 | - :home="kind === 'home'" | |
| 383 | - @update:config="saveConfig" | |
| 384 | - /> | |
| 385 | - | |
| 386 | - <!-- Inventory tabs --> | |
| 387 | - <template v-else> | |
| 388 | - <div class="flex flex-wrap items-center gap-3"> | |
| 389 | - <UTabs | |
| 390 | - v-model="agentFilter" | |
| 391 | - :items="agentOptions" | |
| 392 | - :content="false" | |
| 393 | - size="sm" | |
| 394 | - variant="pill" | |
| 395 | - color="neutral" | |
| 396 | - class="w-auto" | |
| 397 | - /> | |
| 398 | - <UInput | |
| 399 | - v-model="search" | |
| 400 | - icon="i-lucide-search" | |
| 401 | - placeholder="Search name, description, command..." | |
| 402 | - class="w-72" | |
| 403 | - > | |
| 404 | - <template v-if="search" #trailing> | |
| 405 | - <UButton icon="i-lucide-x" size="xs" color="neutral" variant="link" aria-label="Clear search" @click="search = ''" /> | |
| 406 | - </template> | |
| 407 | - </UInput> | |
| 408 | - </div> | |
| 409 | - | |
| 410 | - <UAlert | |
| 411 | - v-if="homeReadOnly" | |
| 412 | - color="neutral" | |
| 413 | - variant="subtle" | |
| 414 | - icon="i-lucide-info" | |
| 415 | - :title="`Home ${tab === 'mcp' ? 'MCP servers' : 'hooks'} are not managed in v1`" | |
| 416 | - description="They are entries of your user configuration files, shown read-only. You can still add them to the catalog." | |
| 417 | - /> | |
| 418 | - | |
| 419 | - <!-- Bulk actions --> | |
| 420 | - <div v-if="selection.length" class="flex flex-wrap items-center gap-2 rounded-lg border border-primary/40 bg-primary/5 px-3 py-2 text-sm"> | |
| 421 | - <span class="font-medium">{{ selection.length }} selected</span> | |
| 422 | - <UButton v-if="importable.length" :label="`Add ${importable.length} to catalog`" icon="i-lucide-library" size="xs" @click="askImport(importable)" /> | |
| 423 | - <UButton v-if="removable.length" :label="`Remove ${removable.length} from global`" icon="i-lucide-globe-lock" size="xs" color="error" variant="soft" @click="askRemoveGlobal(removable)" /> | |
| 424 | - <UButton label="Clear" size="xs" color="neutral" variant="ghost" class="ml-auto" @click="selection = []" /> | |
| 425 | - </div> | |
| 426 | - | |
| 427 | - <ItemTable | |
| 428 | - v-model:selection="selection" | |
| 429 | - :type="tab as ItemType" | |
| 430 | - :items="byType[tab as ItemType]" | |
| 431 | - :filtered="!!search || agentFilter !== 'all'" | |
| 432 | - :statuses="statuses" | |
| 433 | - :home="kind === 'home'" | |
| 434 | - actionable | |
| 435 | - @select="open" | |
| 436 | - @add-to-catalog="(i) => askImport([i])" | |
| 437 | - @open-catalog="openInCatalog" | |
| 438 | - @remove-global="(i) => askRemoveGlobal([i])" | |
| 439 | - /> | |
| 440 | - </template> | |
| 441 | - | |
| 442 | - <!-- Pending changes --> | |
| 443 | - <div | |
| 444 | - v-if="plan && (pending.length || plan.missing.length)" | |
| 445 | - class="sticky bottom-0 z-10 flex flex-wrap items-center gap-3 rounded-xl border bg-default/95 px-4 py-3 shadow-lg backdrop-blur" | |
| 446 | - :class="conflictCount ? 'border-warning' : 'border-primary'" | |
| 447 | - > | |
| 448 | - <UIcon :name="conflictCount ? 'i-lucide-triangle-alert' : 'i-lucide-git-pull-request-arrow'" class="size-5 shrink-0" :class="conflictCount ? 'text-warning' : 'text-primary'" /> | |
| 449 | - <div class="min-w-0 flex-1 text-sm"> | |
| 450 | - <template v-if="pending.length"> | |
| 451 | - <span class="font-semibold">{{ pending.length }} change{{ pending.length > 1 ? 's' : '' }} to apply</span> | |
| 452 | - <span v-if="conflictCount" class="text-warning"> - {{ conflictCount }} conflict{{ conflictCount > 1 ? 's' : '' }} to resolve</span> | |
| 453 | - </template> | |
| 454 | - <span v-else class="font-semibold">{{ plan.missing.length }} item(s) referenced but not found in the catalog</span> | |
| 455 | - <span v-if="saving" class="ml-2 text-xs text-muted">saving...</span> | |
| 456 | - </div> | |
| 457 | - <UButton label="Review and apply" icon="i-lucide-list-checks" :disabled="saving || !pending.length" @click="applyOpen = true" /> | |
| 458 | - </div> | |
| 459 | - </div> | |
| 460 | - </template> | |
| 461 | - </UDashboardPanel> | |
| 462 | - | |
| 463 | - <ItemDetail v-model:open="detailOpen" :item="selected" /> | |
| 464 | - | |
| 465 | - <ApplyModal v-if="plan" v-model:open="applyOpen" :scope="scopeId" :plan="plan" :home="kind === 'home'" @applied="onApplied" /> | |
| 466 | - <AddToCatalogModal v-model:open="importOpen" :scope="scopeId" :items="importItems" :statuses="statuses" @done="afterCatalogChange" /> | |
| 467 | - <RemoveFromGlobalModal v-model:open="globalOpen" :items="globalItems" @done="afterCatalogChange" /> | |
| 468 | - | |
| 469 | - <UModal v-model:open="removeOpen" title="Remove project from skilly" description="This only forgets the project in skilly."> | |
| 470 | - <template #body> | |
| 471 | - <p class="text-sm"> | |
| 472 | - <strong>{{ title }}</strong> will disappear from the project list. <strong>No file is deleted</strong>: | |
| 473 | - the folder and its agent configuration stay untouched. You can add it again at any time. | |
| 474 | - </p> | |
| 475 | - </template> | |
| 476 | - <template #footer> | |
| 477 | - <div class="flex w-full justify-end gap-2"> | |
| 478 | - <UButton label="Cancel" color="neutral" variant="outline" @click="removeOpen = false" /> | |
| 479 | - <UButton label="Remove from skilly" color="error" :loading="removing" @click="confirmRemove" /> | |
| 480 | - </div> | |
| 481 | - </template> | |
| 482 | - </UModal> | |
| 483 | -</template> | |
| deleted file mode 100644 | |||
| @@ -1,483 +0,0 @@ | |||
| 1 | -<script setup lang="ts"> | ||
| 2 | -import type { AgentId, CatalogRef, InventoryItem, ItemType, ScopeConfig, ScopeState } from '@skilly/core' | ||
| 3 | - | ||
| 4 | -const props = defineProps<{ kind: 'home' | 'project', projectId?: string }>() | ||
| 5 | - | ||
| 6 | -const { request } = useApi() | ||
| 7 | -const { projects, remove, refresh: refreshProjects } = useProjects() | ||
| 8 | -const catalog = useCatalog() | ||
| 9 | -const toast = useToast() | ||
| 10 | -const router = useRouter() | ||
| 11 | - | ||
| 12 | -const scopeId = computed(() => (props.kind === 'home' ? 'home' : props.projectId!)) | ||
| 13 | -const state = ref<ScopeState | null>(null) | ||
| 14 | -const inventory = computed(() => state.value?.inventory ?? null) | ||
| 15 | -const statuses = computed(() => state.value?.statuses ?? {}) | ||
| 16 | -const plan = computed(() => state.value?.plan ?? null) | ||
| 17 | -const loading = ref(true) | ||
| 18 | -const error = ref<string | null>(null) | ||
| 19 | - | ||
| 20 | -// Local editable copy of the config: edits are shown at once and saved (debounced) in the background. | ||
| 21 | -const config = ref<ScopeConfig | null>(null) | ||
| 22 | -const saving = ref(false) | ||
| 23 | - | ||
| 24 | -const agentFilter = ref<'all' | AgentId>('all') | ||
| 25 | -const search = ref('') | ||
| 26 | -const tab = ref<ItemType | 'config'>('skill') | ||
| 27 | -const selected = ref<InventoryItem | null>(null) | ||
| 28 | -const detailOpen = ref(false) | ||
| 29 | -const removeOpen = ref(false) | ||
| 30 | -const removing = ref(false) | ||
| 31 | -const errorsOpen = ref(false) | ||
| 32 | - | ||
| 33 | -const project = computed(() => (props.kind === 'project' ? projects.value.find(p => p.id === props.projectId) : undefined)) | ||
| 34 | -const title = computed(() => (props.kind === 'home' ? 'Home' : project.value?.name ?? props.projectId ?? 'Project')) | ||
| 35 | -const rootPath = computed(() => inventory.value?.root ?? project.value?.path ?? '') | ||
| 36 | -const folderMissing = computed(() => props.kind === 'project' && project.value && !project.value.exists) | ||
| 37 | - | ||
| 38 | -async function load(showLoading = true) { | ||
| 39 | - if (showLoading) loading.value = true | ||
| 40 | - try { | ||
| 41 | - state.value = await request<ScopeState>(`/api/scopes/${encodeURIComponent(scopeId.value)}/state`, { silent: true }) | ||
| 42 | - config.value = state.value.config ? cloneConfig(state.value.config) : null | ||
| 43 | - error.value = null | ||
| 44 | - } catch (e) { | ||
| 45 | - error.value = (e as Error).message | ||
| 46 | - // The folder may have disappeared: refresh the summaries so `exists` is up to date. | ||
| 47 | - if (props.kind === 'project') await refreshProjects() | ||
| 48 | - } finally { | ||
| 49 | - loading.value = false | ||
| 50 | - } | ||
| 51 | -} | ||
| 52 | - | ||
| 53 | -async function rescan() { | ||
| 54 | - await load(false) | ||
| 55 | - if (!error.value) toast.add({ title: 'Rescan complete', icon: 'i-lucide-check', color: 'success' }) | ||
| 56 | -} | ||
| 57 | - | ||
| 58 | -onMounted(async () => { | ||
| 59 | - if (!catalog.data.value) catalog.refresh() | ||
| 60 | - await load() | ||
| 61 | - if (config.value) tab.value = 'config' | ||
| 62 | -}) | ||
| 63 | - | ||
| 64 | -/* ------------------------------------------------ Config */ | ||
| 65 | - | ||
| 66 | -let timer: ReturnType<typeof setTimeout> | undefined | ||
| 67 | -let seq = 0 | ||
| 68 | - | ||
| 69 | -function saveConfig(next: ScopeConfig, immediate = false) { | ||
| 70 | - config.value = next | ||
| 71 | - const mine = ++seq | ||
| 72 | - saving.value = true | ||
| 73 | - clearTimeout(timer) | ||
| 74 | - const run = async () => { | ||
| 75 | - try { | ||
| 76 | - const res = await request<ScopeState>(`/api/scopes/${encodeURIComponent(scopeId.value)}/config`, { method: 'PUT', body: next, silent: true }) | ||
| 77 | - state.value = res | ||
| 78 | - // Only adopt the server copy when no newer edit happened meanwhile. | ||
| 79 | - if (mine === seq) config.value = res.config ? cloneConfig(res.config) : next | ||
| 80 | - } catch (e) { | ||
| 81 | - toast.add({ title: 'Could not save the configuration', description: (e as Error).message, color: 'error', icon: 'i-lucide-circle-alert' }) | ||
| 82 | - if (mine === seq) await load(false) | ||
| 83 | - } finally { | ||
| 84 | - if (mine === seq) saving.value = false | ||
| 85 | - } | ||
| 86 | - } | ||
| 87 | - if (immediate) run() | ||
| 88 | - else timer = setTimeout(run, 400) | ||
| 89 | -} | ||
| 90 | -onBeforeUnmount(() => clearTimeout(timer)) | ||
| 91 | - | ||
| 92 | -const setupTargets = ref<AgentId[]>([]) | ||
| 93 | -watch(inventory, (inv) => { | ||
| 94 | - if (!setupTargets.value.length) setupTargets.value = inv?.agentsDetected.length ? [...inv.agentsDetected] : ['claude', 'codex'] | ||
| 95 | -}, { immediate: true }) | ||
| 96 | -function toggleSetupTarget(a: AgentId, on: boolean | 'indeterminate') { | ||
| 97 | - setupTargets.value = on === true ? [...new Set([...setupTargets.value, a])] : setupTargets.value.filter(x => x !== a) | ||
| 98 | -} | ||
| 99 | -async function enableManagement() { | ||
| 100 | - saveConfig(emptyConfig(setupTargets.value), true) | ||
| 101 | - tab.value = 'config' | ||
| 102 | -} | ||
| 103 | - | ||
| 104 | -/* ------------------------------------------------ Inventory */ | ||
| 105 | - | ||
| 106 | -const matches = (item: InventoryItem, q: string) => { | ||
| 107 | - const d = item.details | ||
| 108 | - const hay = [ | ||
| 109 | - item.name, item.description, item.source.path, item.source.plugin, | ||
| 110 | - d.type === 'mcp' ? `${d.command ?? ''} ${d.url ?? ''} ${d.transport}` : '', | ||
| 111 | - d.type === 'hook' ? `${d.event} ${d.matcher ?? ''} ${d.command ?? ''}` : '', | ||
| 112 | - d.type === 'agent' ? d.model : '' | ||
| 113 | - ].join(' ').toLowerCase() | ||
| 114 | - return hay.includes(q) | ||
| 115 | -} | ||
| 116 | - | ||
| 117 | -const filtered = computed(() => { | ||
| 118 | - const q = search.value.trim().toLowerCase() | ||
| 119 | - return (inventory.value?.items ?? []).filter(i => | ||
| 120 | - (agentFilter.value === 'all' || i.agent === agentFilter.value) && (!q || matches(i, q)) | ||
| 121 | - ) | ||
| 122 | -}) | ||
| 123 | - | ||
| 124 | -const byType = computed(() => { | ||
| 125 | - const m = Object.fromEntries(TYPE_ORDER.map(t => [t, [] as InventoryItem[]])) as Record<ItemType, InventoryItem[]> | ||
| 126 | - for (const i of filtered.value) m[i.type].push(i) | ||
| 127 | - // Sort by name, then agent; hooks and instruction files keep their scan order (it is meaningful). | ||
| 128 | - for (const t of ['skill', 'rule', 'mcp', 'agent'] as const) { | ||
| 129 | - m[t].sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }) || a.agent.localeCompare(b.agent)) | ||
| 130 | - } | ||
| 131 | - return m | ||
| 132 | -}) | ||
| 133 | - | ||
| 134 | -const enabledCount = computed(() => (config.value ? resolveItems(catalog.presets.value, config.value.presets, config.value.add).entries.length : 0)) | ||
| 135 | - | ||
| 136 | -const tabs = computed(() => [ | ||
| 137 | - ...(config.value | ||
| 138 | - ? [{ label: 'Configuration', icon: 'i-lucide-sliders-horizontal', value: 'config', badge: { label: String(enabledCount.value), color: 'neutral' as const, variant: 'subtle' as const } }] | ||
| 139 | - : []), | ||
| 140 | - ...TYPE_ORDER.map(t => ({ | ||
| 141 | - label: TYPE_META[t].label, | ||
| 142 | - icon: TYPE_META[t].icon, | ||
| 143 | - value: t, | ||
| 144 | - badge: { label: String(byType.value[t].length), color: 'neutral' as const, variant: 'subtle' as const } | ||
| 145 | - })) | ||
| 146 | -]) | ||
| 147 | - | ||
| 148 | -const agentOptions = [ | ||
| 149 | - { label: 'All', value: 'all' }, | ||
| 150 | - { label: 'Claude Code', value: 'claude' }, | ||
| 151 | - { label: 'Codex', value: 'codex' } | ||
| 152 | -] | ||
| 153 | - | ||
| 154 | -function open(item: InventoryItem) { | ||
| 155 | - selected.value = item | ||
| 156 | - detailOpen.value = true | ||
| 157 | -} | ||
| 158 | - | ||
| 159 | -async function copyPath() { | ||
| 160 | - try { | ||
| 161 | - await navigator.clipboard.writeText(rootPath.value) | ||
| 162 | - toast.add({ title: 'Path copied', icon: 'i-lucide-check', color: 'success' }) | ||
| 163 | - } catch { | ||
| 164 | - toast.add({ title: 'Could not copy', color: 'error' }) | ||
| 165 | - } | ||
| 166 | -} | ||
| 167 | - | ||
| 168 | -async function confirmRemove() { | ||
| 169 | - if (!props.projectId) return | ||
| 170 | - removing.value = true | ||
| 171 | - try { | ||
| 172 | - await remove(props.projectId) | ||
| 173 | - removeOpen.value = false | ||
| 174 | - toast.add({ title: 'Project removed from skilly', description: 'No file was deleted.', icon: 'i-lucide-check', color: 'success' }) | ||
| 175 | - await router.push('/') | ||
| 176 | - } catch { | ||
| 177 | - // toast already shown by useApi | ||
| 178 | - } finally { | ||
| 179 | - removing.value = false | ||
| 180 | - } | ||
| 181 | -} | ||
| 182 | - | ||
| 183 | -/* ------------------------------------------------ Selection & actions */ | ||
| 184 | - | ||
| 185 | -const selection = ref<string[]>([]) | ||
| 186 | -watch(tab, () => { selection.value = [] }) | ||
| 187 | -const selectedItems = computed(() => (inventory.value?.items ?? []).filter(i => selection.value.includes(i.id))) | ||
| 188 | -const importable = computed(() => selectedItems.value.filter(i => ['not-in-catalog', 'differs-from-catalog'].includes(statuses.value[i.id]?.state ?? ''))) | ||
| 189 | -const removable = computed(() => (props.kind === 'home' ? selectedItems.value.filter(i => ['skill', 'rule', 'agent'].includes(i.type)) : [])) | ||
| 190 | - | ||
| 191 | -const importItems = ref<InventoryItem[]>([]) | ||
| 192 | -const importOpen = ref(false) | ||
| 193 | -function askImport(items: InventoryItem[]) { | ||
| 194 | - importItems.value = items | ||
| 195 | - importOpen.value = true | ||
| 196 | -} | ||
| 197 | -const globalItems = ref<InventoryItem[]>([]) | ||
| 198 | -const globalOpen = ref(false) | ||
| 199 | -function askRemoveGlobal(items: InventoryItem[]) { | ||
| 200 | - globalItems.value = items | ||
| 201 | - globalOpen.value = true | ||
| 202 | -} | ||
| 203 | -function openInCatalog(ref: CatalogRef) { | ||
| 204 | - navigateTo(`/catalog/${ref.type}/${encodeURIComponent(ref.name)}`) | ||
| 205 | -} | ||
| 206 | -async function afterCatalogChange() { | ||
| 207 | - selection.value = [] | ||
| 208 | - await Promise.all([load(false), catalog.refresh()]) | ||
| 209 | -} | ||
| 210 | - | ||
| 211 | -/* ------------------------------------------------ Plan */ | ||
| 212 | - | ||
| 213 | -const pending = computed(() => (plan.value?.actions ?? []).filter(a => a.kind !== 'unchanged')) | ||
| 214 | -const conflictCount = computed(() => pending.value.filter(a => a.kind === 'conflict').length) | ||
| 215 | -const applyOpen = ref(false) | ||
| 216 | -function onApplied(next: ScopeState) { | ||
| 217 | - state.value = next | ||
| 218 | - config.value = next.config ? cloneConfig(next.config) : null | ||
| 219 | - selection.value = [] | ||
| 220 | - if (props.kind === 'project') refreshProjects() | ||
| 221 | -} | ||
| 222 | - | ||
| 223 | -watch(config, (c) => { if (!c && tab.value === 'config') tab.value = 'skill' }) | ||
| 224 | - | ||
| 225 | -const homeReadOnly = computed(() => props.kind === 'home' && (tab.value === 'mcp' || tab.value === 'hook')) | ||
| 226 | -const AGENT_LIST: AgentId[] = ['claude', 'codex'] | ||
| 227 | -</script> | ||
| 228 | - | ||
| 229 | -<template> | ||
| 230 | - <UDashboardPanel :id="`scope-${kind}`"> | ||
| 231 | - <template #header> | ||
| 232 | - <UDashboardNavbar :icon="kind === 'home' ? 'i-lucide-house' : 'i-lucide-folder'"> | ||
| 233 | - <template #title> | ||
| 234 | - <!-- The default title is a flex box, which cannot show an ellipsis for long project names --> | ||
| 235 | - <span class="truncate" :title="title">{{ title }}</span> | ||
| 236 | - </template> | ||
| 237 | - <template #right> | ||
| 238 | - <UButton | ||
| 239 | - icon="i-lucide-refresh-cw" | ||
| 240 | - label="Rescan" | ||
| 241 | - color="neutral" | ||
| 242 | - variant="outline" | ||
| 243 | - :loading="loading && !!inventory" | ||
| 244 | - @click="rescan" | ||
| 245 | - /> | ||
| 246 | - <UButton | ||
| 247 | - v-if="kind === 'project'" | ||
| 248 | - icon="i-lucide-trash-2" | ||
| 249 | - label="Remove from skilly" | ||
| 250 | - color="error" | ||
| 251 | - variant="soft" | ||
| 252 | - @click="removeOpen = true" | ||
| 253 | - /> | ||
| 254 | - </template> | ||
| 255 | - </UDashboardNavbar> | ||
| 256 | - </template> | ||
| 257 | - | ||
| 258 | - <template #body> | ||
| 259 | - <!-- Loading --> | ||
| 260 | - <div v-if="loading && !inventory" class="space-y-3"> | ||
| 261 | - <USkeleton class="h-6 w-1/2" /> | ||
| 262 | - <USkeleton class="h-10 w-full" /> | ||
| 263 | - <USkeleton v-for="i in 5" :key="i" class="h-12 w-full" /> | ||
| 264 | - </div> | ||
| 265 | - | ||
| 266 | - <!-- Error --> | ||
| 267 | - <div v-else-if="error && !inventory" class="mx-auto max-w-lg py-12"> | ||
| 268 | - <UAlert | ||
| 269 | - v-if="folderMissing" | ||
| 270 | - color="warning" | ||
| 271 | - variant="subtle" | ||
| 272 | - icon="i-lucide-folder-x" | ||
| 273 | - title="This folder no longer exists on disk" | ||
| 274 | - :description="`${rootPath} was moved or deleted. You can remove the project from skilly, or retry once the folder is back.`" | ||
| 275 | - :actions="[ | ||
| 276 | - { label: 'Retry', color: 'neutral', variant: 'outline', onClick: () => load() }, | ||
| 277 | - { label: 'Remove from skilly', color: 'error', variant: 'soft', onClick: () => { removeOpen = true } } | ||
| 278 | - ]" | ||
| 279 | - /> | ||
| 280 | - <UAlert | ||
| 281 | - v-else | ||
| 282 | - color="error" | ||
| 283 | - variant="subtle" | ||
| 284 | - icon="i-lucide-circle-alert" | ||
| 285 | - title="Could not load this scope" | ||
| 286 | - :description="error" | ||
| 287 | - :actions="[{ label: 'Retry', color: 'neutral', variant: 'outline', onClick: () => load() }]" | ||
| 288 | - /> | ||
| 289 | - </div> | ||
| 290 | - | ||
| 291 | - <div v-else-if="inventory" class="flex flex-col gap-4"> | ||
| 292 | - <!-- Header info --> | ||
| 293 | - <div class="flex flex-wrap items-center gap-x-4 gap-y-2 text-sm"> | ||
| 294 | - <div class="flex min-w-0 items-center gap-1"> | ||
| 295 | - <span class="truncate font-mono text-xs text-muted" :title="rootPath">{{ rootPath }}</span> | ||
| 296 | - <UTooltip text="Copy path"> | ||
| 297 | - <UButton icon="i-lucide-copy" size="xs" color="neutral" variant="ghost" aria-label="Copy path" @click="copyPath" /> | ||
| 298 | - </UTooltip> | ||
| 299 | - </div> | ||
| 300 | - <div class="flex items-center gap-1.5"> | ||
| 301 | - <AgentBadge v-for="a in inventory.agentsDetected" :key="a" :agent="a" /> | ||
| 302 | - <span v-if="!inventory.agentsDetected.length" class="text-muted">No agent detected</span> | ||
| 303 | - </div> | ||
| 304 | - <span class="text-xs text-muted">Scanned {{ formatDate(inventory.scannedAt) }}</span> | ||
| 305 | - </div> | ||
| 306 | - | ||
| 307 | - <UAlert | ||
| 308 | - v-if="folderMissing" | ||
| 309 | - color="warning" | ||
| 310 | - variant="subtle" | ||
| 311 | - icon="i-lucide-triangle-alert" | ||
| 312 | - title="This folder no longer exists on disk" | ||
| 313 | - description="The inventory below may be empty or outdated. You can remove the project from skilly." | ||
| 314 | - /> | ||
| 315 | - | ||
| 316 | - <!-- Scan errors --> | ||
| 317 | - <UAlert | ||
| 318 | - v-if="inventory.errors.length" | ||
| 319 | - color="warning" | ||
| 320 | - variant="subtle" | ||
| 321 | - icon="i-lucide-triangle-alert" | ||
| 322 | - :title="`${inventory.errors.length} scan error${inventory.errors.length > 1 ? 's' : ''}`" | ||
| 323 | - > | ||
| 324 | - <template #description> | ||
| 325 | - <UCollapsible v-model:open="errorsOpen"> | ||
| 326 | - <UButton | ||
| 327 | - :label="errorsOpen ? 'Hide details' : 'Show details'" | ||
| 328 | - :trailing-icon="errorsOpen ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'" | ||
| 329 | - color="neutral" | ||
| 330 | - variant="link" | ||
| 331 | - size="xs" | ||
| 332 | - class="px-0" | ||
| 333 | - /> | ||
| 334 | - <template #content> | ||
| 335 | - <ul class="mt-2 space-y-1.5 text-xs"> | ||
| 336 | - <li v-for="(e, i) in inventory.errors" :key="i"> | ||
| 337 | - <AgentBadge v-if="e.agent" :agent="e.agent" compact class="mr-1" /> | ||
| 338 | - <span class="font-mono break-all" :title="e.path">{{ relativeTo(rootPath, e.path) }}</span> | ||
| 339 | - <span class="block text-muted">{{ e.message }}</span> | ||
| 340 | - </li> | ||
| 341 | - </ul> | ||
| 342 | - </template> | ||
| 343 | - </UCollapsible> | ||
| 344 | - </template> | ||
| 345 | - </UAlert> | ||
| 346 | - | ||
| 347 | - <!-- Not managed yet --> | ||
| 348 | - <div v-if="!config" class="flex flex-wrap items-center gap-x-6 gap-y-3 rounded-xl border border-dashed border-default p-4"> | ||
| 349 | - <div class="min-w-0 flex-1 basis-72"> | ||
| 350 | - <h2 class="flex items-center gap-2 font-semibold"><UIcon name="i-lucide-wand-sparkles" class="size-4 text-primary" />{{ kind === 'home' ? 'Manage Home with skilly' : 'Manage this project with skilly' }}</h2> | ||
| 351 | - <p class="text-sm text-muted"> | ||
| 352 | - Enable presets and catalog items, switch skills to on demand and keep files in sync. | ||
| 353 | - {{ kind === 'home' ? 'Settings are stored in ~/.skilly/home.json.' : 'A skilly.json file is created at the project root; commit it with the project.' }} | ||
| 354 | - </p> | ||
| 355 | - </div> | ||
| 356 | - <div class="flex items-center gap-4"> | ||
| 357 | - <span class="text-xs font-medium uppercase tracking-wide text-muted">Targets</span> | ||
| 358 | - <UCheckbox v-for="a in AGENT_LIST" :key="a" :model-value="setupTargets.includes(a)" :label="AGENT_LABELS[a]" @update:model-value="(v) => toggleSetupTarget(a, v)" /> | ||
| 359 | - </div> | ||
| 360 | - <UButton label="Start managing" icon="i-lucide-check" :loading="saving" :disabled="!setupTargets.length" @click="enableManagement" /> | ||
| 361 | - </div> | ||
| 362 | - | ||
| 363 | - <!-- Targets and presets --> | ||
| 364 | - <ScopeConfigBar | ||
| 365 | - v-else | ||
| 366 | - :config="config" | ||
| 367 | - :presets="catalog.presets.value" | ||
| 368 | - :catalog-exists="catalog.exists.value" | ||
| 369 | - :saving="saving" | ||
| 370 | - @update:config="saveConfig" | ||
| 371 | - /> | ||
| 372 | - | ||
| 373 | - <!-- Type tabs --> | ||
| 374 | - <UTabs v-model="tab" :items="tabs" :content="false" variant="link" class="w-full" :ui="{ list: 'overflow-x-auto overflow-y-hidden [scrollbar-width:thin]', trigger: 'shrink-0 max-xl:px-1.5 max-xl:gap-1 max-xl:text-xs' }" /> | ||
| 375 | - | ||
| 376 | - <!-- Configuration tab --> | ||
| 377 | - <ScopeConfigPanel | ||
| 378 | - v-if="tab === 'config' && config" | ||
| 379 | - :config="config" | ||
| 380 | - :items="catalog.items.value" | ||
| 381 | - :presets="catalog.presets.value" | ||
| 382 | - :home="kind === 'home'" | ||
| 383 | - @update:config="saveConfig" | ||
| 384 | - /> | ||
| 385 | - | ||
| 386 | - <!-- Inventory tabs --> | ||
| 387 | - <template v-else> | ||
| 388 | - <div class="flex flex-wrap items-center gap-3"> | ||
| 389 | - <UTabs | ||
| 390 | - v-model="agentFilter" | ||
| 391 | - :items="agentOptions" | ||
| 392 | - :content="false" | ||
| 393 | - size="sm" | ||
| 394 | - variant="pill" | ||
| 395 | - color="neutral" | ||
| 396 | - class="w-auto" | ||
| 397 | - /> | ||
| 398 | - <UInput | ||
| 399 | - v-model="search" | ||
| 400 | - icon="i-lucide-search" | ||
| 401 | - placeholder="Search name, description, command..." | ||
| 402 | - class="w-72" | ||
| 403 | - > | ||
| 404 | - <template v-if="search" #trailing> | ||
| 405 | - <UButton icon="i-lucide-x" size="xs" color="neutral" variant="link" aria-label="Clear search" @click="search = ''" /> | ||
| 406 | - </template> | ||
| 407 | - </UInput> | ||
| 408 | - </div> | ||
| 409 | - | ||
| 410 | - <UAlert | ||
| 411 | - v-if="homeReadOnly" | ||
| 412 | - color="neutral" | ||
| 413 | - variant="subtle" | ||
| 414 | - icon="i-lucide-info" | ||
| 415 | - :title="`Home ${tab === 'mcp' ? 'MCP servers' : 'hooks'} are not managed in v1`" | ||
| 416 | - description="They are entries of your user configuration files, shown read-only. You can still add them to the catalog." | ||
| 417 | - /> | ||
| 418 | - | ||
| 419 | - <!-- Bulk actions --> | ||
| 420 | - <div v-if="selection.length" class="flex flex-wrap items-center gap-2 rounded-lg border border-primary/40 bg-primary/5 px-3 py-2 text-sm"> | ||
| 421 | - <span class="font-medium">{{ selection.length }} selected</span> | ||
| 422 | - <UButton v-if="importable.length" :label="`Add ${importable.length} to catalog`" icon="i-lucide-library" size="xs" @click="askImport(importable)" /> | ||
| 423 | - <UButton v-if="removable.length" :label="`Remove ${removable.length} from global`" icon="i-lucide-globe-lock" size="xs" color="error" variant="soft" @click="askRemoveGlobal(removable)" /> | ||
| 424 | - <UButton label="Clear" size="xs" color="neutral" variant="ghost" class="ml-auto" @click="selection = []" /> | ||
| 425 | - </div> | ||
| 426 | - | ||
| 427 | - <ItemTable | ||
| 428 | - v-model:selection="selection" | ||
| 429 | - :type="tab as ItemType" | ||
| 430 | - :items="byType[tab as ItemType]" | ||
| 431 | - :filtered="!!search || agentFilter !== 'all'" | ||
| 432 | - :statuses="statuses" | ||
| 433 | - :home="kind === 'home'" | ||
| 434 | - actionable | ||
| 435 | - @select="open" | ||
| 436 | - @add-to-catalog="(i) => askImport([i])" | ||
| 437 | - @open-catalog="openInCatalog" | ||
| 438 | - @remove-global="(i) => askRemoveGlobal([i])" | ||
| 439 | - /> | ||
| 440 | - </template> | ||
| 441 | - | ||
| 442 | - <!-- Pending changes --> | ||
| 443 | - <div | ||
| 444 | - v-if="plan && (pending.length || plan.missing.length)" | ||
| 445 | - class="sticky bottom-0 z-10 flex flex-wrap items-center gap-3 rounded-xl border bg-default/95 px-4 py-3 shadow-lg backdrop-blur" | ||
| 446 | - :class="conflictCount ? 'border-warning' : 'border-primary'" | ||
| 447 | - > | ||
| 448 | - <UIcon :name="conflictCount ? 'i-lucide-triangle-alert' : 'i-lucide-git-pull-request-arrow'" class="size-5 shrink-0" :class="conflictCount ? 'text-warning' : 'text-primary'" /> | ||
| 449 | - <div class="min-w-0 flex-1 text-sm"> | ||
| 450 | - <template v-if="pending.length"> | ||
| 451 | - <span class="font-semibold">{{ pending.length }} change{{ pending.length > 1 ? 's' : '' }} to apply</span> | ||
| 452 | - <span v-if="conflictCount" class="text-warning"> - {{ conflictCount }} conflict{{ conflictCount > 1 ? 's' : '' }} to resolve</span> | ||
| 453 | - </template> | ||
| 454 | - <span v-else class="font-semibold">{{ plan.missing.length }} item(s) referenced but not found in the catalog</span> | ||
| 455 | - <span v-if="saving" class="ml-2 text-xs text-muted">saving...</span> | ||
| 456 | - </div> | ||
| 457 | - <UButton label="Review and apply" icon="i-lucide-list-checks" :disabled="saving || !pending.length" @click="applyOpen = true" /> | ||
| 458 | - </div> | ||
| 459 | - </div> | ||
| 460 | - </template> | ||
| 461 | - </UDashboardPanel> | ||
| 462 | - | ||
| 463 | - <ItemDetail v-model:open="detailOpen" :item="selected" /> | ||
| 464 | - | ||
| 465 | - <ApplyModal v-if="plan" v-model:open="applyOpen" :scope="scopeId" :plan="plan" :home="kind === 'home'" @applied="onApplied" /> | ||
| 466 | - <AddToCatalogModal v-model:open="importOpen" :scope="scopeId" :items="importItems" :statuses="statuses" @done="afterCatalogChange" /> | ||
| 467 | - <RemoveFromGlobalModal v-model:open="globalOpen" :items="globalItems" @done="afterCatalogChange" /> | ||
| 468 | - | ||
| 469 | - <UModal v-model:open="removeOpen" title="Remove project from skilly" description="This only forgets the project in skilly."> | ||
| 470 | - <template #body> | ||
| 471 | - <p class="text-sm"> | ||
| 472 | - <strong>{{ title }}</strong> will disappear from the project list. <strong>No file is deleted</strong>: | ||
| 473 | - the folder and its agent configuration stay untouched. You can add it again at any time. | ||
| 474 | - </p> | ||
| 475 | - </template> | ||
| 476 | - <template #footer> | ||
| 477 | - <div class="flex w-full justify-end gap-2"> | ||
| 478 | - <UButton label="Cancel" color="neutral" variant="outline" @click="removeOpen = false" /> | ||
| 479 | - <UButton label="Remove from skilly" color="error" :loading="removing" @click="confirmRemove" /> | ||
| 480 | - </div> | ||
| 481 | - </template> | ||
| 482 | - </UModal> | ||
| 483 | -</template> | ||
added
packages/ui/app/components/home/HomeInventory.vue +95 -0 | new file mode 100644 | ||
| @@ -0,0 +1,95 @@ | ||
| 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> | |
| new file mode 100644 | |||
| @@ -0,0 +1,95 @@ | |||
| 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> | ||
added
packages/ui/app/components/home/HomeSuggestions.vue +40 -0 | new file mode 100644 | ||
| @@ -0,0 +1,40 @@ | ||
| 1 | +<script setup lang="ts"> | |
| 2 | +import type { InventoryItem, ItemStatus } from '@skilly/core' | |
| 3 | +import type { ProjectSpecific } from '../../utils/inventoryView' | |
| 4 | + | |
| 5 | +/** | |
| 6 | + * "2 · N global items look project-specific" (mockup 6): gradient tiles that open the Move modal. | |
| 7 | + * Computed client side from catalog usage (brief 4.5); nothing is ever moved automatically. | |
| 8 | + */ | |
| 9 | +defineProps<{ | |
| 10 | + items: InventoryItem[] | |
| 11 | + flagged: Map<string, ProjectSpecific> | |
| 12 | + statuses: Record<string, ItemStatus> | |
| 13 | + /** The inventory above is filtered to these items. */ | |
| 14 | + review: boolean | |
| 15 | +}>() | |
| 16 | +defineEmits<{ toggleReview: [], move: [item: InventoryItem] }>() | |
| 17 | + | |
| 18 | +const catalogLabel = (st?: ItemStatus) => (st?.state === 'in-sync' ? 'in catalog' : 'local') | |
| 19 | +</script> | |
| 20 | + | |
| 21 | +<template> | |
| 22 | + <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 | + <template #actions> | |
| 25 | + <SkButton variant="secondary" pill :label="review ? 'Show all' : 'Review'" @click="$emit('toggleReview')" /> | |
| 26 | + </template> | |
| 27 | + </SkStepHeader> | |
| 28 | + <div class="grid gap-3.5" style="grid-template-columns: repeat(auto-fill, minmax(230px, 1fr))"> | |
| 29 | + <SkSuggestionTile | |
| 30 | + v-for="(i, n) in items" | |
| 31 | + :key="i.id" | |
| 32 | + :variant="suggestionVariant(n)" | |
| 33 | + :name="i.name" | |
| 34 | + :hint="`${AGENT_LABELS[i.agent]} · ${TYPE_TAG[i.type]} · ${catalogLabel(statuses[i.id])}`" | |
| 35 | + :title="flagged.get(i.id)?.hint" | |
| 36 | + @click="$emit('move', i)" | |
| 37 | + /> | |
| 38 | + </div> | |
| 39 | + </SkPanel> | |
| 40 | +</template> | |
| new file mode 100644 | |||
| @@ -0,0 +1,40 @@ | |||
| 1 | +<script setup lang="ts"> | ||
| 2 | +import type { InventoryItem, ItemStatus } from '@skilly/core' | ||
| 3 | +import type { ProjectSpecific } from '../../utils/inventoryView' | ||
| 4 | + | ||
| 5 | +/** | ||
| 6 | + * "2 · N global items look project-specific" (mockup 6): gradient tiles that open the Move modal. | ||
| 7 | + * Computed client side from catalog usage (brief 4.5); nothing is ever moved automatically. | ||
| 8 | + */ | ||
| 9 | +defineProps<{ | ||
| 10 | + items: InventoryItem[] | ||
| 11 | + flagged: Map<string, ProjectSpecific> | ||
| 12 | + statuses: Record<string, ItemStatus> | ||
| 13 | + /** The inventory above is filtered to these items. */ | ||
| 14 | + review: boolean | ||
| 15 | +}>() | ||
| 16 | +defineEmits<{ toggleReview: [], move: [item: InventoryItem] }>() | ||
| 17 | + | ||
| 18 | +const catalogLabel = (st?: ItemStatus) => (st?.state === 'in-sync' ? 'in catalog' : 'local') | ||
| 19 | +</script> | ||
| 20 | + | ||
| 21 | +<template> | ||
| 22 | + <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 | + <template #actions> | ||
| 25 | + <SkButton variant="secondary" pill :label="review ? 'Show all' : 'Review'" @click="$emit('toggleReview')" /> | ||
| 26 | + </template> | ||
| 27 | + </SkStepHeader> | ||
| 28 | + <div class="grid gap-3.5" style="grid-template-columns: repeat(auto-fill, minmax(230px, 1fr))"> | ||
| 29 | + <SkSuggestionTile | ||
| 30 | + v-for="(i, n) in items" | ||
| 31 | + :key="i.id" | ||
| 32 | + :variant="suggestionVariant(n)" | ||
| 33 | + :name="i.name" | ||
| 34 | + :hint="`${AGENT_LABELS[i.agent]} · ${TYPE_TAG[i.type]} · ${catalogLabel(statuses[i.id])}`" | ||
| 35 | + :title="flagged.get(i.id)?.hint" | ||
| 36 | + @click="$emit('move', i)" | ||
| 37 | + /> | ||
| 38 | + </div> | ||
| 39 | + </SkPanel> | ||
| 40 | +</template> | ||
added
packages/ui/app/components/projects/ProjectsTable.vue +138 -0 | new file mode 100644 | ||
| @@ -0,0 +1,138 @@ | ||
| 1 | +<script setup lang="ts"> | |
| 2 | +import type { ProjectSummary, ScopeState } from '@skilly/core' | |
| 3 | +import type { Tone } from '../../utils/status' | |
| 4 | + | |
| 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. | |
| 8 | + */ | |
| 9 | +const { projects, loaded, error: projectsError, refresh: refreshProjects } = useProjects() | |
| 10 | +const { request } = useApi() | |
| 11 | +const catalog = useCatalog() | |
| 12 | +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 | +} | |
| 54 | + | |
| 55 | +onMounted(async () => { | |
| 56 | + if (!catalog.data.value) catalog.refresh() | |
| 57 | + // Projects may have changed on disk (e.g. a folder was deleted) since the layout loaded them. | |
| 58 | + if (loaded.value) await refreshProjects() | |
| 59 | + else await new Promise<void>((res) => { const stop = watch(loaded, (v) => { if (v) { stop(); res() } }) }) | |
| 60 | + await loadSummaries() | |
| 61 | +}) | |
| 62 | + | |
| 63 | +const COLS = 'minmax(0,2.2fr) minmax(0,1.3fr) minmax(0,1.4fr) 64px minmax(0,1.5fr)' | |
| 64 | + | |
| 65 | +function go(id: string) { | |
| 66 | + navigateTo(id === 'home' ? '/home' : `/projects/${encodeURIComponent(id)}`) | |
| 67 | +} | |
| 68 | + | |
| 69 | +const rowStatus = (p: ProjectSummary): { tone: Tone, label: string } | null => { | |
| 70 | + if (!p.exists) return { tone: 'accent', label: 'Folder missing' } | |
| 71 | + return summaries.value[p.id]?.status ?? null | |
| 72 | +} | |
| 73 | +const agentsOf = (p: ProjectSummary) => summaries.value[p.id]?.agents | |
| 74 | + ?? (p.agentsDetected.length ? p.agentsDetected.map(a => AGENT_LABELS[a]).join(', ') : '—') | |
| 75 | +const countOf = (p: ProjectSummary) => summaries.value[p.id]?.items ?? Object.entries(p.counts).filter(([t]) => t !== 'instructions').reduce((n, [, c]) => n + c, 0) | |
| 76 | +</script> | |
| 77 | + | |
| 78 | +<template> | |
| 79 | + <SkPanel> | |
| 80 | + <SkRow head :cols="COLS"> | |
| 81 | + <span>Project</span><span>Agents</span><span>Presets</span><span>Items</span><span>Status</span> | |
| 82 | + </SkRow> | |
| 83 | + | |
| 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')"> | |
| 86 | + <div class="flex min-w-0 items-center gap-2.5"> | |
| 87 | + <SkAvatar :size="28" /> | |
| 88 | + <div class="min-w-0"> | |
| 89 | + <div class="truncate font-medium">Home · user-level config</div> | |
| 90 | + <div class="truncate font-mono text-[11px] text-sk-faint">{{ summaries.home?.root ?? '~' }}</div> | |
| 91 | + </div> | |
| 92 | + </div> | |
| 93 | + <span class="truncate text-sk-muted">{{ summaries.home?.agents ?? '' }}</span> | |
| 94 | + <span class="truncate text-sk-muted">{{ summaries.home ? (summaries.home.presets.join(', ') || '—') : '' }}</span> | |
| 95 | + <span class="font-mono text-xs text-sk-muted">{{ summaries.home?.items ?? '' }}</span> | |
| 96 | + <SkStatus v-if="summaries.home" :tone="summaries.home.status.tone" :label="summaries.home.status.label" /> | |
| 97 | + <SkStatus v-else-if="summaries.home === null" tone="faint" label="Unavailable" /> | |
| 98 | + <USkeleton v-else class="h-4 w-20" /> | |
| 99 | + </SkRow> | |
| 100 | + | |
| 101 | + <template v-if="loaded"> | |
| 102 | + <SkRow | |
| 103 | + v-for="p in projects" | |
| 104 | + :key="p.id" | |
| 105 | + interactive | |
| 106 | + :cols="COLS" | |
| 107 | + class="py-3" | |
| 108 | + tabindex="0" | |
| 109 | + role="link" | |
| 110 | + :aria-label="`Open ${p.name}`" | |
| 111 | + :faded="false" | |
| 112 | + @click="go(p.id)" | |
| 113 | + @keydown.enter="go(p.id)" | |
| 114 | + > | |
| 115 | + <div class="min-w-0"> | |
| 116 | + <div class="truncate font-medium">{{ p.name }}</div> | |
| 117 | + <div class="truncate font-mono text-[11px] text-sk-faint" :title="p.path">{{ p.path }}</div> | |
| 118 | + </div> | |
| 119 | + <span class="truncate text-sk-muted">{{ agentsOf(p) }}</span> | |
| 120 | + <span class="truncate text-sk-muted" :title="summaries[p.id]?.presets.join(', ')">{{ p.exists && summaries[p.id] ? (summaries[p.id]!.presets.join(', ') || '—') : '' }}</span> | |
| 121 | + <span class="font-mono text-xs text-sk-muted">{{ countOf(p) }}</span> | |
| 122 | + <template v-if="rowStatus(p)"><SkStatus :tone="rowStatus(p)!.tone" :label="rowStatus(p)!.label" /></template> | |
| 123 | + <SkStatus v-else-if="summaries[p.id] === null" tone="faint" label="Unavailable" /> | |
| 124 | + <USkeleton v-else class="h-4 w-20" /> | |
| 125 | + </SkRow> | |
| 126 | + </template> | |
| 127 | + <div v-else class="flex flex-col gap-2 border-t border-sk-line2 p-4"><USkeleton class="h-9 w-full" /><USkeleton class="h-9 w-full" /></div> | |
| 128 | + | |
| 129 | + <SkNote v-if="projectsError" tone="accent" class="m-3"> | |
| 130 | + <span class="font-medium">Could not load projects.</span> {{ projectsError }} | |
| 131 | + <template #aside><SkButton size="sm" label="Retry" @click="refreshProjects()" /></template> | |
| 132 | + </SkNote> | |
| 133 | + <SkEmpty v-else-if="loaded && !projects.length" title="Add your first project"> | |
| 134 | + Pick a folder to see which skills, rules, MCP servers and instructions Claude Code and Codex use there, then manage them from your catalog. | |
| 135 | + <template #actions><SkButton variant="primary" label="Add project" @click="addOpen = true" /></template> | |
| 136 | + </SkEmpty> | |
| 137 | + </SkPanel> | |
| 138 | +</template> | |
| new file mode 100644 | |||
| @@ -0,0 +1,138 @@ | |||
| 1 | +<script setup lang="ts"> | ||
| 2 | +import type { ProjectSummary, ScopeState } from '@skilly/core' | ||
| 3 | +import type { Tone } from '../../utils/status' | ||
| 4 | + | ||
| 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. | ||
| 8 | + */ | ||
| 9 | +const { projects, loaded, error: projectsError, refresh: refreshProjects } = useProjects() | ||
| 10 | +const { request } = useApi() | ||
| 11 | +const catalog = useCatalog() | ||
| 12 | +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 | +} | ||
| 54 | + | ||
| 55 | +onMounted(async () => { | ||
| 56 | + if (!catalog.data.value) catalog.refresh() | ||
| 57 | + // Projects may have changed on disk (e.g. a folder was deleted) since the layout loaded them. | ||
| 58 | + if (loaded.value) await refreshProjects() | ||
| 59 | + else await new Promise<void>((res) => { const stop = watch(loaded, (v) => { if (v) { stop(); res() } }) }) | ||
| 60 | + await loadSummaries() | ||
| 61 | +}) | ||
| 62 | + | ||
| 63 | +const COLS = 'minmax(0,2.2fr) minmax(0,1.3fr) minmax(0,1.4fr) 64px minmax(0,1.5fr)' | ||
| 64 | + | ||
| 65 | +function go(id: string) { | ||
| 66 | + navigateTo(id === 'home' ? '/home' : `/projects/${encodeURIComponent(id)}`) | ||
| 67 | +} | ||
| 68 | + | ||
| 69 | +const rowStatus = (p: ProjectSummary): { tone: Tone, label: string } | null => { | ||
| 70 | + if (!p.exists) return { tone: 'accent', label: 'Folder missing' } | ||
| 71 | + return summaries.value[p.id]?.status ?? null | ||
| 72 | +} | ||
| 73 | +const agentsOf = (p: ProjectSummary) => summaries.value[p.id]?.agents | ||
| 74 | + ?? (p.agentsDetected.length ? p.agentsDetected.map(a => AGENT_LABELS[a]).join(', ') : '—') | ||
| 75 | +const countOf = (p: ProjectSummary) => summaries.value[p.id]?.items ?? Object.entries(p.counts).filter(([t]) => t !== 'instructions').reduce((n, [, c]) => n + c, 0) | ||
| 76 | +</script> | ||
| 77 | + | ||
| 78 | +<template> | ||
| 79 | + <SkPanel> | ||
| 80 | + <SkRow head :cols="COLS"> | ||
| 81 | + <span>Project</span><span>Agents</span><span>Presets</span><span>Items</span><span>Status</span> | ||
| 82 | + </SkRow> | ||
| 83 | + | ||
| 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')"> | ||
| 86 | + <div class="flex min-w-0 items-center gap-2.5"> | ||
| 87 | + <SkAvatar :size="28" /> | ||
| 88 | + <div class="min-w-0"> | ||
| 89 | + <div class="truncate font-medium">Home · user-level config</div> | ||
| 90 | + <div class="truncate font-mono text-[11px] text-sk-faint">{{ summaries.home?.root ?? '~' }}</div> | ||
| 91 | + </div> | ||
| 92 | + </div> | ||
| 93 | + <span class="truncate text-sk-muted">{{ summaries.home?.agents ?? '' }}</span> | ||
| 94 | + <span class="truncate text-sk-muted">{{ summaries.home ? (summaries.home.presets.join(', ') || '—') : '' }}</span> | ||
| 95 | + <span class="font-mono text-xs text-sk-muted">{{ summaries.home?.items ?? '' }}</span> | ||
| 96 | + <SkStatus v-if="summaries.home" :tone="summaries.home.status.tone" :label="summaries.home.status.label" /> | ||
| 97 | + <SkStatus v-else-if="summaries.home === null" tone="faint" label="Unavailable" /> | ||
| 98 | + <USkeleton v-else class="h-4 w-20" /> | ||
| 99 | + </SkRow> | ||
| 100 | + | ||
| 101 | + <template v-if="loaded"> | ||
| 102 | + <SkRow | ||
| 103 | + v-for="p in projects" | ||
| 104 | + :key="p.id" | ||
| 105 | + interactive | ||
| 106 | + :cols="COLS" | ||
| 107 | + class="py-3" | ||
| 108 | + tabindex="0" | ||
| 109 | + role="link" | ||
| 110 | + :aria-label="`Open ${p.name}`" | ||
| 111 | + :faded="false" | ||
| 112 | + @click="go(p.id)" | ||
| 113 | + @keydown.enter="go(p.id)" | ||
| 114 | + > | ||
| 115 | + <div class="min-w-0"> | ||
| 116 | + <div class="truncate font-medium">{{ p.name }}</div> | ||
| 117 | + <div class="truncate font-mono text-[11px] text-sk-faint" :title="p.path">{{ p.path }}</div> | ||
| 118 | + </div> | ||
| 119 | + <span class="truncate text-sk-muted">{{ agentsOf(p) }}</span> | ||
| 120 | + <span class="truncate text-sk-muted" :title="summaries[p.id]?.presets.join(', ')">{{ p.exists && summaries[p.id] ? (summaries[p.id]!.presets.join(', ') || '—') : '' }}</span> | ||
| 121 | + <span class="font-mono text-xs text-sk-muted">{{ countOf(p) }}</span> | ||
| 122 | + <template v-if="rowStatus(p)"><SkStatus :tone="rowStatus(p)!.tone" :label="rowStatus(p)!.label" /></template> | ||
| 123 | + <SkStatus v-else-if="summaries[p.id] === null" tone="faint" label="Unavailable" /> | ||
| 124 | + <USkeleton v-else class="h-4 w-20" /> | ||
| 125 | + </SkRow> | ||
| 126 | + </template> | ||
| 127 | + <div v-else class="flex flex-col gap-2 border-t border-sk-line2 p-4"><USkeleton class="h-9 w-full" /><USkeleton class="h-9 w-full" /></div> | ||
| 128 | + | ||
| 129 | + <SkNote v-if="projectsError" tone="accent" class="m-3"> | ||
| 130 | + <span class="font-medium">Could not load projects.</span> {{ projectsError }} | ||
| 131 | + <template #aside><SkButton size="sm" label="Retry" @click="refreshProjects()" /></template> | ||
| 132 | + </SkNote> | ||
| 133 | + <SkEmpty v-else-if="loaded && !projects.length" title="Add your first project"> | ||
| 134 | + Pick a folder to see which skills, rules, MCP servers and instructions Claude Code and Codex use there, then manage them from your catalog. | ||
| 135 | + <template #actions><SkButton variant="primary" label="Add project" @click="addOpen = true" /></template> | ||
| 136 | + </SkEmpty> | ||
| 137 | + </SkPanel> | ||
| 138 | +</template> | ||
added
packages/ui/app/components/scope/ScopeAddPresetsModal.vue +72 -0 | new file mode 100644 | ||
| @@ -0,0 +1,72 @@ | ||
| 1 | +<script setup lang="ts"> | |
| 2 | +import type { Preset } from '@skilly/core' | |
| 3 | + | |
| 4 | +/** "Add presets to {scope}" modal (mockup 11): search, checkbox list of presets, "Create a new preset" link. */ | |
| 5 | +const props = defineProps<{ presets: Preset[], enabled: string[], scopeName: string, catalogExists: boolean }>() | |
| 6 | +const open = defineModel<boolean>('open', { default: false }) | |
| 7 | +const emit = defineEmits<{ add: [names: string[]] }>() | |
| 8 | + | |
| 9 | +const search = ref('') | |
| 10 | +const chosen = ref<string[]>([]) | |
| 11 | + | |
| 12 | +watch(open, (v) => { | |
| 13 | + if (v) { | |
| 14 | + search.value = '' | |
| 15 | + chosen.value = [] | |
| 16 | + } | |
| 17 | +}) | |
| 18 | + | |
| 19 | +const sizeOf = (p: Preset) => CAT_TYPES.reduce((n, t) => n + (p.items[t]?.length ?? 0), 0) | |
| 20 | +const itemNames = (p: Preset) => [...CAT_TYPES.flatMap(t => p.items[t] ?? []), ...p.includes.map(i => `+ ${i}`)] | |
| 21 | + | |
| 22 | +const list = computed(() => { | |
| 23 | + const q = search.value.trim().toLowerCase() | |
| 24 | + return props.presets | |
| 25 | + .filter(p => !props.enabled.includes(p.name)) | |
| 26 | + .filter(p => !q || `${p.name} ${p.description ?? ''} ${itemNames(p).join(' ')}`.toLowerCase().includes(q)) | |
| 27 | +}) | |
| 28 | + | |
| 29 | +function toggle(name: string, on: boolean | 'indeterminate') { | |
| 30 | + chosen.value = on === true ? [...new Set([...chosen.value, name])] : chosen.value.filter(n => n !== name) | |
| 31 | +} | |
| 32 | +</script> | |
| 33 | + | |
| 34 | +<template> | |
| 35 | + <SkModal v-model:open="open" :title="`Add presets to ${scopeName}`" flush> | |
| 36 | + <div class="flex flex-col"> | |
| 37 | + <div class="px-5 pb-4"> | |
| 38 | + <UInput v-model="search" placeholder="Search presets or items" class="w-full" /> | |
| 39 | + </div> | |
| 40 | + <div class="max-h-[360px] overflow-y-auto border-t border-sk-line2"> | |
| 41 | + <label | |
| 42 | + v-for="p in list" | |
| 43 | + :key="p.name" | |
| 44 | + class="flex cursor-pointer items-start gap-3 border-t border-sk-line2 px-5 py-3 first:border-t-0 hover:bg-sk-hover" | |
| 45 | + > | |
| 46 | + <UCheckbox class="mt-0.5" :model-value="chosen.includes(p.name)" :aria-label="p.name" @update:model-value="(v) => toggle(p.name, v)" /> | |
| 47 | + <span class="flex min-w-0 flex-col gap-0.5"> | |
| 48 | + <span class="flex items-baseline gap-2"> | |
| 49 | + <span class="font-medium">{{ p.name }}</span> | |
| 50 | + <span class="font-mono text-[11px] text-sk-faint">{{ sizeOf(p) }} item{{ sizeOf(p) === 1 ? '' : 's' }}</span> | |
| 51 | + </span> | |
| 52 | + <span v-if="p.description" class="text-xs text-sk-muted">{{ p.description }}</span> | |
| 53 | + <span class="truncate text-xs text-sk-muted" :title="itemNames(p).join(', ')">{{ itemNames(p).join(', ') }}</span> | |
| 54 | + </span> | |
| 55 | + </label> | |
| 56 | + <SkEmpty v-if="!list.length"> | |
| 57 | + <template v-if="search">No preset matches your search.</template> | |
| 58 | + <template v-else-if="!catalogExists">No catalog yet. Create it from the Catalog page first.</template> | |
| 59 | + <template v-else-if="!presets.length">No preset in the catalog yet.</template> | |
| 60 | + <template v-else>Every preset of the catalog is already enabled.</template> | |
| 61 | + </SkEmpty> | |
| 62 | + </div> | |
| 63 | + </div> | |
| 64 | + <template #footer> | |
| 65 | + <NuxtLink :to="catalogExists ? '/catalog/presets/new' : '/catalog'" class="mr-auto text-sk-muted hover:text-sk-text" @click="open = false"> | |
| 66 | + {{ catalogExists ? 'Create a new preset' : 'Go to the catalog' }} | |
| 67 | + </NuxtLink> | |
| 68 | + <SkButton label="Cancel" @click="open = false" /> | |
| 69 | + <SkButton variant="primary" :disabled="!chosen.length" :label="chosen.length ? `Add ${chosen.length} preset${chosen.length > 1 ? 's' : ''}` : 'Add'" @click="emit('add', chosen)" /> | |
| 70 | + </template> | |
| 71 | + </SkModal> | |
| 72 | +</template> | |
| new file mode 100644 | |||
| @@ -0,0 +1,72 @@ | |||
| 1 | +<script setup lang="ts"> | ||
| 2 | +import type { Preset } from '@skilly/core' | ||
| 3 | + | ||
| 4 | +/** "Add presets to {scope}" modal (mockup 11): search, checkbox list of presets, "Create a new preset" link. */ | ||
| 5 | +const props = defineProps<{ presets: Preset[], enabled: string[], scopeName: string, catalogExists: boolean }>() | ||
| 6 | +const open = defineModel<boolean>('open', { default: false }) | ||
| 7 | +const emit = defineEmits<{ add: [names: string[]] }>() | ||
| 8 | + | ||
| 9 | +const search = ref('') | ||
| 10 | +const chosen = ref<string[]>([]) | ||
| 11 | + | ||
| 12 | +watch(open, (v) => { | ||
| 13 | + if (v) { | ||
| 14 | + search.value = '' | ||
| 15 | + chosen.value = [] | ||
| 16 | + } | ||
| 17 | +}) | ||
| 18 | + | ||
| 19 | +const sizeOf = (p: Preset) => CAT_TYPES.reduce((n, t) => n + (p.items[t]?.length ?? 0), 0) | ||
| 20 | +const itemNames = (p: Preset) => [...CAT_TYPES.flatMap(t => p.items[t] ?? []), ...p.includes.map(i => `+ ${i}`)] | ||
| 21 | + | ||
| 22 | +const list = computed(() => { | ||
| 23 | + const q = search.value.trim().toLowerCase() | ||
| 24 | + return props.presets | ||
| 25 | + .filter(p => !props.enabled.includes(p.name)) | ||
| 26 | + .filter(p => !q || `${p.name} ${p.description ?? ''} ${itemNames(p).join(' ')}`.toLowerCase().includes(q)) | ||
| 27 | +}) | ||
| 28 | + | ||
| 29 | +function toggle(name: string, on: boolean | 'indeterminate') { | ||
| 30 | + chosen.value = on === true ? [...new Set([...chosen.value, name])] : chosen.value.filter(n => n !== name) | ||
| 31 | +} | ||
| 32 | +</script> | ||
| 33 | + | ||
| 34 | +<template> | ||
| 35 | + <SkModal v-model:open="open" :title="`Add presets to ${scopeName}`" flush> | ||
| 36 | + <div class="flex flex-col"> | ||
| 37 | + <div class="px-5 pb-4"> | ||
| 38 | + <UInput v-model="search" placeholder="Search presets or items" class="w-full" /> | ||
| 39 | + </div> | ||
| 40 | + <div class="max-h-[360px] overflow-y-auto border-t border-sk-line2"> | ||
| 41 | + <label | ||
| 42 | + v-for="p in list" | ||
| 43 | + :key="p.name" | ||
| 44 | + class="flex cursor-pointer items-start gap-3 border-t border-sk-line2 px-5 py-3 first:border-t-0 hover:bg-sk-hover" | ||
| 45 | + > | ||
| 46 | + <UCheckbox class="mt-0.5" :model-value="chosen.includes(p.name)" :aria-label="p.name" @update:model-value="(v) => toggle(p.name, v)" /> | ||
| 47 | + <span class="flex min-w-0 flex-col gap-0.5"> | ||
| 48 | + <span class="flex items-baseline gap-2"> | ||
| 49 | + <span class="font-medium">{{ p.name }}</span> | ||
| 50 | + <span class="font-mono text-[11px] text-sk-faint">{{ sizeOf(p) }} item{{ sizeOf(p) === 1 ? '' : 's' }}</span> | ||
| 51 | + </span> | ||
| 52 | + <span v-if="p.description" class="text-xs text-sk-muted">{{ p.description }}</span> | ||
| 53 | + <span class="truncate text-xs text-sk-muted" :title="itemNames(p).join(', ')">{{ itemNames(p).join(', ') }}</span> | ||
| 54 | + </span> | ||
| 55 | + </label> | ||
| 56 | + <SkEmpty v-if="!list.length"> | ||
| 57 | + <template v-if="search">No preset matches your search.</template> | ||
| 58 | + <template v-else-if="!catalogExists">No catalog yet. Create it from the Catalog page first.</template> | ||
| 59 | + <template v-else-if="!presets.length">No preset in the catalog yet.</template> | ||
| 60 | + <template v-else>Every preset of the catalog is already enabled.</template> | ||
| 61 | + </SkEmpty> | ||
| 62 | + </div> | ||
| 63 | + </div> | ||
| 64 | + <template #footer> | ||
| 65 | + <NuxtLink :to="catalogExists ? '/catalog/presets/new' : '/catalog'" class="mr-auto text-sk-muted hover:text-sk-text" @click="open = false"> | ||
| 66 | + {{ catalogExists ? 'Create a new preset' : 'Go to the catalog' }} | ||
| 67 | + </NuxtLink> | ||
| 68 | + <SkButton label="Cancel" @click="open = false" /> | ||
| 69 | + <SkButton variant="primary" :disabled="!chosen.length" :label="chosen.length ? `Add ${chosen.length} preset${chosen.length > 1 ? 's' : ''}` : 'Add'" @click="emit('add', chosen)" /> | ||
| 70 | + </template> | ||
| 71 | + </SkModal> | ||
| 72 | +</template> | ||
added
packages/ui/app/components/scope/ScopeConfigTab.vue +20 -0 | new file mode 100644 | ||
| @@ -0,0 +1,20 @@ | ||
| 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> | |
| new file mode 100644 | |||
| @@ -0,0 +1,20 @@ | |||
| 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> | ||
added
packages/ui/app/components/scope/ScopeConflictChoice.vue +29 -0 | new file mode 100644 | ||
| @@ -0,0 +1,29 @@ | ||
| 1 | +<script setup lang="ts"> | |
| 2 | +import type { ConflictResolution, PlanAction } from '@skilly/core' | |
| 3 | + | |
| 4 | +/** | |
| 5 | + * The three ways to resolve a conflict, as a segmented choice. Picking one only stages it: the Review & apply | |
| 6 | + * bar counts it and Apply executes it. keep-local is the default; take-catalog is impossible when the item is gone from the catalog. | |
| 7 | + */ | |
| 8 | +const props = defineProps<{ action: PlanAction, home?: boolean }>() | |
| 9 | +const model = defineModel<ConflictResolution>({ required: true }) | |
| 10 | + | |
| 11 | +const options = computed(() => [ | |
| 12 | + { value: 'keep-local' as const, label: props.home ? 'Keep Home 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 }, | |
| 14 | + { value: 'push-to-catalog' as const, label: 'Push to catalog' } | |
| 15 | +]) | |
| 16 | + | |
| 17 | +const EFFECT: Record<ConflictResolution, string> = { | |
| 18 | + 'keep-local': 'The files stay as they are. skilly stops overwriting this item until you resolve it.', | |
| 19 | + 'take-catalog': 'The catalog version replaces the local one on Apply.', | |
| 20 | + 'push-to-catalog': 'The local version is copied into the catalog on Apply; other scopes that use it will get an update.' | |
| 21 | +} | |
| 22 | +</script> | |
| 23 | + | |
| 24 | +<template> | |
| 25 | + <div class="flex flex-col gap-1.5"> | |
| 26 | + <SkSeg v-model="model" :options="options" aria-label="Resolution" /> | |
| 27 | + <p class="m-0 text-xs text-sk-muted">{{ EFFECT[model] }}</p> | |
| 28 | + </div> | |
| 29 | +</template> | |
| new file mode 100644 | |||
| @@ -0,0 +1,29 @@ | |||
| 1 | +<script setup lang="ts"> | ||
| 2 | +import type { ConflictResolution, PlanAction } from '@skilly/core' | ||
| 3 | + | ||
| 4 | +/** | ||
| 5 | + * The three ways to resolve a conflict, as a segmented choice. Picking one only stages it: the Review & apply | ||
| 6 | + * bar counts it and Apply executes it. keep-local is the default; take-catalog is impossible when the item is gone from the catalog. | ||
| 7 | + */ | ||
| 8 | +const props = defineProps<{ action: PlanAction, home?: boolean }>() | ||
| 9 | +const model = defineModel<ConflictResolution>({ required: true }) | ||
| 10 | + | ||
| 11 | +const options = computed(() => [ | ||
| 12 | + { value: 'keep-local' as const, label: props.home ? 'Keep Home 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 }, | ||
| 14 | + { value: 'push-to-catalog' as const, label: 'Push to catalog' } | ||
| 15 | +]) | ||
| 16 | + | ||
| 17 | +const EFFECT: Record<ConflictResolution, string> = { | ||
| 18 | + 'keep-local': 'The files stay as they are. skilly stops overwriting this item until you resolve it.', | ||
| 19 | + 'take-catalog': 'The catalog version replaces the local one on Apply.', | ||
| 20 | + 'push-to-catalog': 'The local version is copied into the catalog on Apply; other scopes that use it will get an update.' | ||
| 21 | +} | ||
| 22 | +</script> | ||
| 23 | + | ||
| 24 | +<template> | ||
| 25 | + <div class="flex flex-col gap-1.5"> | ||
| 26 | + <SkSeg v-model="model" :options="options" aria-label="Resolution" /> | ||
| 27 | + <p class="m-0 text-xs text-sk-muted">{{ EFFECT[model] }}</p> | ||
| 28 | + </div> | ||
| 29 | +</template> | ||
added
packages/ui/app/components/scope/ScopeConflictCompare.vue +84 -0 | new file mode 100644 | ||
| @@ -0,0 +1,84 @@ | ||
| 1 | +<script setup lang="ts"> | |
| 2 | +import type { FileContent, PlanAction } from '@skilly/core' | |
| 3 | + | |
| 4 | +/** | |
| 5 | + * Diff of a conflicting item against its catalog version (design/BRIEF.md 4.2). Only standalone files have one | |
| 6 | + * (SKILL.md, rule and subagent markdown). Entries of shared configuration files (.mcp.json, settings.json, the | |
| 7 | + * AGENTS.md block) get the note "no standalone diff". | |
| 8 | + */ | |
| 9 | +const props = withDefaults(defineProps<{ | |
| 10 | + action: PlanAction | |
| 11 | + root: string | |
| 12 | + mode?: 'unified' | 'split' | |
| 13 | + /** Show the "differs from the catalog: 3 lines added, 1 removed" sentence above the diff. */ | |
| 14 | + sentence?: boolean | |
| 15 | + localLabel?: string | |
| 16 | +}>(), { mode: 'unified', sentence: true, localLabel: 'This project' }) | |
| 17 | + | |
| 18 | +const { request } = useApi() | |
| 19 | + | |
| 20 | +const loading = ref(true) | |
| 21 | +const error = ref<string | null>(null) | |
| 22 | +const note = ref<string | null>(null) | |
| 23 | +const local = ref<string | undefined>() | |
| 24 | +const catalogText = ref<string | undefined>() | |
| 25 | + | |
| 26 | +const stats = computed(() => (local.value !== undefined && catalogText.value !== undefined | |
| 27 | + ? diffStats(diffLines(catalogText.value, local.value)) | |
| 28 | + : null)) | |
| 29 | + | |
| 30 | +async function load() { | |
| 31 | + loading.value = true | |
| 32 | + error.value = null | |
| 33 | + note.value = null | |
| 34 | + local.value = undefined | |
| 35 | + catalogText.value = undefined | |
| 36 | + const file = comparableFile(props.action) | |
| 37 | + if (!file) { | |
| 38 | + note.value = 'Lives in a shared configuration file: no standalone diff.' | |
| 39 | + loading.value = false | |
| 40 | + return | |
| 41 | + } | |
| 42 | + const errors: string[] = [] | |
| 43 | + try { | |
| 44 | + local.value = (await request<FileContent>('/api/files', { query: { path: joinPath(props.root, file) }, silent: true })).content | |
| 45 | + } catch (e) { | |
| 46 | + errors.push(`File: ${(e as Error).message}`) | |
| 47 | + } | |
| 48 | + if (props.action.conflict === 'missing-in-catalog') note.value = 'The item no longer exists in the catalog.' | |
| 49 | + else { | |
| 50 | + try { | |
| 51 | + catalogText.value = (await request<{ content: string }>(`/api/catalog/items/${props.action.type}/${encodeURIComponent(props.action.name)}`, { silent: true })).content | |
| 52 | + } catch (e) { | |
| 53 | + errors.push(`Catalog: ${(e as Error).message}`) | |
| 54 | + } | |
| 55 | + } | |
| 56 | + error.value = errors.join(' · ') || null | |
| 57 | + loading.value = false | |
| 58 | +} | |
| 59 | + | |
| 60 | +watch(() => props.action.id, load, { immediate: true }) | |
| 61 | +</script> | |
| 62 | + | |
| 63 | +<template> | |
| 64 | + <div class="flex flex-col gap-2"> | |
| 65 | + <USkeleton v-if="loading" class="h-20 w-full rounded-[10px]" /> | |
| 66 | + <template v-else> | |
| 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> | |
| 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> | |
| 71 | + <SkNote v-if="note">{{ note }}</SkNote> | |
| 72 | + <SkNote v-if="error" tone="accent">{{ error }}</SkNote> | |
| 73 | + <SkDiff | |
| 74 | + v-if="local !== undefined && catalogText !== undefined" | |
| 75 | + :old-text="catalogText" | |
| 76 | + :new-text="local" | |
| 77 | + old-label="Catalog" | |
| 78 | + :new-label="localLabel" | |
| 79 | + :mode="mode" | |
| 80 | + :context="3" | |
| 81 | + /> | |
| 82 | + </template> | |
| 83 | + </div> | |
| 84 | +</template> | |
| new file mode 100644 | |||
| @@ -0,0 +1,84 @@ | |||
| 1 | +<script setup lang="ts"> | ||
| 2 | +import type { FileContent, PlanAction } from '@skilly/core' | ||
| 3 | + | ||
| 4 | +/** | ||
| 5 | + * Diff of a conflicting item against its catalog version (design/BRIEF.md 4.2). Only standalone files have one | ||
| 6 | + * (SKILL.md, rule and subagent markdown). Entries of shared configuration files (.mcp.json, settings.json, the | ||
| 7 | + * AGENTS.md block) get the note "no standalone diff". | ||
| 8 | + */ | ||
| 9 | +const props = withDefaults(defineProps<{ | ||
| 10 | + action: PlanAction | ||
| 11 | + root: string | ||
| 12 | + mode?: 'unified' | 'split' | ||
| 13 | + /** Show the "differs from the catalog: 3 lines added, 1 removed" sentence above the diff. */ | ||
| 14 | + sentence?: boolean | ||
| 15 | + localLabel?: string | ||
| 16 | +}>(), { mode: 'unified', sentence: true, localLabel: 'This project' }) | ||
| 17 | + | ||
| 18 | +const { request } = useApi() | ||
| 19 | + | ||
| 20 | +const loading = ref(true) | ||
| 21 | +const error = ref<string | null>(null) | ||
| 22 | +const note = ref<string | null>(null) | ||
| 23 | +const local = ref<string | undefined>() | ||
| 24 | +const catalogText = ref<string | undefined>() | ||
| 25 | + | ||
| 26 | +const stats = computed(() => (local.value !== undefined && catalogText.value !== undefined | ||
| 27 | + ? diffStats(diffLines(catalogText.value, local.value)) | ||
| 28 | + : null)) | ||
| 29 | + | ||
| 30 | +async function load() { | ||
| 31 | + loading.value = true | ||
| 32 | + error.value = null | ||
| 33 | + note.value = null | ||
| 34 | + local.value = undefined | ||
| 35 | + catalogText.value = undefined | ||
| 36 | + const file = comparableFile(props.action) | ||
| 37 | + if (!file) { | ||
| 38 | + note.value = 'Lives in a shared configuration file: no standalone diff.' | ||
| 39 | + loading.value = false | ||
| 40 | + return | ||
| 41 | + } | ||
| 42 | + const errors: string[] = [] | ||
| 43 | + try { | ||
| 44 | + local.value = (await request<FileContent>('/api/files', { query: { path: joinPath(props.root, file) }, silent: true })).content | ||
| 45 | + } catch (e) { | ||
| 46 | + errors.push(`File: ${(e as Error).message}`) | ||
| 47 | + } | ||
| 48 | + if (props.action.conflict === 'missing-in-catalog') note.value = 'The item no longer exists in the catalog.' | ||
| 49 | + else { | ||
| 50 | + try { | ||
| 51 | + catalogText.value = (await request<{ content: string }>(`/api/catalog/items/${props.action.type}/${encodeURIComponent(props.action.name)}`, { silent: true })).content | ||
| 52 | + } catch (e) { | ||
| 53 | + errors.push(`Catalog: ${(e as Error).message}`) | ||
| 54 | + } | ||
| 55 | + } | ||
| 56 | + error.value = errors.join(' · ') || null | ||
| 57 | + loading.value = false | ||
| 58 | +} | ||
| 59 | + | ||
| 60 | +watch(() => props.action.id, load, { immediate: true }) | ||
| 61 | +</script> | ||
| 62 | + | ||
| 63 | +<template> | ||
| 64 | + <div class="flex flex-col gap-2"> | ||
| 65 | + <USkeleton v-if="loading" class="h-20 w-full rounded-[10px]" /> | ||
| 66 | + <template v-else> | ||
| 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> | ||
| 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> | ||
| 71 | + <SkNote v-if="note">{{ note }}</SkNote> | ||
| 72 | + <SkNote v-if="error" tone="accent">{{ error }}</SkNote> | ||
| 73 | + <SkDiff | ||
| 74 | + v-if="local !== undefined && catalogText !== undefined" | ||
| 75 | + :old-text="catalogText" | ||
| 76 | + :new-text="local" | ||
| 77 | + old-label="Catalog" | ||
| 78 | + :new-label="localLabel" | ||
| 79 | + :mode="mode" | ||
| 80 | + :context="3" | ||
| 81 | + /> | ||
| 82 | + </template> | ||
| 83 | + </div> | ||
| 84 | +</template> | ||
added
packages/ui/app/components/scope/ScopeFilesTab.vue +21 -0 | new file mode 100644 | ||
| @@ -0,0 +1,21 @@ | ||
| 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> | |
| new file mode 100644 | |||
| @@ -0,0 +1,21 @@ | |||
| 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> | ||
added
packages/ui/app/components/scope/ScopeInheritedSection.vue +116 -0 | new file mode 100644 | ||
| @@ -0,0 +1,116 @@ | ||
| 1 | +<script setup lang="ts"> | |
| 2 | +import type { AgentId, ItemType } from '@skilly/core' | |
| 3 | + | |
| 4 | +/** | |
| 5 | + * "Inherited from Home" (projects only, mockup 9): Home items this project also sees, with hide switches. | |
| 6 | + * Only skills and MCP servers can be hidden, and only for Claude Code; Codex cannot hide per project (brief 4.6). | |
| 7 | + */ | |
| 8 | +const scope = useScopeContext() | |
| 9 | +const { home: homeInv, loading, refresh } = useHomeInventory() | |
| 10 | + | |
| 11 | +onMounted(() => { | |
| 12 | + if (!homeInv.value) refresh() | |
| 13 | +}) | |
| 14 | + | |
| 15 | +interface Inherited { key: string, type: ItemType, name: string, agents: AgentId[], plugin: boolean, hideable: boolean } | |
| 16 | + | |
| 17 | +const all = computed<Inherited[]>(() => { | |
| 18 | + const map = new Map<string, Inherited>() | |
| 19 | + for (const i of homeInv.value?.items ?? []) { | |
| 20 | + if (i.type === 'instructions') continue | |
| 21 | + const name = itemDisplayName(i) | |
| 22 | + const key = `${i.type}/${name}` | |
| 23 | + const e = map.get(key) ?? { key, type: i.type, name, agents: [], plugin: !!i.source.plugin, hideable: i.type === 'skill' || i.type === 'mcp' } | |
| 24 | + if (!e.agents.includes(i.agent)) e.agents.push(i.agent) | |
| 25 | + e.plugin = e.plugin && !!i.source.plugin | |
| 26 | + map.set(key, e) | |
| 27 | + } | |
| 28 | + const order: ItemType[] = ['skill', 'mcp', 'rule', 'hook', 'agent'] | |
| 29 | + return [...map.values()].sort((a, b) => order.indexOf(a.type) - order.indexOf(b.type) || a.name.localeCompare(b.name)) | |
| 30 | +}) | |
| 31 | + | |
| 32 | +const search = ref('') | |
| 33 | +const showFixed = ref(false) | |
| 34 | +const LIMIT = 40 | |
| 35 | +const showAllHideable = ref(false) | |
| 36 | + | |
| 37 | +const filtered = computed(() => { | |
| 38 | + const q = search.value.trim().toLowerCase() | |
| 39 | + return all.value.filter(i => !q || i.name.toLowerCase().includes(q)) | |
| 40 | +}) | |
| 41 | +const hideable = computed(() => filtered.value.filter(i => i.hideable)) | |
| 42 | +const fixed = computed(() => filtered.value.filter(i => !i.hideable)) | |
| 43 | +const shownHideable = computed(() => (showAllHideable.value ? hideable.value : hideable.value.slice(0, LIMIT))) | |
| 44 | + | |
| 45 | +const listFor = (t: ItemType) => (t === 'skill' ? 'skills' : 'mcp') as 'skills' | 'mcp' | |
| 46 | +const hiddenMatch = (i: Inherited) => (i.hideable ? matchIn(scope.config.value?.hideGlobal[listFor(i.type)], i.name) : null) | |
| 47 | + | |
| 48 | +function note(i: Inherited): string { | |
| 49 | + const noun = i.type === 'mcp' ? 'MCP servers' : 'skills' | |
| 50 | + if (!i.hideable) return 'Can\'t be hidden' | |
| 51 | + if (i.plugin) return 'Provided by a plugin' | |
| 52 | + const hidden = !!hiddenMatch(i) | |
| 53 | + const claude = i.agents.includes('claude') | |
| 54 | + const codex = i.agents.includes('codex') | |
| 55 | + if (!claude) return `Codex can't hide ${noun}` | |
| 56 | + if (hidden) return codex ? 'Hidden for Claude Code · Codex can\'t hide it' : 'Hidden here' | |
| 57 | + return codex ? 'Visible · Codex can\'t hide it' : 'Visible' | |
| 58 | +} | |
| 59 | + | |
| 60 | +const canSwitch = (i: Inherited) => i.hideable && !i.plugin && i.agents.includes('claude') && (hiddenMatch(i)?.exact ?? true) | |
| 61 | + | |
| 62 | +function setHidden(i: Inherited, on: boolean) { | |
| 63 | + scope.edit((c) => { | |
| 64 | + const k = listFor(i.type) | |
| 65 | + c.hideGlobal[k] = toggleIn(c.hideGlobal[k], i.name, on) | |
| 66 | + }) | |
| 67 | +} | |
| 68 | +</script> | |
| 69 | + | |
| 70 | +<template> | |
| 71 | + <SkSection title="Inherited from Home"> | |
| 72 | + <template #actions> | |
| 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> | |
| 75 | + </template> | |
| 76 | + | |
| 77 | + <SkPanel> | |
| 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" /> | |
| 80 | + </div> | |
| 81 | + | |
| 82 | + <SkRow v-for="i in shownHideable" :key="i.key" cols="minmax(0,1fr) auto minmax(0,220px) 28px" class="text-[13px]"> | |
| 83 | + <span class="flex min-w-0 items-baseline gap-2"><span class="truncate font-medium">{{ i.name }}</span><SkTypeTag :type="i.type" /></span> | |
| 84 | + <span class="text-sk-muted">{{ i.agents.map(a => AGENT_LABELS[a]).join(', ') }}</span> | |
| 85 | + <span class="text-right text-xs text-sk-muted">{{ note(i) }}</span> | |
| 86 | + <span class="inline-flex justify-end" :class="!canSwitch(i) && 'opacity-40'"> | |
| 87 | + <USwitch | |
| 88 | + :model-value="!!hiddenMatch(i)" | |
| 89 | + :disabled="!canSwitch(i)" | |
| 90 | + :aria-label="`Hide ${i.name} in this project`" | |
| 91 | + @update:model-value="(v: boolean) => setHidden(i, v)" | |
| 92 | + /> | |
| 93 | + </span> | |
| 94 | + </SkRow> | |
| 95 | + | |
| 96 | + <div v-if="hideable.length > shownHideable.length" class="border-t border-sk-line2 px-4 py-2.5 text-center"> | |
| 97 | + <SkLinkAction @click="showAllHideable = true">Show all {{ hideable.length }} skills and MCP servers</SkLinkAction> | |
| 98 | + </div> | |
| 99 | + | |
| 100 | + <template v-if="showFixed || search"> | |
| 101 | + <SkRow v-for="i in fixed" :key="i.key" cols="minmax(0,1fr) auto minmax(0,220px) 28px" class="text-[13px]"> | |
| 102 | + <span class="flex min-w-0 items-baseline gap-2"><span class="truncate font-medium">{{ i.name }}</span><SkTypeTag :type="i.type" /></span> | |
| 103 | + <span class="text-sk-muted">{{ i.agents.map(a => AGENT_LABELS[a]).join(', ') }}</span> | |
| 104 | + <span class="text-right text-xs text-sk-muted">{{ note(i) }}</span> | |
| 105 | + <span class="inline-flex justify-end opacity-40"><USwitch :model-value="false" disabled :aria-label="`${i.name} can't be hidden`" /></span> | |
| 106 | + </SkRow> | |
| 107 | + </template> | |
| 108 | + <div v-if="fixed.length && !showFixed && !search" class="border-t border-sk-line2 px-4 py-2.5 text-center"> | |
| 109 | + <SkLinkAction @click="showFixed = true">Show {{ fixed.length }} rules, hooks and subagents that can't be hidden</SkLinkAction> | |
| 110 | + </div> | |
| 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> | |
| 113 | + <div v-if="loading && !homeInv" class="p-4"><USkeleton class="h-8 w-full" /></div> | |
| 114 | + </SkPanel> | |
| 115 | + </SkSection> | |
| 116 | +</template> | |
| new file mode 100644 | |||
| @@ -0,0 +1,116 @@ | |||
| 1 | +<script setup lang="ts"> | ||
| 2 | +import type { AgentId, ItemType } from '@skilly/core' | ||
| 3 | + | ||
| 4 | +/** | ||
| 5 | + * "Inherited from Home" (projects only, mockup 9): Home items this project also sees, with hide switches. | ||
| 6 | + * Only skills and MCP servers can be hidden, and only for Claude Code; Codex cannot hide per project (brief 4.6). | ||
| 7 | + */ | ||
| 8 | +const scope = useScopeContext() | ||
| 9 | +const { home: homeInv, loading, refresh } = useHomeInventory() | ||
| 10 | + | ||
| 11 | +onMounted(() => { | ||
| 12 | + if (!homeInv.value) refresh() | ||
| 13 | +}) | ||
| 14 | + | ||
| 15 | +interface Inherited { key: string, type: ItemType, name: string, agents: AgentId[], plugin: boolean, hideable: boolean } | ||
| 16 | + | ||
| 17 | +const all = computed<Inherited[]>(() => { | ||
| 18 | + const map = new Map<string, Inherited>() | ||
| 19 | + for (const i of homeInv.value?.items ?? []) { | ||
| 20 | + if (i.type === 'instructions') continue | ||
| 21 | + const name = itemDisplayName(i) | ||
| 22 | + const key = `${i.type}/${name}` | ||
| 23 | + const e = map.get(key) ?? { key, type: i.type, name, agents: [], plugin: !!i.source.plugin, hideable: i.type === 'skill' || i.type === 'mcp' } | ||
| 24 | + if (!e.agents.includes(i.agent)) e.agents.push(i.agent) | ||
| 25 | + e.plugin = e.plugin && !!i.source.plugin | ||
| 26 | + map.set(key, e) | ||
| 27 | + } | ||
| 28 | + const order: ItemType[] = ['skill', 'mcp', 'rule', 'hook', 'agent'] | ||
| 29 | + return [...map.values()].sort((a, b) => order.indexOf(a.type) - order.indexOf(b.type) || a.name.localeCompare(b.name)) | ||
| 30 | +}) | ||
| 31 | + | ||
| 32 | +const search = ref('') | ||
| 33 | +const showFixed = ref(false) | ||
| 34 | +const LIMIT = 40 | ||
| 35 | +const showAllHideable = ref(false) | ||
| 36 | + | ||
| 37 | +const filtered = computed(() => { | ||
| 38 | + const q = search.value.trim().toLowerCase() | ||
| 39 | + return all.value.filter(i => !q || i.name.toLowerCase().includes(q)) | ||
| 40 | +}) | ||
| 41 | +const hideable = computed(() => filtered.value.filter(i => i.hideable)) | ||
| 42 | +const fixed = computed(() => filtered.value.filter(i => !i.hideable)) | ||
| 43 | +const shownHideable = computed(() => (showAllHideable.value ? hideable.value : hideable.value.slice(0, LIMIT))) | ||
| 44 | + | ||
| 45 | +const listFor = (t: ItemType) => (t === 'skill' ? 'skills' : 'mcp') as 'skills' | 'mcp' | ||
| 46 | +const hiddenMatch = (i: Inherited) => (i.hideable ? matchIn(scope.config.value?.hideGlobal[listFor(i.type)], i.name) : null) | ||
| 47 | + | ||
| 48 | +function note(i: Inherited): string { | ||
| 49 | + const noun = i.type === 'mcp' ? 'MCP servers' : 'skills' | ||
| 50 | + if (!i.hideable) return 'Can\'t be hidden' | ||
| 51 | + if (i.plugin) return 'Provided by a plugin' | ||
| 52 | + const hidden = !!hiddenMatch(i) | ||
| 53 | + const claude = i.agents.includes('claude') | ||
| 54 | + const codex = i.agents.includes('codex') | ||
| 55 | + if (!claude) return `Codex can't hide ${noun}` | ||
| 56 | + if (hidden) return codex ? 'Hidden for Claude Code · Codex can\'t hide it' : 'Hidden here' | ||
| 57 | + return codex ? 'Visible · Codex can\'t hide it' : 'Visible' | ||
| 58 | +} | ||
| 59 | + | ||
| 60 | +const canSwitch = (i: Inherited) => i.hideable && !i.plugin && i.agents.includes('claude') && (hiddenMatch(i)?.exact ?? true) | ||
| 61 | + | ||
| 62 | +function setHidden(i: Inherited, on: boolean) { | ||
| 63 | + scope.edit((c) => { | ||
| 64 | + const k = listFor(i.type) | ||
| 65 | + c.hideGlobal[k] = toggleIn(c.hideGlobal[k], i.name, on) | ||
| 66 | + }) | ||
| 67 | +} | ||
| 68 | +</script> | ||
| 69 | + | ||
| 70 | +<template> | ||
| 71 | + <SkSection title="Inherited from Home"> | ||
| 72 | + <template #actions> | ||
| 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> | ||
| 75 | + </template> | ||
| 76 | + | ||
| 77 | + <SkPanel> | ||
| 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" /> | ||
| 80 | + </div> | ||
| 81 | + | ||
| 82 | + <SkRow v-for="i in shownHideable" :key="i.key" cols="minmax(0,1fr) auto minmax(0,220px) 28px" class="text-[13px]"> | ||
| 83 | + <span class="flex min-w-0 items-baseline gap-2"><span class="truncate font-medium">{{ i.name }}</span><SkTypeTag :type="i.type" /></span> | ||
| 84 | + <span class="text-sk-muted">{{ i.agents.map(a => AGENT_LABELS[a]).join(', ') }}</span> | ||
| 85 | + <span class="text-right text-xs text-sk-muted">{{ note(i) }}</span> | ||
| 86 | + <span class="inline-flex justify-end" :class="!canSwitch(i) && 'opacity-40'"> | ||
| 87 | + <USwitch | ||
| 88 | + :model-value="!!hiddenMatch(i)" | ||
| 89 | + :disabled="!canSwitch(i)" | ||
| 90 | + :aria-label="`Hide ${i.name} in this project`" | ||
| 91 | + @update:model-value="(v: boolean) => setHidden(i, v)" | ||
| 92 | + /> | ||
| 93 | + </span> | ||
| 94 | + </SkRow> | ||
| 95 | + | ||
| 96 | + <div v-if="hideable.length > shownHideable.length" class="border-t border-sk-line2 px-4 py-2.5 text-center"> | ||
| 97 | + <SkLinkAction @click="showAllHideable = true">Show all {{ hideable.length }} skills and MCP servers</SkLinkAction> | ||
| 98 | + </div> | ||
| 99 | + | ||
| 100 | + <template v-if="showFixed || search"> | ||
| 101 | + <SkRow v-for="i in fixed" :key="i.key" cols="minmax(0,1fr) auto minmax(0,220px) 28px" class="text-[13px]"> | ||
| 102 | + <span class="flex min-w-0 items-baseline gap-2"><span class="truncate font-medium">{{ i.name }}</span><SkTypeTag :type="i.type" /></span> | ||
| 103 | + <span class="text-sk-muted">{{ i.agents.map(a => AGENT_LABELS[a]).join(', ') }}</span> | ||
| 104 | + <span class="text-right text-xs text-sk-muted">{{ note(i) }}</span> | ||
| 105 | + <span class="inline-flex justify-end opacity-40"><USwitch :model-value="false" disabled :aria-label="`${i.name} can't be hidden`" /></span> | ||
| 106 | + </SkRow> | ||
| 107 | + </template> | ||
| 108 | + <div v-if="fixed.length && !showFixed && !search" class="border-t border-sk-line2 px-4 py-2.5 text-center"> | ||
| 109 | + <SkLinkAction @click="showFixed = true">Show {{ fixed.length }} rules, hooks and subagents that can't be hidden</SkLinkAction> | ||
| 110 | + </div> | ||
| 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> | ||
| 113 | + <div v-if="loading && !homeInv" class="p-4"><USkeleton class="h-8 w-full" /></div> | ||
| 114 | + </SkPanel> | ||
| 115 | + </SkSection> | ||
| 116 | +</template> | ||
added
packages/ui/app/components/scope/ScopeInstructionsTab.vue +78 -0 | new file mode 100644 | ||
| @@ -0,0 +1,78 @@ | ||
| 1 | +<script setup lang="ts"> | |
| 2 | +import type { InstructionsDetails, InventoryItem } from '@skilly/core' | |
| 3 | + | |
| 4 | +/** | |
| 5 | + * Instructions tab (mockup 12): CLAUDE.md / AGENTS.md of the project, read-only, with loaded / not loaded, | |
| 6 | + * imports, size, and the Codex 32 KiB limit gauge (brief 3, "Project instructions"). | |
| 7 | + */ | |
| 8 | +const scope = useScopeContext() | |
| 9 | + | |
| 10 | +const CODEX_LIMIT = 32 * 1024 | |
| 11 | +const detail = (i: InventoryItem) => i.details as InstructionsDetails | |
| 12 | + | |
| 13 | +/** One entry per file: AGENTS.md is scanned for both agents but is the same file on disk. */ | |
| 14 | +interface InstructionFile { id: string, item: InventoryItem, loadedBy: InventoryItem[], notLoaded: InventoryItem[] } | |
| 15 | +const files = computed<InstructionFile[]>(() => { | |
| 16 | + const byPath = new Map<string, InstructionFile>() | |
| 17 | + for (const i of (scope.inventory.value?.items ?? []).filter(x => x.type === 'instructions')) { | |
| 18 | + const key = i.source.path.toLowerCase() | |
| 19 | + const e = byPath.get(key) ?? { id: i.id, item: i, loadedBy: [], notLoaded: [] } | |
| 20 | + ;(detail(i).loaded ? e.loadedBy : e.notLoaded).push(i) | |
| 21 | + byPath.set(key, e) | |
| 22 | + } | |
| 23 | + return [...byPath.values()] | |
| 24 | +}) | |
| 25 | + | |
| 26 | +const selected = ref<string>('') | |
| 27 | +watch(files, (list) => { | |
| 28 | + if (!list.some(f => f.id === selected.value)) selected.value = list[0]?.id ?? '' | |
| 29 | +}, { immediate: true }) | |
| 30 | +const current = computed(() => files.value.find(f => f.id === selected.value)) | |
| 31 | + | |
| 32 | +const options = computed(() => files.value.map(f => ({ | |
| 33 | + value: f.id, | |
| 34 | + label: detail(f.item).fileName, | |
| 35 | + title: f.loadedBy.length ? undefined : detail(f.item).notLoadedReason ?? 'Not loaded' | |
| 36 | +}))) | |
| 37 | +const gauge = computed(() => { | |
| 38 | + const f = current.value | |
| 39 | + if (!f || !f.loadedBy.concat(f.notLoaded).some(i => i.agent === 'codex' && detail(i).fileName.startsWith('AGENTS'))) return null | |
| 40 | + const size = detail(f.item).sizeBytes | |
| 41 | + return { size, pct: Math.min(100, Math.round((size / CODEX_LIMIT) * 100)), over: size > CODEX_LIMIT } | |
| 42 | +}) | |
| 43 | +const loadedText = (f: InstructionFile) => (f.loadedBy.length ? `Loaded by ${f.loadedBy.map(i => AGENT_LABELS[i.agent]).join(' and ')}` : 'Not loaded') | |
| 44 | +</script> | |
| 45 | + | |
| 46 | +<template> | |
| 47 | + <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 | + <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 | + </div> | |
| 52 | + | |
| 53 | + <template v-if="current"> | |
| 54 | + <SkNote v-for="n in current.notLoaded" :key="n.id" tone="faint"> | |
| 55 | + <span class="font-medium">Not loaded by {{ AGENT_LABELS[n.agent] }}.</span> {{ detail(n).notLoadedReason ?? `${AGENT_LABELS[n.agent]} does not read this file here.` }} | |
| 56 | + </SkNote> | |
| 57 | + <div class="flex flex-wrap items-center gap-x-4 gap-y-1.5 text-xs text-sk-muted"> | |
| 58 | + <SkStatus :tone="current.loadedBy.length ? 'ok' : 'faint'" :label="loadedText(current)" small class="text-sk-muted" /> | |
| 59 | + <span class="font-mono">{{ formatBytes(detail(current.item).sizeBytes) }}</span> | |
| 60 | + <span v-if="detail(current.item).imports.length" class="font-mono">imports {{ detail(current.item).imports.join(', ') }}</span> | |
| 61 | + <span class="truncate font-mono text-sk-faint" :title="current.item.source.path">{{ pathInRoot(scope.rootPath.value, current.item.source.path) }}</span> | |
| 62 | + </div> | |
| 63 | + <div v-if="gauge" class="flex items-center gap-3 text-xs" :class="gauge.over ? 'text-sk-accent-text' : 'text-sk-muted'"> | |
| 64 | + <span class="h-1.5 w-40 overflow-hidden rounded-full bg-sk-line2" role="img" :aria-label="`${gauge.pct}% of the Codex limit`"> | |
| 65 | + <span class="block h-full rounded-full" :class="gauge.over ? 'bg-sk-accent' : 'bg-sk-info'" :style="{ width: `${gauge.pct}%` }" /> | |
| 66 | + </span> | |
| 67 | + <span class="font-mono">{{ formatBytes(gauge.size) }} of 32 KB read by Codex</span> | |
| 68 | + </div> | |
| 69 | + <SkPanel padded> | |
| 70 | + <ScopeInstructionsViewer :path="current.item.source.path" /> | |
| 71 | + </SkPanel> | |
| 72 | + </template> | |
| 73 | + | |
| 74 | + <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 | + </SkPanel> | |
| 77 | + </div> | |
| 78 | +</template> | |
| new file mode 100644 | |||
| @@ -0,0 +1,78 @@ | |||
| 1 | +<script setup lang="ts"> | ||
| 2 | +import type { InstructionsDetails, InventoryItem } from '@skilly/core' | ||
| 3 | + | ||
| 4 | +/** | ||
| 5 | + * Instructions tab (mockup 12): CLAUDE.md / AGENTS.md of the project, read-only, with loaded / not loaded, | ||
| 6 | + * imports, size, and the Codex 32 KiB limit gauge (brief 3, "Project instructions"). | ||
| 7 | + */ | ||
| 8 | +const scope = useScopeContext() | ||
| 9 | + | ||
| 10 | +const CODEX_LIMIT = 32 * 1024 | ||
| 11 | +const detail = (i: InventoryItem) => i.details as InstructionsDetails | ||
| 12 | + | ||
| 13 | +/** One entry per file: AGENTS.md is scanned for both agents but is the same file on disk. */ | ||
| 14 | +interface InstructionFile { id: string, item: InventoryItem, loadedBy: InventoryItem[], notLoaded: InventoryItem[] } | ||
| 15 | +const files = computed<InstructionFile[]>(() => { | ||
| 16 | + const byPath = new Map<string, InstructionFile>() | ||
| 17 | + for (const i of (scope.inventory.value?.items ?? []).filter(x => x.type === 'instructions')) { | ||
| 18 | + const key = i.source.path.toLowerCase() | ||
| 19 | + const e = byPath.get(key) ?? { id: i.id, item: i, loadedBy: [], notLoaded: [] } | ||
| 20 | + ;(detail(i).loaded ? e.loadedBy : e.notLoaded).push(i) | ||
| 21 | + byPath.set(key, e) | ||
| 22 | + } | ||
| 23 | + return [...byPath.values()] | ||
| 24 | +}) | ||
| 25 | + | ||
| 26 | +const selected = ref<string>('') | ||
| 27 | +watch(files, (list) => { | ||
| 28 | + if (!list.some(f => f.id === selected.value)) selected.value = list[0]?.id ?? '' | ||
| 29 | +}, { immediate: true }) | ||
| 30 | +const current = computed(() => files.value.find(f => f.id === selected.value)) | ||
| 31 | + | ||
| 32 | +const options = computed(() => files.value.map(f => ({ | ||
| 33 | + value: f.id, | ||
| 34 | + label: detail(f.item).fileName, | ||
| 35 | + title: f.loadedBy.length ? undefined : detail(f.item).notLoadedReason ?? 'Not loaded' | ||
| 36 | +}))) | ||
| 37 | +const gauge = computed(() => { | ||
| 38 | + const f = current.value | ||
| 39 | + if (!f || !f.loadedBy.concat(f.notLoaded).some(i => i.agent === 'codex' && detail(i).fileName.startsWith('AGENTS'))) return null | ||
| 40 | + const size = detail(f.item).sizeBytes | ||
| 41 | + return { size, pct: Math.min(100, Math.round((size / CODEX_LIMIT) * 100)), over: size > CODEX_LIMIT } | ||
| 42 | +}) | ||
| 43 | +const loadedText = (f: InstructionFile) => (f.loadedBy.length ? `Loaded by ${f.loadedBy.map(i => AGENT_LABELS[i.agent]).join(' and ')}` : 'Not loaded') | ||
| 44 | +</script> | ||
| 45 | + | ||
| 46 | +<template> | ||
| 47 | + <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 | + <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 | + </div> | ||
| 52 | + | ||
| 53 | + <template v-if="current"> | ||
| 54 | + <SkNote v-for="n in current.notLoaded" :key="n.id" tone="faint"> | ||
| 55 | + <span class="font-medium">Not loaded by {{ AGENT_LABELS[n.agent] }}.</span> {{ detail(n).notLoadedReason ?? `${AGENT_LABELS[n.agent]} does not read this file here.` }} | ||
| 56 | + </SkNote> | ||
| 57 | + <div class="flex flex-wrap items-center gap-x-4 gap-y-1.5 text-xs text-sk-muted"> | ||
| 58 | + <SkStatus :tone="current.loadedBy.length ? 'ok' : 'faint'" :label="loadedText(current)" small class="text-sk-muted" /> | ||
| 59 | + <span class="font-mono">{{ formatBytes(detail(current.item).sizeBytes) }}</span> | ||
| 60 | + <span v-if="detail(current.item).imports.length" class="font-mono">imports {{ detail(current.item).imports.join(', ') }}</span> | ||
| 61 | + <span class="truncate font-mono text-sk-faint" :title="current.item.source.path">{{ pathInRoot(scope.rootPath.value, current.item.source.path) }}</span> | ||
| 62 | + </div> | ||
| 63 | + <div v-if="gauge" class="flex items-center gap-3 text-xs" :class="gauge.over ? 'text-sk-accent-text' : 'text-sk-muted'"> | ||
| 64 | + <span class="h-1.5 w-40 overflow-hidden rounded-full bg-sk-line2" role="img" :aria-label="`${gauge.pct}% of the Codex limit`"> | ||
| 65 | + <span class="block h-full rounded-full" :class="gauge.over ? 'bg-sk-accent' : 'bg-sk-info'" :style="{ width: `${gauge.pct}%` }" /> | ||
| 66 | + </span> | ||
| 67 | + <span class="font-mono">{{ formatBytes(gauge.size) }} of 32 KB read by Codex</span> | ||
| 68 | + </div> | ||
| 69 | + <SkPanel padded> | ||
| 70 | + <ScopeInstructionsViewer :path="current.item.source.path" /> | ||
| 71 | + </SkPanel> | ||
| 72 | + </template> | ||
| 73 | + | ||
| 74 | + <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 | + </SkPanel> | ||
| 77 | + </div> | ||
| 78 | +</template> | ||
added
packages/ui/app/components/scope/ScopeInstructionsViewer.vue +47 -0 | new file mode 100644 | ||
| @@ -0,0 +1,47 @@ | ||
| 1 | +<script setup lang="ts"> | |
| 2 | +import type { FileContent } from '@skilly/core' | |
| 3 | + | |
| 4 | +/** | |
| 5 | + * Read-only view of an instruction file (CLAUDE.md, AGENTS.md...) in a mono block. | |
| 6 | + * The block managed by skilly (between `<!-- skilly:begin -->` and `<!-- skilly:end -->`) is highlighted. | |
| 7 | + */ | |
| 8 | +const props = defineProps<{ path: string }>() | |
| 9 | +const { request } = useApi() | |
| 10 | + | |
| 11 | +const file = ref<FileContent | null>(null) | |
| 12 | +const loading = ref(false) | |
| 13 | +const error = ref<string | null>(null) | |
| 14 | + | |
| 15 | +async function load() { | |
| 16 | + loading.value = true | |
| 17 | + error.value = null | |
| 18 | + file.value = null | |
| 19 | + try { | |
| 20 | + file.value = await request<FileContent>('/api/files', { query: { path: props.path }, silent: true }) | |
| 21 | + } catch (e) { | |
| 22 | + error.value = (e as Error).message | |
| 23 | + } finally { | |
| 24 | + loading.value = false | |
| 25 | + } | |
| 26 | +} | |
| 27 | +watch(() => props.path, load, { immediate: true }) | |
| 28 | + | |
| 29 | +const segments = computed(() => splitManagedBlock(file.value?.content ?? '')) | |
| 30 | +const hasManaged = computed(() => segments.value.some(s => s.managed)) | |
| 31 | +</script> | |
| 32 | + | |
| 33 | +<template> | |
| 34 | + <div class="flex flex-col gap-2.5"> | |
| 35 | + <div v-if="loading" class="flex flex-col gap-2"><USkeleton class="h-4 w-1/3" /><USkeleton class="h-4 w-full" /><USkeleton class="h-4 w-5/6" /></div> | |
| 36 | + <SkNote v-else-if="error" tone="accent">Could not read the file: {{ error }}</SkNote> | |
| 37 | + <template v-else-if="file"> | |
| 38 | + <SkNote v-if="file.truncated">Only the first part of this file is shown ({{ formatBytes(file.sizeBytes) }} in total).</SkNote> | |
| 39 | + <p v-if="hasManaged" class="m-0 flex items-center gap-2 text-xs text-sk-muted"> | |
| 40 | + <span class="inline-block h-3 w-1 rounded-full bg-sk-accent" aria-hidden="true" /> | |
| 41 | + The highlighted block is generated by skilly from your enabled rules. Edit the rules, not the block. | |
| 42 | + </p> | |
| 43 | + <p v-if="!file.content.trim()" class="m-0 py-6 text-center text-sk-faint">This file is empty.</p> | |
| 44 | + <pre v-else class="m-0 overflow-auto font-mono text-xs leading-[1.7] break-words whitespace-pre-wrap text-sk-text"><template v-for="(s, i) in segments" :key="i"><span v-if="s.managed" class="-mx-3 block rounded-[6px] border-l-2 border-sk-accent bg-sk-accent-bg px-[10px]">{{ s.text }}</span><template v-else>{{ s.text }}</template></template></pre> | |
| 45 | + </template> | |
| 46 | + </div> | |
| 47 | +</template> | |
| new file mode 100644 | |||
| @@ -0,0 +1,47 @@ | |||
| 1 | +<script setup lang="ts"> | ||
| 2 | +import type { FileContent } from '@skilly/core' | ||
| 3 | + | ||
| 4 | +/** | ||
| 5 | + * Read-only view of an instruction file (CLAUDE.md, AGENTS.md...) in a mono block. | ||
| 6 | + * The block managed by skilly (between `<!-- skilly:begin -->` and `<!-- skilly:end -->`) is highlighted. | ||
| 7 | + */ | ||
| 8 | +const props = defineProps<{ path: string }>() | ||
| 9 | +const { request } = useApi() | ||
| 10 | + | ||
| 11 | +const file = ref<FileContent | null>(null) | ||
| 12 | +const loading = ref(false) | ||
| 13 | +const error = ref<string | null>(null) | ||
| 14 | + | ||
| 15 | +async function load() { | ||
| 16 | + loading.value = true | ||
| 17 | + error.value = null | ||
| 18 | + file.value = null | ||
| 19 | + try { | ||
| 20 | + file.value = await request<FileContent>('/api/files', { query: { path: props.path }, silent: true }) | ||
| 21 | + } catch (e) { | ||
| 22 | + error.value = (e as Error).message | ||
| 23 | + } finally { | ||
| 24 | + loading.value = false | ||
| 25 | + } | ||
| 26 | +} | ||
| 27 | +watch(() => props.path, load, { immediate: true }) | ||
| 28 | + | ||
| 29 | +const segments = computed(() => splitManagedBlock(file.value?.content ?? '')) | ||
| 30 | +const hasManaged = computed(() => segments.value.some(s => s.managed)) | ||
| 31 | +</script> | ||
| 32 | + | ||
| 33 | +<template> | ||
| 34 | + <div class="flex flex-col gap-2.5"> | ||
| 35 | + <div v-if="loading" class="flex flex-col gap-2"><USkeleton class="h-4 w-1/3" /><USkeleton class="h-4 w-full" /><USkeleton class="h-4 w-5/6" /></div> | ||
| 36 | + <SkNote v-else-if="error" tone="accent">Could not read the file: {{ error }}</SkNote> | ||
| 37 | + <template v-else-if="file"> | ||
| 38 | + <SkNote v-if="file.truncated">Only the first part of this file is shown ({{ formatBytes(file.sizeBytes) }} in total).</SkNote> | ||
| 39 | + <p v-if="hasManaged" class="m-0 flex items-center gap-2 text-xs text-sk-muted"> | ||
| 40 | + <span class="inline-block h-3 w-1 rounded-full bg-sk-accent" aria-hidden="true" /> | ||
| 41 | + The highlighted block is generated by skilly from your enabled rules. Edit the rules, not the block. | ||
| 42 | + </p> | ||
| 43 | + <p v-if="!file.content.trim()" class="m-0 py-6 text-center text-sk-faint">This file is empty.</p> | ||
| 44 | + <pre v-else class="m-0 overflow-auto font-mono text-xs leading-[1.7] break-words whitespace-pre-wrap text-sk-text"><template v-for="(s, i) in segments" :key="i"><span v-if="s.managed" class="-mx-3 block rounded-[6px] border-l-2 border-sk-accent bg-sk-accent-bg px-[10px]">{{ s.text }}</span><template v-else>{{ s.text }}</template></template></pre> | ||
| 45 | + </template> | ||
| 46 | + </div> | ||
| 47 | +</template> | ||
added
packages/ui/app/components/scope/ScopeInventoryList.vue +149 -0 | new file mode 100644 | ||
| @@ -0,0 +1,149 @@ | ||
| 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> | |
| new file mode 100644 | |||
| @@ -0,0 +1,149 @@ | |||
| 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> | ||
added
packages/ui/app/components/scope/ScopeItemDrawer.vue +154 -0 | new file mode 100644 | ||
| @@ -0,0 +1,154 @@ | ||
| 1 | +<script setup lang="ts"> | |
| 2 | +import type { SkillDetails } from '@skilly/core' | |
| 3 | + | |
| 4 | +/** | |
| 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. | |
| 7 | + */ | |
| 8 | +const scope = useScopeContext() | |
| 9 | +const open = defineModel<boolean>('open', { default: false }) | |
| 10 | +const item = computed(() => scope.selectedItem.value) | |
| 11 | +const status = computed(() => (item.value ? scope.statuses.value[item.value.id] : undefined)) | |
| 12 | +const home = scope.kind === 'home' | |
| 13 | + | |
| 14 | +const filePath = computed<string | null>(() => { | |
| 15 | + const it = item.value | |
| 16 | + if (!it) return null | |
| 17 | + if (it.type === 'skill') return joinPath(it.source.path, 'SKILL.md') | |
| 18 | + if (['rule', 'agent', 'instructions'].includes(it.type) && it.source.kind === 'file') return it.source.path | |
| 19 | + return null | |
| 20 | +}) | |
| 21 | + | |
| 22 | +/** Skill file currently displayed (defaults to SKILL.md). */ | |
| 23 | +const currentFile = ref<string | null>(null) | |
| 24 | +watch(() => item.value?.id, () => { currentFile.value = null }) | |
| 25 | +const shownPath = computed(() => currentFile.value ?? filePath.value) | |
| 26 | + | |
| 27 | +const skillFiles = computed(() => (item.value?.type === 'skill' | |
| 28 | + ? [...(item.value.details as SkillDetails).files].sort((a, b) => Number(b === 'SKILL.md') - Number(a === 'SKILL.md')) | |
| 29 | + : [])) | |
| 30 | + | |
| 31 | +const frontmatter = computed(() => { | |
| 32 | + const d = item.value?.details as { frontmatter?: Record<string, unknown> } | undefined | |
| 33 | + return currentFile.value ? undefined : d?.frontmatter | |
| 34 | +}) | |
| 35 | + | |
| 36 | +const display = computed(() => (item.value ? itemDisplayName(item.value) : '')) | |
| 37 | +const stateLabel = computed(() => { | |
| 38 | + if (!item.value || item.value.type === 'instructions') return null | |
| 39 | + return home ? homeStateMeta(status.value?.state) : { tone: ITEM_STATE_META[status.value?.state ?? 'not-in-catalog'].tone, label: ITEM_STATE_META[status.value?.state ?? 'not-in-catalog'].label } | |
| 40 | +}) | |
| 41 | + | |
| 42 | +interface MetaRow { label: string, value: string, mono?: boolean } | |
| 43 | +const rows = computed<MetaRow[]>(() => { | |
| 44 | + const it = item.value | |
| 45 | + if (!it) return [] | |
| 46 | + const r: MetaRow[] = [{ label: 'Path', value: it.source.path, mono: true }] | |
| 47 | + if (it.source.configKey) r.push({ label: 'Config key', value: it.source.configKey, mono: true }) | |
| 48 | + if (it.source.symlinkTarget) r.push({ label: 'Symlink', value: `Only the link is removed when you move it. Target: ${it.source.symlinkTarget}`, mono: true }) | |
| 49 | + if (it.source.plugin) r.push({ label: 'Plugin', value: it.source.plugin }) | |
| 50 | + if (it.hash) r.push({ label: 'Hash', value: it.hash.slice(0, 12), mono: true }) | |
| 51 | + const d = it.details | |
| 52 | + if (d.type === 'skill') { | |
| 53 | + r.push({ label: 'Visibility', value: d.visibility + (d.visibilitySource ? ` · ${d.visibilitySource}` : '') }) | |
| 54 | + } else if (d.type === 'rule') { | |
| 55 | + r.push({ label: 'Applies to', value: d.paths.length ? d.paths.join(', ') : 'always', mono: true }) | |
| 56 | + } else if (d.type === 'mcp') { | |
| 57 | + r.push({ label: 'Transport', value: d.transport }) | |
| 58 | + if (d.command) r.push({ label: 'Command', value: [d.command, ...(d.args ?? [])].join(' '), mono: true }) | |
| 59 | + if (d.url) r.push({ label: 'URL', value: d.url, mono: true }) | |
| 60 | + if (d.agentScope) r.push({ label: 'Scope', value: d.agentScope }) | |
| 61 | + r.push({ label: 'Enabled', value: d.enabled ? 'yes' : 'no' }) | |
| 62 | + if (d.envVarNames.length) r.push({ label: 'Env vars', value: d.envVarNames.join(', '), mono: true }) | |
| 63 | + if (d.literalEnvKeys.length) r.push({ label: 'Literal env', value: `${d.literalEnvKeys.join(', ')} (values are never shown)`, mono: true }) | |
| 64 | + } else if (d.type === 'hook') { | |
| 65 | + r.push({ label: 'Event', value: d.event, mono: true }) | |
| 66 | + if (d.matcher) r.push({ label: 'Matcher', value: d.matcher, mono: true }) | |
| 67 | + r.push({ label: 'Handler', value: d.handlerType }) | |
| 68 | + if (d.command) r.push({ label: 'Command', value: d.command, mono: true }) | |
| 69 | + } else if (d.type === 'agent') { | |
| 70 | + if (d.model) r.push({ label: 'Model', value: d.model }) | |
| 71 | + if (d.tools?.length) r.push({ label: 'Tools', value: d.tools.join(', '), mono: true }) | |
| 72 | + } else if (d.type === 'instructions') { | |
| 73 | + r.push({ label: 'Size', value: formatBytes(d.sizeBytes) }) | |
| 74 | + r.push({ label: 'Loaded', value: d.loaded ? 'yes' : `no${d.notLoadedReason ? ` · ${d.notLoadedReason}` : ''}` }) | |
| 75 | + if (d.imports.length) r.push({ label: 'Imports', value: d.imports.join(', '), mono: true }) | |
| 76 | + } | |
| 77 | + return r | |
| 78 | +}) | |
| 79 | + | |
| 80 | +const canCatalog = computed(() => !!item.value && canAddToCatalog(item.value, status.value)) | |
| 81 | +const canMove = computed(() => home && !!item.value && canMoveOutOfHome(item.value, status.value)) | |
| 82 | + | |
| 83 | +function openInCatalog() { | |
| 84 | + const ref = status.value?.catalogRef | |
| 85 | + if (!ref) return | |
| 86 | + open.value = false | |
| 87 | + navigateTo(`/catalog/${ref.type}/${encodeURIComponent(ref.name)}`) | |
| 88 | +} | |
| 89 | +function addToCatalog() { | |
| 90 | + if (!item.value) return | |
| 91 | + open.value = false | |
| 92 | + scope.askImport([item.value]) | |
| 93 | +} | |
| 94 | +function move() { | |
| 95 | + if (!item.value) return | |
| 96 | + open.value = false | |
| 97 | + scope.askMove([item.value]) | |
| 98 | +} | |
| 99 | +</script> | |
| 100 | + | |
| 101 | +<template> | |
| 102 | + <SkDrawer v-model:open="open" width="lg"> | |
| 103 | + <template #header> | |
| 104 | + <div v-if="item" class="flex min-w-0 flex-1 flex-col gap-1"> | |
| 105 | + <div class="flex items-baseline gap-2.5"> | |
| 106 | + <h2 class="m-0 truncate text-lg font-semibold tracking-[-0.01em]">{{ display }}</h2> | |
| 107 | + <SkTypeTag :type="item.type" /> | |
| 108 | + <SkAgentBadge :agent="item.agent" class="text-xs" /> | |
| 109 | + </div> | |
| 110 | + <p v-if="item.description" class="m-0 line-clamp-3 text-pretty text-sk-muted">{{ item.description }}</p> | |
| 111 | + <SkStatus v-if="stateLabel" :tone="stateLabel.tone" :label="stateLabel.label" small class="text-xs text-sk-muted" /> | |
| 112 | + </div> | |
| 113 | + <SkButton variant="ghost" icon="i-lucide-x" aria-label="Close" class="shrink-0" @click="open = false" /> | |
| 114 | + </template> | |
| 115 | + | |
| 116 | + <div v-if="item" class="flex flex-col gap-5"> | |
| 117 | + <SkNote v-if="item.warnings.length" tone="accent"> | |
| 118 | + <ul class="m-0 flex list-none flex-col gap-1 p-0"> | |
| 119 | + <li v-for="w in item.warnings" :key="w">{{ w }}</li> | |
| 120 | + </ul> | |
| 121 | + </SkNote> | |
| 122 | + | |
| 123 | + <SkMetaGrid label-width="96px"> | |
| 124 | + <SkMeta v-for="r in rows" :key="r.label" :label="r.label" :mono="r.mono"><span class="break-all">{{ r.value }}</span></SkMeta> | |
| 125 | + </SkMetaGrid> | |
| 126 | + | |
| 127 | + <div v-if="skillFiles.length > 1" class="flex flex-col gap-2"> | |
| 128 | + <SkSectionLabel :count="skillFiles.length">Files</SkSectionLabel> | |
| 129 | + <ul class="m-0 flex max-h-44 list-none flex-col overflow-auto rounded-[10px] border border-sk-line2 p-0 font-mono text-xs"> | |
| 130 | + <li v-for="f in skillFiles" :key="f"> | |
| 131 | + <button | |
| 132 | + type="button" | |
| 133 | + class="w-full cursor-pointer truncate border-0 bg-transparent px-3 py-1.5 text-left font-mono text-xs text-sk-muted hover:bg-sk-hover" | |
| 134 | + :class="shownPath === joinPath(item.source.path, f) && 'bg-sk-sel text-sk-text'" | |
| 135 | + @click="currentFile = f === 'SKILL.md' ? null : joinPath(item.source.path, f)" | |
| 136 | + >{{ f }}</button> | |
| 137 | + </li> | |
| 138 | + </ul> | |
| 139 | + </div> | |
| 140 | + | |
| 141 | + <ScopeInstructionsViewer v-if="item.type === 'instructions'" :path="item.source.path" /> | |
| 142 | + <MarkdownView v-else-if="shownPath" :path="shownPath" :frontmatter="frontmatter" /> | |
| 143 | + <p v-else class="m-0 text-sk-faint">This item is an entry of a shared configuration file: there is no separate file to show.</p> | |
| 144 | + </div> | |
| 145 | + | |
| 146 | + <template v-if="status?.catalogRef || canCatalog || canMove" #footer> | |
| 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" /> | |
| 149 | + <SkButton v-if="canCatalog" label="Add to catalog" @click="addToCatalog" /> | |
| 150 | + <SkButton v-if="canMove" variant="primary" label="Remove from global" @click="move" /> | |
| 151 | + </div> | |
| 152 | + </template> | |
| 153 | + </SkDrawer> | |
| 154 | +</template> | |
| new file mode 100644 | |||
| @@ -0,0 +1,154 @@ | |||
| 1 | +<script setup lang="ts"> | ||
| 2 | +import type { SkillDetails } from '@skilly/core' | ||
| 3 | + | ||
| 4 | +/** | ||
| 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. | ||
| 7 | + */ | ||
| 8 | +const scope = useScopeContext() | ||
| 9 | +const open = defineModel<boolean>('open', { default: false }) | ||
| 10 | +const item = computed(() => scope.selectedItem.value) | ||
| 11 | +const status = computed(() => (item.value ? scope.statuses.value[item.value.id] : undefined)) | ||
| 12 | +const home = scope.kind === 'home' | ||
| 13 | + | ||
| 14 | +const filePath = computed<string | null>(() => { | ||
| 15 | + const it = item.value | ||
| 16 | + if (!it) return null | ||
| 17 | + if (it.type === 'skill') return joinPath(it.source.path, 'SKILL.md') | ||
| 18 | + if (['rule', 'agent', 'instructions'].includes(it.type) && it.source.kind === 'file') return it.source.path | ||
| 19 | + return null | ||
| 20 | +}) | ||
| 21 | + | ||
| 22 | +/** Skill file currently displayed (defaults to SKILL.md). */ | ||
| 23 | +const currentFile = ref<string | null>(null) | ||
| 24 | +watch(() => item.value?.id, () => { currentFile.value = null }) | ||
| 25 | +const shownPath = computed(() => currentFile.value ?? filePath.value) | ||
| 26 | + | ||
| 27 | +const skillFiles = computed(() => (item.value?.type === 'skill' | ||
| 28 | + ? [...(item.value.details as SkillDetails).files].sort((a, b) => Number(b === 'SKILL.md') - Number(a === 'SKILL.md')) | ||
| 29 | + : [])) | ||
| 30 | + | ||
| 31 | +const frontmatter = computed(() => { | ||
| 32 | + const d = item.value?.details as { frontmatter?: Record<string, unknown> } | undefined | ||
| 33 | + return currentFile.value ? undefined : d?.frontmatter | ||
| 34 | +}) | ||
| 35 | + | ||
| 36 | +const display = computed(() => (item.value ? itemDisplayName(item.value) : '')) | ||
| 37 | +const stateLabel = computed(() => { | ||
| 38 | + if (!item.value || item.value.type === 'instructions') return null | ||
| 39 | + return home ? homeStateMeta(status.value?.state) : { tone: ITEM_STATE_META[status.value?.state ?? 'not-in-catalog'].tone, label: ITEM_STATE_META[status.value?.state ?? 'not-in-catalog'].label } | ||
| 40 | +}) | ||
| 41 | + | ||
| 42 | +interface MetaRow { label: string, value: string, mono?: boolean } | ||
| 43 | +const rows = computed<MetaRow[]>(() => { | ||
| 44 | + const it = item.value | ||
| 45 | + if (!it) return [] | ||
| 46 | + const r: MetaRow[] = [{ label: 'Path', value: it.source.path, mono: true }] | ||
| 47 | + if (it.source.configKey) r.push({ label: 'Config key', value: it.source.configKey, mono: true }) | ||
| 48 | + if (it.source.symlinkTarget) r.push({ label: 'Symlink', value: `Only the link is removed when you move it. Target: ${it.source.symlinkTarget}`, mono: true }) | ||
| 49 | + if (it.source.plugin) r.push({ label: 'Plugin', value: it.source.plugin }) | ||
| 50 | + if (it.hash) r.push({ label: 'Hash', value: it.hash.slice(0, 12), mono: true }) | ||
| 51 | + const d = it.details | ||
| 52 | + if (d.type === 'skill') { | ||
| 53 | + r.push({ label: 'Visibility', value: d.visibility + (d.visibilitySource ? ` · ${d.visibilitySource}` : '') }) | ||
| 54 | + } else if (d.type === 'rule') { | ||
| 55 | + r.push({ label: 'Applies to', value: d.paths.length ? d.paths.join(', ') : 'always', mono: true }) | ||
| 56 | + } else if (d.type === 'mcp') { | ||
| 57 | + r.push({ label: 'Transport', value: d.transport }) | ||
| 58 | + if (d.command) r.push({ label: 'Command', value: [d.command, ...(d.args ?? [])].join(' '), mono: true }) | ||
| 59 | + if (d.url) r.push({ label: 'URL', value: d.url, mono: true }) | ||
| 60 | + if (d.agentScope) r.push({ label: 'Scope', value: d.agentScope }) | ||
| 61 | + r.push({ label: 'Enabled', value: d.enabled ? 'yes' : 'no' }) | ||
| 62 | + if (d.envVarNames.length) r.push({ label: 'Env vars', value: d.envVarNames.join(', '), mono: true }) | ||
| 63 | + if (d.literalEnvKeys.length) r.push({ label: 'Literal env', value: `${d.literalEnvKeys.join(', ')} (values are never shown)`, mono: true }) | ||
| 64 | + } else if (d.type === 'hook') { | ||
| 65 | + r.push({ label: 'Event', value: d.event, mono: true }) | ||
| 66 | + if (d.matcher) r.push({ label: 'Matcher', value: d.matcher, mono: true }) | ||
| 67 | + r.push({ label: 'Handler', value: d.handlerType }) | ||
| 68 | + if (d.command) r.push({ label: 'Command', value: d.command, mono: true }) | ||
| 69 | + } else if (d.type === 'agent') { | ||
| 70 | + if (d.model) r.push({ label: 'Model', value: d.model }) | ||
| 71 | + if (d.tools?.length) r.push({ label: 'Tools', value: d.tools.join(', '), mono: true }) | ||
| 72 | + } else if (d.type === 'instructions') { | ||
| 73 | + r.push({ label: 'Size', value: formatBytes(d.sizeBytes) }) | ||
| 74 | + r.push({ label: 'Loaded', value: d.loaded ? 'yes' : `no${d.notLoadedReason ? ` · ${d.notLoadedReason}` : ''}` }) | ||
| 75 | + if (d.imports.length) r.push({ label: 'Imports', value: d.imports.join(', '), mono: true }) | ||
| 76 | + } | ||
| 77 | + return r | ||
| 78 | +}) | ||
| 79 | + | ||
| 80 | +const canCatalog = computed(() => !!item.value && canAddToCatalog(item.value, status.value)) | ||
| 81 | +const canMove = computed(() => home && !!item.value && canMoveOutOfHome(item.value, status.value)) | ||
| 82 | + | ||
| 83 | +function openInCatalog() { | ||
| 84 | + const ref = status.value?.catalogRef | ||
| 85 | + if (!ref) return | ||
| 86 | + open.value = false | ||
| 87 | + navigateTo(`/catalog/${ref.type}/${encodeURIComponent(ref.name)}`) | ||
| 88 | +} | ||
| 89 | +function addToCatalog() { | ||
| 90 | + if (!item.value) return | ||
| 91 | + open.value = false | ||
| 92 | + scope.askImport([item.value]) | ||
| 93 | +} | ||
| 94 | +function move() { | ||
| 95 | + if (!item.value) return | ||
| 96 | + open.value = false | ||
| 97 | + scope.askMove([item.value]) | ||
| 98 | +} | ||
| 99 | +</script> | ||
| 100 | + | ||
| 101 | +<template> | ||
| 102 | + <SkDrawer v-model:open="open" width="lg"> | ||
| 103 | + <template #header> | ||
| 104 | + <div v-if="item" class="flex min-w-0 flex-1 flex-col gap-1"> | ||
| 105 | + <div class="flex items-baseline gap-2.5"> | ||
| 106 | + <h2 class="m-0 truncate text-lg font-semibold tracking-[-0.01em]">{{ display }}</h2> | ||
| 107 | + <SkTypeTag :type="item.type" /> | ||
| 108 | + <SkAgentBadge :agent="item.agent" class="text-xs" /> | ||
| 109 | + </div> | ||
| 110 | + <p v-if="item.description" class="m-0 line-clamp-3 text-pretty text-sk-muted">{{ item.description }}</p> | ||
| 111 | + <SkStatus v-if="stateLabel" :tone="stateLabel.tone" :label="stateLabel.label" small class="text-xs text-sk-muted" /> | ||
| 112 | + </div> | ||
| 113 | + <SkButton variant="ghost" icon="i-lucide-x" aria-label="Close" class="shrink-0" @click="open = false" /> | ||
| 114 | + </template> | ||
| 115 | + | ||
| 116 | + <div v-if="item" class="flex flex-col gap-5"> | ||
| 117 | + <SkNote v-if="item.warnings.length" tone="accent"> | ||
| 118 | + <ul class="m-0 flex list-none flex-col gap-1 p-0"> | ||
| 119 | + <li v-for="w in item.warnings" :key="w">{{ w }}</li> | ||
| 120 | + </ul> | ||
| 121 | + </SkNote> | ||
| 122 | + | ||
| 123 | + <SkMetaGrid label-width="96px"> | ||
| 124 | + <SkMeta v-for="r in rows" :key="r.label" :label="r.label" :mono="r.mono"><span class="break-all">{{ r.value }}</span></SkMeta> | ||
| 125 | + </SkMetaGrid> | ||
| 126 | + | ||
| 127 | + <div v-if="skillFiles.length > 1" class="flex flex-col gap-2"> | ||
| 128 | + <SkSectionLabel :count="skillFiles.length">Files</SkSectionLabel> | ||
| 129 | + <ul class="m-0 flex max-h-44 list-none flex-col overflow-auto rounded-[10px] border border-sk-line2 p-0 font-mono text-xs"> | ||
| 130 | + <li v-for="f in skillFiles" :key="f"> | ||
| 131 | + <button | ||
| 132 | + type="button" | ||
| 133 | + class="w-full cursor-pointer truncate border-0 bg-transparent px-3 py-1.5 text-left font-mono text-xs text-sk-muted hover:bg-sk-hover" | ||
| 134 | + :class="shownPath === joinPath(item.source.path, f) && 'bg-sk-sel text-sk-text'" | ||
| 135 | + @click="currentFile = f === 'SKILL.md' ? null : joinPath(item.source.path, f)" | ||
| 136 | + >{{ f }}</button> | ||
| 137 | + </li> | ||
| 138 | + </ul> | ||
| 139 | + </div> | ||
| 140 | + | ||
| 141 | + <ScopeInstructionsViewer v-if="item.type === 'instructions'" :path="item.source.path" /> | ||
| 142 | + <MarkdownView v-else-if="shownPath" :path="shownPath" :frontmatter="frontmatter" /> | ||
| 143 | + <p v-else class="m-0 text-sk-faint">This item is an entry of a shared configuration file: there is no separate file to show.</p> | ||
| 144 | + </div> | ||
| 145 | + | ||
| 146 | + <template v-if="status?.catalogRef || canCatalog || canMove" #footer> | ||
| 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" /> | ||
| 149 | + <SkButton v-if="canCatalog" label="Add to catalog" @click="addToCatalog" /> | ||
| 150 | + <SkButton v-if="canMove" variant="primary" label="Remove from global" @click="move" /> | ||
| 151 | + </div> | ||
| 152 | + </template> | ||
| 153 | + </SkDrawer> | ||
| 154 | +</template> | ||
added
packages/ui/app/components/scope/ScopeItemRow.vue +80 -0 | new file mode 100644 | ||
| @@ -0,0 +1,80 @@ | ||
| 1 | +<script setup lang="ts"> | |
| 2 | +import type { RowAction, ScopeRow } from '../../utils/scopeRows' | |
| 3 | + | |
| 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. | |
| 8 | + */ | |
| 9 | +const props = defineProps<{ row: ScopeRow, root: string, home?: boolean }>() | |
| 10 | +const emit = defineEmits<{ toggle: [on: boolean], mode: [onDemand: boolean], review: [], import: [] }>() | |
| 11 | + | |
| 12 | +const open = ref(false) | |
| 13 | +const faded = computed(() => props.row.excluded || props.row.kind === 'removal') | |
| 14 | +const switchable = computed(() => props.row.kind === 'entry') | |
| 15 | +const switchDisabled = computed(() => props.row.excluded && !props.row.excludedExact) | |
| 16 | +const switchTitle = computed(() => { | |
| 17 | + if (switchDisabled.value) return `Excluded by the pattern ${props.row.excludePattern}` | |
| 18 | + if (props.row.excluded) return 'Include this item' | |
| 19 | + return props.row.viaPreset ? 'Exclude from this project' : 'Remove from this project' | |
| 20 | +}) | |
| 21 | +const mode = computed({ | |
| 22 | + get: () => (props.row.onDemand.on ? 'demand' : 'auto'), | |
| 23 | + set: (v: 'auto' | 'demand') => emit('mode', v === 'demand') | |
| 24 | +}) | |
| 25 | +const MODE_OPTIONS = computed(() => [ | |
| 26 | + { value: 'auto' as const, label: 'Automatic', disabled: props.row.onDemand.on && !props.row.onDemand.exact, title: 'The agent sees the skill and uses it when relevant' }, | |
| 27 | + { value: 'demand' as const, label: 'On demand', title: 'Installed but hidden from the agent: you invoke it with /name in Claude Code or $name in Codex' } | |
| 28 | +]) | |
| 29 | + | |
| 30 | +function onAction(kind: RowAction) { | |
| 31 | + if (kind === 'resolve') open.value = !open.value | |
| 32 | + else if (kind === 'review') emit('review') | |
| 33 | + else emit('import') | |
| 34 | +} | |
| 35 | +</script> | |
| 36 | + | |
| 37 | +<template> | |
| 38 | + <div class="border-t border-sk-line2 first:border-t-0"> | |
| 39 | + <div class="grid items-center gap-[14px] px-4 py-[9px]" style="grid-template-columns: 30px minmax(0, 1fr) auto 176px"> | |
| 40 | + <UTooltip v-if="switchable" :text="switchTitle"> | |
| 41 | + <span class="inline-flex"> | |
| 42 | + <USwitch | |
| 43 | + :model-value="!row.excluded" | |
| 44 | + :disabled="switchDisabled" | |
| 45 | + :aria-label="`${switchTitle}: ${row.name}`" | |
| 46 | + @update:model-value="(v: boolean) => emit('toggle', v)" | |
| 47 | + /> | |
| 48 | + </span> | |
| 49 | + </UTooltip> | |
| 50 | + <span v-else /> | |
| 51 | + | |
| 52 | + <div class="min-w-0" :class="faded && 'opacity-55'"> | |
| 53 | + <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> | |
| 56 | + <SkTypeTag :type="row.type" /> | |
| 57 | + </div> | |
| 58 | + <div class="flex flex-wrap items-center gap-x-2 text-xs text-sk-muted"> | |
| 59 | + <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> | |
| 61 | + </div> | |
| 62 | + </div> | |
| 63 | + | |
| 64 | + <div :class="faded && row.kind !== 'removal' && 'opacity-55'"> | |
| 65 | + <ScopeRowStatus :row="row" @action="onAction" /> | |
| 66 | + </div> | |
| 67 | + | |
| 68 | + <div class="justify-self-end"> | |
| 69 | + <template v-if="row.type === 'skill' && row.kind === 'entry' && !row.excluded"> | |
| 70 | + <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> | |
| 72 | + </template> | |
| 73 | + </div> | |
| 74 | + </div> | |
| 75 | + | |
| 76 | + <div v-if="open && row.conflicts.length" class="px-4 pb-3.5 pl-[60px]"> | |
| 77 | + <ScopeResolveBox :conflicts="row.conflicts" :root="root" :home="home" /> | |
| 78 | + </div> | |
| 79 | + </div> | |
| 80 | +</template> | |
| new file mode 100644 | |||
| @@ -0,0 +1,80 @@ | |||
| 1 | +<script setup lang="ts"> | ||
| 2 | +import type { RowAction, ScopeRow } from '../../utils/scopeRows' | ||
| 3 | + | ||
| 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. | ||
| 8 | + */ | ||
| 9 | +const props = defineProps<{ row: ScopeRow, root: string, home?: boolean }>() | ||
| 10 | +const emit = defineEmits<{ toggle: [on: boolean], mode: [onDemand: boolean], review: [], import: [] }>() | ||
| 11 | + | ||
| 12 | +const open = ref(false) | ||
| 13 | +const faded = computed(() => props.row.excluded || props.row.kind === 'removal') | ||
| 14 | +const switchable = computed(() => props.row.kind === 'entry') | ||
| 15 | +const switchDisabled = computed(() => props.row.excluded && !props.row.excludedExact) | ||
| 16 | +const switchTitle = computed(() => { | ||
| 17 | + if (switchDisabled.value) return `Excluded by the pattern ${props.row.excludePattern}` | ||
| 18 | + if (props.row.excluded) return 'Include this item' | ||
| 19 | + return props.row.viaPreset ? 'Exclude from this project' : 'Remove from this project' | ||
| 20 | +}) | ||
| 21 | +const mode = computed({ | ||
| 22 | + get: () => (props.row.onDemand.on ? 'demand' : 'auto'), | ||
| 23 | + set: (v: 'auto' | 'demand') => emit('mode', v === 'demand') | ||
| 24 | +}) | ||
| 25 | +const MODE_OPTIONS = computed(() => [ | ||
| 26 | + { value: 'auto' as const, label: 'Automatic', disabled: props.row.onDemand.on && !props.row.onDemand.exact, title: 'The agent sees the skill and uses it when relevant' }, | ||
| 27 | + { value: 'demand' as const, label: 'On demand', title: 'Installed but hidden from the agent: you invoke it with /name in Claude Code or $name in Codex' } | ||
| 28 | +]) | ||
| 29 | + | ||
| 30 | +function onAction(kind: RowAction) { | ||
| 31 | + if (kind === 'resolve') open.value = !open.value | ||
| 32 | + else if (kind === 'review') emit('review') | ||
| 33 | + else emit('import') | ||
| 34 | +} | ||
| 35 | +</script> | ||
| 36 | + | ||
| 37 | +<template> | ||
| 38 | + <div class="border-t border-sk-line2 first:border-t-0"> | ||
| 39 | + <div class="grid items-center gap-[14px] px-4 py-[9px]" style="grid-template-columns: 30px minmax(0, 1fr) auto 176px"> | ||
| 40 | + <UTooltip v-if="switchable" :text="switchTitle"> | ||
| 41 | + <span class="inline-flex"> | ||
| 42 | + <USwitch | ||
| 43 | + :model-value="!row.excluded" | ||
| 44 | + :disabled="switchDisabled" | ||
| 45 | + :aria-label="`${switchTitle}: ${row.name}`" | ||
| 46 | + @update:model-value="(v: boolean) => emit('toggle', v)" | ||
| 47 | + /> | ||
| 48 | + </span> | ||
| 49 | + </UTooltip> | ||
| 50 | + <span v-else /> | ||
| 51 | + | ||
| 52 | + <div class="min-w-0" :class="faded && 'opacity-55'"> | ||
| 53 | + <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> | ||
| 56 | + <SkTypeTag :type="row.type" /> | ||
| 57 | + </div> | ||
| 58 | + <div class="flex flex-wrap items-center gap-x-2 text-xs text-sk-muted"> | ||
| 59 | + <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> | ||
| 61 | + </div> | ||
| 62 | + </div> | ||
| 63 | + | ||
| 64 | + <div :class="faded && row.kind !== 'removal' && 'opacity-55'"> | ||
| 65 | + <ScopeRowStatus :row="row" @action="onAction" /> | ||
| 66 | + </div> | ||
| 67 | + | ||
| 68 | + <div class="justify-self-end"> | ||
| 69 | + <template v-if="row.type === 'skill' && row.kind === 'entry' && !row.excluded"> | ||
| 70 | + <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> | ||
| 72 | + </template> | ||
| 73 | + </div> | ||
| 74 | + </div> | ||
| 75 | + | ||
| 76 | + <div v-if="open && row.conflicts.length" class="px-4 pb-3.5 pl-[60px]"> | ||
| 77 | + <ScopeResolveBox :conflicts="row.conflicts" :root="root" :home="home" /> | ||
| 78 | + </div> | ||
| 79 | + </div> | ||
| 80 | +</template> | ||
added
packages/ui/app/components/scope/ScopeItemsSection.vue +140 -0 | new file mode 100644 | ||
| @@ -0,0 +1,140 @@ | ||
| 1 | +<script setup lang="ts"> | |
| 2 | +import type { CatalogItemType } from '@skilly/core' | |
| 3 | +import type { HomeNames, ScopeRow } from '../../utils/scopeRows' | |
| 4 | + | |
| 5 | +/** "Items in this project": one row per item with switch, status, mode and inline resolution (mockup 9-10). */ | |
| 6 | +const scope = useScopeContext() | |
| 7 | +const catalog = useCatalog() | |
| 8 | +const { home: homeInv, refresh: refreshHome } = useHomeInventory() | |
| 9 | +const home = scope.kind === 'home' | |
| 10 | + | |
| 11 | +onMounted(() => { | |
| 12 | + if (!home && !homeInv.value) refreshHome() | |
| 13 | +}) | |
| 14 | + | |
| 15 | +const homeNames = computed<HomeNames>(() => { | |
| 16 | + const out: HomeNames = { skill: new Set(), mcp: new Set() } | |
| 17 | + for (const i of homeInv.value?.items ?? []) { | |
| 18 | + if (i.type === 'skill') out.skill.add(i.name) | |
| 19 | + else if (i.type === 'mcp') out.mcp.add(i.name) | |
| 20 | + } | |
| 21 | + return out | |
| 22 | +}) | |
| 23 | + | |
| 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 | + | |
| 39 | +const hint = computed(() => writtenToHint(scope.config.value?.targets ?? [], home)) | |
| 40 | + | |
| 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 | |
| 48 | +}) | |
| 49 | + | |
| 50 | +function onToggle(row: ScopeRow, on: boolean) { | |
| 51 | + scope.edit((c) => { | |
| 52 | + const t = row.type | |
| 53 | + if (row.excluded || row.viaPreset) { | |
| 54 | + const list = toggleIn(c.exclude[t] ?? [], row.name, !on) | |
| 55 | + if (list.length) c.exclude[t] = list | |
| 56 | + else delete c.exclude[t] | |
| 57 | + } else { | |
| 58 | + const list = (c.add[t] ?? []).filter(x => x !== row.name) | |
| 59 | + if (list.length) c.add[t] = list | |
| 60 | + else delete c.add[t] | |
| 61 | + } | |
| 62 | + }) | |
| 63 | +} | |
| 64 | + | |
| 65 | +function onMode(row: ScopeRow, onDemand: boolean) { | |
| 66 | + scope.edit((c) => { c.onDemand.skills = toggleIn(c.onDemand.skills, row.name, onDemand) }) | |
| 67 | +} | |
| 68 | + | |
| 69 | +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) | |
| 71 | + if (items.length) scope.askImport(items) | |
| 72 | +} | |
| 73 | + | |
| 74 | +/* ---------------------------------------------------------------- add items */ | |
| 75 | + | |
| 76 | +const ADD_TYPES = computed<CatalogItemType[]>(() => (home ? ['skill', 'rule', 'agent'] : ['skill', 'rule', 'agent', 'mcp', 'hook'])) | |
| 77 | +const pickerOpen = ref(false) | |
| 78 | +const pickerSel = ref<Partial<Record<CatalogItemType, string[]>>>({}) | |
| 79 | +const pickable = computed(() => catalog.items.value.filter(i => ADD_TYPES.value.includes(i.type))) | |
| 80 | +const locked = computed(() => { | |
| 81 | + const m: Record<string, string> = {} | |
| 82 | + const c = scope.config.value | |
| 83 | + if (!c) return m | |
| 84 | + for (const e of resolveItems(catalog.presets.value, c.presets, {}).entries) { | |
| 85 | + m[`${e.type}/${e.name}`] = `via ${e.via[0]}` | |
| 86 | + } | |
| 87 | + return m | |
| 88 | +}) | |
| 89 | + | |
| 90 | +function openPicker() { | |
| 91 | + pickerSel.value = structuredClone(toRaw(scope.config.value?.add ?? {})) | |
| 92 | + pickerOpen.value = true | |
| 93 | +} | |
| 94 | +function confirmPicker() { | |
| 95 | + scope.edit((c) => { | |
| 96 | + for (const t of ADD_TYPES.value) { | |
| 97 | + const l = pickerSel.value[t] ?? [] | |
| 98 | + if (l.length) c.add[t] = l | |
| 99 | + else delete c.add[t] | |
| 100 | + } | |
| 101 | + }) | |
| 102 | + pickerOpen.value = false | |
| 103 | +} | |
| 104 | +</script> | |
| 105 | + | |
| 106 | +<template> | |
| 107 | + <SkSection :title="home ? 'Items in Home' : 'Items in this project'"> | |
| 108 | + <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" /> | |
| 111 | + </template> | |
| 112 | + | |
| 113 | + <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.' }} | |
| 127 | + </SkEmpty> | |
| 128 | + </SkPanel> | |
| 129 | + <p v-if="patterns.length" class="m-0 font-mono text-[11px] text-sk-faint">Patterns kept as is: {{ patterns.join(' · ') }}</p> | |
| 130 | + </SkSection> | |
| 131 | + | |
| 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"> | |
| 133 | + <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> | |
| 135 | + <template #footer> | |
| 136 | + <SkButton label="Cancel" @click="pickerOpen = false" /> | |
| 137 | + <SkButton variant="primary" label="Save selection" @click="confirmPicker" /> | |
| 138 | + </template> | |
| 139 | + </SkModal> | |
| 140 | +</template> | |
| new file mode 100644 | |||
| @@ -0,0 +1,140 @@ | |||
| 1 | +<script setup lang="ts"> | ||
| 2 | +import type { CatalogItemType } from '@skilly/core' | ||
| 3 | +import type { HomeNames, ScopeRow } from '../../utils/scopeRows' | ||
| 4 | + | ||
| 5 | +/** "Items in this project": one row per item with switch, status, mode and inline resolution (mockup 9-10). */ | ||
| 6 | +const scope = useScopeContext() | ||
| 7 | +const catalog = useCatalog() | ||
| 8 | +const { home: homeInv, refresh: refreshHome } = useHomeInventory() | ||
| 9 | +const home = scope.kind === 'home' | ||
| 10 | + | ||
| 11 | +onMounted(() => { | ||
| 12 | + if (!home && !homeInv.value) refreshHome() | ||
| 13 | +}) | ||
| 14 | + | ||
| 15 | +const homeNames = computed<HomeNames>(() => { | ||
| 16 | + const out: HomeNames = { skill: new Set(), mcp: new Set() } | ||
| 17 | + for (const i of homeInv.value?.items ?? []) { | ||
| 18 | + if (i.type === 'skill') out.skill.add(i.name) | ||
| 19 | + else if (i.type === 'mcp') out.mcp.add(i.name) | ||
| 20 | + } | ||
| 21 | + return out | ||
| 22 | +}) | ||
| 23 | + | ||
| 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 | + | ||
| 39 | +const hint = computed(() => writtenToHint(scope.config.value?.targets ?? [], home)) | ||
| 40 | + | ||
| 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 | ||
| 48 | +}) | ||
| 49 | + | ||
| 50 | +function onToggle(row: ScopeRow, on: boolean) { | ||
| 51 | + scope.edit((c) => { | ||
| 52 | + const t = row.type | ||
| 53 | + if (row.excluded || row.viaPreset) { | ||
| 54 | + const list = toggleIn(c.exclude[t] ?? [], row.name, !on) | ||
| 55 | + if (list.length) c.exclude[t] = list | ||
| 56 | + else delete c.exclude[t] | ||
| 57 | + } else { | ||
| 58 | + const list = (c.add[t] ?? []).filter(x => x !== row.name) | ||
| 59 | + if (list.length) c.add[t] = list | ||
| 60 | + else delete c.add[t] | ||
| 61 | + } | ||
| 62 | + }) | ||
| 63 | +} | ||
| 64 | + | ||
| 65 | +function onMode(row: ScopeRow, onDemand: boolean) { | ||
| 66 | + scope.edit((c) => { c.onDemand.skills = toggleIn(c.onDemand.skills, row.name, onDemand) }) | ||
| 67 | +} | ||
| 68 | + | ||
| 69 | +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) | ||
| 71 | + if (items.length) scope.askImport(items) | ||
| 72 | +} | ||
| 73 | + | ||
| 74 | +/* ---------------------------------------------------------------- add items */ | ||
| 75 | + | ||
| 76 | +const ADD_TYPES = computed<CatalogItemType[]>(() => (home ? ['skill', 'rule', 'agent'] : ['skill', 'rule', 'agent', 'mcp', 'hook'])) | ||
| 77 | +const pickerOpen = ref(false) | ||
| 78 | +const pickerSel = ref<Partial<Record<CatalogItemType, string[]>>>({}) | ||
| 79 | +const pickable = computed(() => catalog.items.value.filter(i => ADD_TYPES.value.includes(i.type))) | ||
| 80 | +const locked = computed(() => { | ||
| 81 | + const m: Record<string, string> = {} | ||
| 82 | + const c = scope.config.value | ||
| 83 | + if (!c) return m | ||
| 84 | + for (const e of resolveItems(catalog.presets.value, c.presets, {}).entries) { | ||
| 85 | + m[`${e.type}/${e.name}`] = `via ${e.via[0]}` | ||
| 86 | + } | ||
| 87 | + return m | ||
| 88 | +}) | ||
| 89 | + | ||
| 90 | +function openPicker() { | ||
| 91 | + pickerSel.value = structuredClone(toRaw(scope.config.value?.add ?? {})) | ||
| 92 | + pickerOpen.value = true | ||
| 93 | +} | ||
| 94 | +function confirmPicker() { | ||
| 95 | + scope.edit((c) => { | ||
| 96 | + for (const t of ADD_TYPES.value) { | ||
| 97 | + const l = pickerSel.value[t] ?? [] | ||
| 98 | + if (l.length) c.add[t] = l | ||
| 99 | + else delete c.add[t] | ||
| 100 | + } | ||
| 101 | + }) | ||
| 102 | + pickerOpen.value = false | ||
| 103 | +} | ||
| 104 | +</script> | ||
| 105 | + | ||
| 106 | +<template> | ||
| 107 | + <SkSection :title="home ? 'Items in Home' : 'Items in this project'"> | ||
| 108 | + <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" /> | ||
| 111 | + </template> | ||
| 112 | + | ||
| 113 | + <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.' }} | ||
| 127 | + </SkEmpty> | ||
| 128 | + </SkPanel> | ||
| 129 | + <p v-if="patterns.length" class="m-0 font-mono text-[11px] text-sk-faint">Patterns kept as is: {{ patterns.join(' · ') }}</p> | ||
| 130 | + </SkSection> | ||
| 131 | + | ||
| 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"> | ||
| 133 | + <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> | ||
| 135 | + <template #footer> | ||
| 136 | + <SkButton label="Cancel" @click="pickerOpen = false" /> | ||
| 137 | + <SkButton variant="primary" label="Save selection" @click="confirmPicker" /> | ||
| 138 | + </template> | ||
| 139 | + </SkModal> | ||
| 140 | +</template> | ||
added
packages/ui/app/components/scope/ScopeManageCard.vue +59 -0 | new file mode 100644 | ||
| @@ -0,0 +1,59 @@ | ||
| 1 | +<script setup lang="ts"> | |
| 2 | +import type { AgentId } from '@skilly/core' | |
| 3 | + | |
| 4 | +/** Empty state of a scope without skilly.json / home.json: choose the target agents and start managing. */ | |
| 5 | +const scope = useScopeContext() | |
| 6 | +const home = scope.kind === 'home' | |
| 7 | +const AGENT_LIST: AgentId[] = ['claude', 'codex'] | |
| 8 | + | |
| 9 | +const targets = ref<AgentId[]>([]) | |
| 10 | +watch(scope.inventory, (inv) => { | |
| 11 | + if (!targets.value.length) targets.value = inv?.agentsDetected.length ? [...inv.agentsDetected] : [...AGENT_LIST] | |
| 12 | +}, { immediate: true }) | |
| 13 | + | |
| 14 | +function toggle(a: AgentId, on: boolean | 'indeterminate') { | |
| 15 | + targets.value = on === true ? [...new Set([...targets.value, a])] : targets.value.filter(x => x !== a) | |
| 16 | +} | |
| 17 | + | |
| 18 | +function start() { | |
| 19 | + scope.saveConfig(emptyConfig(targets.value), true) | |
| 20 | +} | |
| 21 | +</script> | |
| 22 | + | |
| 23 | +<template> | |
| 24 | + <SkPanel padded class="flex flex-col gap-5"> | |
| 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> | |
| 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. | |
| 29 | + {{ home | |
| 30 | + ? 'Settings are stored in ~/.skilly/home.json. Nothing is written into your agent folders 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 | + </p> | |
| 33 | + </div> | |
| 34 | + | |
| 35 | + <div class="flex flex-col gap-2.5"> | |
| 36 | + <SkSectionLabel>Write files for</SkSectionLabel> | |
| 37 | + <div class="grid gap-2.5 sm:grid-cols-2"> | |
| 38 | + <label | |
| 39 | + v-for="a in AGENT_LIST" | |
| 40 | + :key="a" | |
| 41 | + class="flex cursor-pointer items-start gap-3 rounded-[10px] border p-3.5 transition-colors" | |
| 42 | + :class="targets.includes(a) ? 'border-sk-ink' : 'border-sk-line hover:border-sk-faint'" | |
| 43 | + > | |
| 44 | + <UCheckbox :model-value="targets.includes(a)" :aria-label="AGENT_LABELS[a]" @update:model-value="(v) => toggle(a, v)" /> | |
| 45 | + <span class="flex flex-col gap-0.5"> | |
| 46 | + <span class="font-medium">{{ AGENT_LABELS[a] }}</span> | |
| 47 | + <span class="font-mono text-[11px] text-sk-faint">{{ a === 'claude' ? (home ? '~/.claude' : '.claude/') : (home ? '~/.agents · ~/.codex' : '.agents/ · .codex/') }}</span> | |
| 48 | + <span v-if="scope.inventory.value?.agentsDetected.includes(a)" class="text-xs text-sk-muted">Detected here</span> | |
| 49 | + </span> | |
| 50 | + </label> | |
| 51 | + </div> | |
| 52 | + </div> | |
| 53 | + | |
| 54 | + <div class="flex items-center gap-3"> | |
| 55 | + <SkButton variant="primary" label="Start managing" :loading="scope.saving.value" :disabled="!targets.length" @click="start" /> | |
| 56 | + <span v-if="!targets.length" class="text-xs text-sk-accent-text">Pick at least one agent.</span> | |
| 57 | + </div> | |
| 58 | + </SkPanel> | |
| 59 | +</template> | |
| new file mode 100644 | |||
| @@ -0,0 +1,59 @@ | |||
| 1 | +<script setup lang="ts"> | ||
| 2 | +import type { AgentId } from '@skilly/core' | ||
| 3 | + | ||
| 4 | +/** Empty state of a scope without skilly.json / home.json: choose the target agents and start managing. */ | ||
| 5 | +const scope = useScopeContext() | ||
| 6 | +const home = scope.kind === 'home' | ||
| 7 | +const AGENT_LIST: AgentId[] = ['claude', 'codex'] | ||
| 8 | + | ||
| 9 | +const targets = ref<AgentId[]>([]) | ||
| 10 | +watch(scope.inventory, (inv) => { | ||
| 11 | + if (!targets.value.length) targets.value = inv?.agentsDetected.length ? [...inv.agentsDetected] : [...AGENT_LIST] | ||
| 12 | +}, { immediate: true }) | ||
| 13 | + | ||
| 14 | +function toggle(a: AgentId, on: boolean | 'indeterminate') { | ||
| 15 | + targets.value = on === true ? [...new Set([...targets.value, a])] : targets.value.filter(x => x !== a) | ||
| 16 | +} | ||
| 17 | + | ||
| 18 | +function start() { | ||
| 19 | + scope.saveConfig(emptyConfig(targets.value), true) | ||
| 20 | +} | ||
| 21 | +</script> | ||
| 22 | + | ||
| 23 | +<template> | ||
| 24 | + <SkPanel padded class="flex flex-col gap-5"> | ||
| 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> | ||
| 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. | ||
| 29 | + {{ home | ||
| 30 | + ? 'Settings are stored in ~/.skilly/home.json. Nothing is written into your agent folders 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 | + </p> | ||
| 33 | + </div> | ||
| 34 | + | ||
| 35 | + <div class="flex flex-col gap-2.5"> | ||
| 36 | + <SkSectionLabel>Write files for</SkSectionLabel> | ||
| 37 | + <div class="grid gap-2.5 sm:grid-cols-2"> | ||
| 38 | + <label | ||
| 39 | + v-for="a in AGENT_LIST" | ||
| 40 | + :key="a" | ||
| 41 | + class="flex cursor-pointer items-start gap-3 rounded-[10px] border p-3.5 transition-colors" | ||
| 42 | + :class="targets.includes(a) ? 'border-sk-ink' : 'border-sk-line hover:border-sk-faint'" | ||
| 43 | + > | ||
| 44 | + <UCheckbox :model-value="targets.includes(a)" :aria-label="AGENT_LABELS[a]" @update:model-value="(v) => toggle(a, v)" /> | ||
| 45 | + <span class="flex flex-col gap-0.5"> | ||
| 46 | + <span class="font-medium">{{ AGENT_LABELS[a] }}</span> | ||
| 47 | + <span class="font-mono text-[11px] text-sk-faint">{{ a === 'claude' ? (home ? '~/.claude' : '.claude/') : (home ? '~/.agents · ~/.codex' : '.agents/ · .codex/') }}</span> | ||
| 48 | + <span v-if="scope.inventory.value?.agentsDetected.includes(a)" class="text-xs text-sk-muted">Detected here</span> | ||
| 49 | + </span> | ||
| 50 | + </label> | ||
| 51 | + </div> | ||
| 52 | + </div> | ||
| 53 | + | ||
| 54 | + <div class="flex items-center gap-3"> | ||
| 55 | + <SkButton variant="primary" label="Start managing" :loading="scope.saving.value" :disabled="!targets.length" @click="start" /> | ||
| 56 | + <span v-if="!targets.length" class="text-xs text-sk-accent-text">Pick at least one agent.</span> | ||
| 57 | + </div> | ||
| 58 | + </SkPanel> | ||
| 59 | +</template> | ||
added
packages/ui/app/components/scope/ScopePage.vue +200 -0 | new file mode 100644 | ||
| @@ -0,0 +1,200 @@ | ||
| 1 | +<script setup lang="ts"> | |
| 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). | |
| 5 | + * State lives in `createScope` and is shared with the scope components through `useScopeContext()`. | |
| 6 | + */ | |
| 7 | +const props = defineProps<{ kind: 'home' | 'project', projectId?: string }>() | |
| 8 | + | |
| 9 | +const scope = createScope(props.kind, () => props.projectId) | |
| 10 | +provideScope(scope) | |
| 11 | +const { | |
| 12 | + config, inventory, loading, error, saving, plan, statuses, folderMissing, rootPath, title, scopeId, pending, openConflicts, | |
| 13 | + itemOpen, reviewOpen, importOpen, importItems, moveOpen, moveItems | |
| 14 | +} = scope | |
| 15 | + | |
| 16 | +const { remove } = useProjects() | |
| 17 | +const catalog = useCatalog() | |
| 18 | +const route = useRoute() | |
| 19 | +const router = useRouter() | |
| 20 | +const toast = useToast() | |
| 21 | +const home = props.kind === 'home' | |
| 22 | + | |
| 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' }] | |
| 27 | +const tab = computed<Tab>({ | |
| 28 | + get: () => { | |
| 29 | + const q = String(route.query.tab ?? '') | |
| 30 | + return (TABS.find(t => t.value === q)?.value ?? TABS[0]!.value) | |
| 31 | + }, | |
| 32 | + set: (v) => { router.replace({ query: { ...route.query, tab: v === TABS[0]!.value ? undefined : v } }) } | |
| 33 | +}) | |
| 34 | + | |
| 35 | +onMounted(async () => { | |
| 36 | + if (!catalog.data.value) catalog.refresh() | |
| 37 | + await scope.load() | |
| 38 | +}) | |
| 39 | + | |
| 40 | +/* ---------------------------------------------------------------- header */ | |
| 41 | + | |
| 42 | +const removeOpen = ref(false) | |
| 43 | +const removing = ref(false) | |
| 44 | +const scanErrorsOpen = ref(false) | |
| 45 | + | |
| 46 | +async function copyPath() { | |
| 47 | + try { | |
| 48 | + await navigator.clipboard.writeText(rootPath.value) | |
| 49 | + toast.add({ title: 'Path copied', icon: 'i-lucide-check', color: 'success' }) | |
| 50 | + } catch { | |
| 51 | + toast.add({ title: 'Could not copy', color: 'error' }) | |
| 52 | + } | |
| 53 | +} | |
| 54 | + | |
| 55 | +async function confirmRemove() { | |
| 56 | + if (!props.projectId) return | |
| 57 | + removing.value = true | |
| 58 | + try { | |
| 59 | + await remove(props.projectId) | |
| 60 | + removeOpen.value = false | |
| 61 | + toast.add({ title: 'Project removed from skilly', description: 'No file was deleted.', icon: 'i-lucide-check', color: 'success' }) | |
| 62 | + await router.push('/projects') | |
| 63 | + } catch { | |
| 64 | + // The error toast comes from useApi. | |
| 65 | + } finally { | |
| 66 | + removing.value = false | |
| 67 | + } | |
| 68 | +} | |
| 69 | + | |
| 70 | +const menu = computed(() => [[ | |
| 71 | + { label: 'Rescan', icon: 'i-lucide-refresh-cw', onSelect: () => scope.rescan() }, | |
| 72 | + { label: 'Copy path', icon: 'i-lucide-copy', onSelect: copyPath }, | |
| 73 | + ...(home ? [] : [{ label: 'Remove from skilly', icon: 'i-lucide-trash-2', color: 'error' as const, onSelect: () => { removeOpen.value = true } }]) | |
| 74 | +]]) | |
| 75 | + | |
| 76 | +const agentsText = computed(() => { | |
| 77 | + const list = config.value?.targets ?? inventory.value?.agentsDetected ?? [] | |
| 78 | + return list.length ? list.map(a => AGENT_LABELS[a]).join(', ') : 'No agent detected' | |
| 79 | +}) | |
| 80 | + | |
| 81 | +/* ---------------------------------------------------------------- pending bar */ | |
| 82 | + | |
| 83 | +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> | |
| 87 | + | |
| 88 | +<template> | |
| 89 | + <SkPage> | |
| 90 | + <SkPageHeader :title="home ? 'Home · user-level config' : title" :subtitle="home ? 'Everything here is visible in every project.' : undefined" :aside="home"> | |
| 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> | |
| 94 | + <span class="flex flex-wrap items-center gap-x-2 gap-y-1 font-mono text-xs text-sk-faint"> | |
| 95 | + <span class="max-w-[360px] truncate" :title="rootPath">{{ rootPath }}</span> | |
| 96 | + <template v-if="inventory"> | |
| 97 | + <span aria-hidden="true">·</span> | |
| 98 | + <ScopeTargets v-if="config" /> | |
| 99 | + <span v-else>{{ agentsText }}</span> | |
| 100 | + </template> | |
| 101 | + </span> | |
| 102 | + </template> | |
| 103 | + <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" /> | |
| 106 | + <UDropdownMenu v-if="!home" :items="menu" :content="{ align: 'end' }"> | |
| 107 | + <SkButton variant="ghost" icon="i-lucide-ellipsis" aria-label="More actions" /> | |
| 108 | + </UDropdownMenu> | |
| 109 | + </template> | |
| 110 | + </SkPageHeader> | |
| 111 | + | |
| 112 | + <!-- Loading --> | |
| 113 | + <div v-if="loading && !inventory" class="flex flex-col gap-3"> | |
| 114 | + <USkeleton class="h-24 w-full rounded-[20px]" /> | |
| 115 | + <USkeleton class="h-64 w-full rounded-[20px]" /> | |
| 116 | + </div> | |
| 117 | + | |
| 118 | + <!-- Error --> | |
| 119 | + <template v-else-if="error && !inventory"> | |
| 120 | + <SkNote v-if="folderMissing" tone="accent"> | |
| 121 | + <p class="m-0 font-semibold">This folder no longer exists on disk</p> | |
| 122 | + <p class="m-0 mt-1">{{ rootPath }} was moved or deleted. Remove the project from skilly, or retry once the folder is back.</p> | |
| 123 | + <template #aside> | |
| 124 | + <div class="flex gap-2"> | |
| 125 | + <SkButton size="sm" label="Retry" @click="scope.load()" /> | |
| 126 | + <SkButton size="sm" variant="danger" label="Remove from skilly" @click="removeOpen = true" /> | |
| 127 | + </div> | |
| 128 | + </template> | |
| 129 | + </SkNote> | |
| 130 | + <SkNote v-else tone="accent"> | |
| 131 | + <p class="m-0 font-semibold">Could not load this {{ home ? 'Home' : 'project' }}</p> | |
| 132 | + <p class="m-0 mt-1">{{ error }}</p> | |
| 133 | + <template #aside><SkButton size="sm" label="Retry" @click="scope.load()" /></template> | |
| 134 | + </SkNote> | |
| 135 | + </template> | |
| 136 | + | |
| 137 | + <template v-else-if="inventory"> | |
| 138 | + <SkNote v-if="folderMissing" tone="accent"> | |
| 139 | + <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> | |
| 141 | + </SkNote> | |
| 142 | + | |
| 143 | + <SkNote v-if="inventory.errors.length && tab !== 'inventory'" tone="accent"> | |
| 144 | + <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 | |
| 146 | + <span class="font-normal opacity-80">· {{ scanErrorsOpen ? 'hide' : 'show' }} details</span> | |
| 147 | + </button> | |
| 148 | + <ScopeScanErrors v-if="scanErrorsOpen" :errors="inventory.errors" :root="rootPath" /> | |
| 149 | + </SkNote> | |
| 150 | + | |
| 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> | |
| 170 | + </template> | |
| 171 | + | |
| 172 | + <SkActionBar | |
| 173 | + v-if="barVisible" | |
| 174 | + :changes="pending.length" | |
| 175 | + :conflicts="openConflicts.length" | |
| 176 | + :saved="!saving" | |
| 177 | + :busy="saving" | |
| 178 | + @review="scope.openReview()" | |
| 179 | + > | |
| 180 | + <span v-if="!pending.length && plan?.missing.length" class="text-sk-muted">{{ plan.missing.length }} item{{ plan.missing.length > 1 ? 's' : '' }} not found in the catalog</span> | |
| 181 | + <span v-if="saving" class="font-mono text-[11px] text-sk-faint">Saving…</span> | |
| 182 | + </SkActionBar> | |
| 183 | + </SkPage> | |
| 184 | + | |
| 185 | + <ScopeItemDrawer v-model:open="itemOpen" /> | |
| 186 | + <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" /> | |
| 188 | + <RemoveFromGlobalModal v-model:open="moveOpen" :items="moveItems" :statuses="statuses" @done="scope.afterCatalogChange" /> | |
| 189 | + | |
| 190 | + <SkModal v-model:open="removeOpen" title="Remove project from skilly" description="This only forgets the project in skilly." width="sm"> | |
| 191 | + <p class="m-0"> | |
| 192 | + <strong>{{ title }}</strong> will disappear from the project list. <strong>No file is deleted</strong>: | |
| 193 | + the folder and its agent configuration stay untouched. You can add it again at any time. | |
| 194 | + </p> | |
| 195 | + <template #footer> | |
| 196 | + <SkButton label="Cancel" @click="removeOpen = false" /> | |
| 197 | + <SkButton variant="primary" label="Remove from skilly" :loading="removing" @click="confirmRemove" /> | |
| 198 | + </template> | |
| 199 | + </SkModal> | |
| 200 | +</template> | |
| new file mode 100644 | |||
| @@ -0,0 +1,200 @@ | |||
| 1 | +<script setup lang="ts"> | ||
| 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). | ||
| 5 | + * State lives in `createScope` and is shared with the scope components through `useScopeContext()`. | ||
| 6 | + */ | ||
| 7 | +const props = defineProps<{ kind: 'home' | 'project', projectId?: string }>() | ||
| 8 | + | ||
| 9 | +const scope = createScope(props.kind, () => props.projectId) | ||
| 10 | +provideScope(scope) | ||
| 11 | +const { | ||
| 12 | + config, inventory, loading, error, saving, plan, statuses, folderMissing, rootPath, title, scopeId, pending, openConflicts, | ||
| 13 | + itemOpen, reviewOpen, importOpen, importItems, moveOpen, moveItems | ||
| 14 | +} = scope | ||
| 15 | + | ||
| 16 | +const { remove } = useProjects() | ||
| 17 | +const catalog = useCatalog() | ||
| 18 | +const route = useRoute() | ||
| 19 | +const router = useRouter() | ||
| 20 | +const toast = useToast() | ||
| 21 | +const home = props.kind === 'home' | ||
| 22 | + | ||
| 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' }] | ||
| 27 | +const tab = computed<Tab>({ | ||
| 28 | + get: () => { | ||
| 29 | + const q = String(route.query.tab ?? '') | ||
| 30 | + return (TABS.find(t => t.value === q)?.value ?? TABS[0]!.value) | ||
| 31 | + }, | ||
| 32 | + set: (v) => { router.replace({ query: { ...route.query, tab: v === TABS[0]!.value ? undefined : v } }) } | ||
| 33 | +}) | ||
| 34 | + | ||
| 35 | +onMounted(async () => { | ||
| 36 | + if (!catalog.data.value) catalog.refresh() | ||
| 37 | + await scope.load() | ||
| 38 | +}) | ||
| 39 | + | ||
| 40 | +/* ---------------------------------------------------------------- header */ | ||
| 41 | + | ||
| 42 | +const removeOpen = ref(false) | ||
| 43 | +const removing = ref(false) | ||
| 44 | +const scanErrorsOpen = ref(false) | ||
| 45 | + | ||
| 46 | +async function copyPath() { | ||
| 47 | + try { | ||
| 48 | + await navigator.clipboard.writeText(rootPath.value) | ||
| 49 | + toast.add({ title: 'Path copied', icon: 'i-lucide-check', color: 'success' }) | ||
| 50 | + } catch { | ||
| 51 | + toast.add({ title: 'Could not copy', color: 'error' }) | ||
| 52 | + } | ||
| 53 | +} | ||
| 54 | + | ||
| 55 | +async function confirmRemove() { | ||
| 56 | + if (!props.projectId) return | ||
| 57 | + removing.value = true | ||
| 58 | + try { | ||
| 59 | + await remove(props.projectId) | ||
| 60 | + removeOpen.value = false | ||
| 61 | + toast.add({ title: 'Project removed from skilly', description: 'No file was deleted.', icon: 'i-lucide-check', color: 'success' }) | ||
| 62 | + await router.push('/projects') | ||
| 63 | + } catch { | ||
| 64 | + // The error toast comes from useApi. | ||
| 65 | + } finally { | ||
| 66 | + removing.value = false | ||
| 67 | + } | ||
| 68 | +} | ||
| 69 | + | ||
| 70 | +const menu = computed(() => [[ | ||
| 71 | + { label: 'Rescan', icon: 'i-lucide-refresh-cw', onSelect: () => scope.rescan() }, | ||
| 72 | + { label: 'Copy path', icon: 'i-lucide-copy', onSelect: copyPath }, | ||
| 73 | + ...(home ? [] : [{ label: 'Remove from skilly', icon: 'i-lucide-trash-2', color: 'error' as const, onSelect: () => { removeOpen.value = true } }]) | ||
| 74 | +]]) | ||
| 75 | + | ||
| 76 | +const agentsText = computed(() => { | ||
| 77 | + const list = config.value?.targets ?? inventory.value?.agentsDetected ?? [] | ||
| 78 | + return list.length ? list.map(a => AGENT_LABELS[a]).join(', ') : 'No agent detected' | ||
| 79 | +}) | ||
| 80 | + | ||
| 81 | +/* ---------------------------------------------------------------- pending bar */ | ||
| 82 | + | ||
| 83 | +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> | ||
| 87 | + | ||
| 88 | +<template> | ||
| 89 | + <SkPage> | ||
| 90 | + <SkPageHeader :title="home ? 'Home · user-level config' : title" :subtitle="home ? 'Everything here is visible in every project.' : undefined" :aside="home"> | ||
| 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> | ||
| 94 | + <span class="flex flex-wrap items-center gap-x-2 gap-y-1 font-mono text-xs text-sk-faint"> | ||
| 95 | + <span class="max-w-[360px] truncate" :title="rootPath">{{ rootPath }}</span> | ||
| 96 | + <template v-if="inventory"> | ||
| 97 | + <span aria-hidden="true">·</span> | ||
| 98 | + <ScopeTargets v-if="config" /> | ||
| 99 | + <span v-else>{{ agentsText }}</span> | ||
| 100 | + </template> | ||
| 101 | + </span> | ||
| 102 | + </template> | ||
| 103 | + <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" /> | ||
| 106 | + <UDropdownMenu v-if="!home" :items="menu" :content="{ align: 'end' }"> | ||
| 107 | + <SkButton variant="ghost" icon="i-lucide-ellipsis" aria-label="More actions" /> | ||
| 108 | + </UDropdownMenu> | ||
| 109 | + </template> | ||
| 110 | + </SkPageHeader> | ||
| 111 | + | ||
| 112 | + <!-- Loading --> | ||
| 113 | + <div v-if="loading && !inventory" class="flex flex-col gap-3"> | ||
| 114 | + <USkeleton class="h-24 w-full rounded-[20px]" /> | ||
| 115 | + <USkeleton class="h-64 w-full rounded-[20px]" /> | ||
| 116 | + </div> | ||
| 117 | + | ||
| 118 | + <!-- Error --> | ||
| 119 | + <template v-else-if="error && !inventory"> | ||
| 120 | + <SkNote v-if="folderMissing" tone="accent"> | ||
| 121 | + <p class="m-0 font-semibold">This folder no longer exists on disk</p> | ||
| 122 | + <p class="m-0 mt-1">{{ rootPath }} was moved or deleted. Remove the project from skilly, or retry once the folder is back.</p> | ||
| 123 | + <template #aside> | ||
| 124 | + <div class="flex gap-2"> | ||
| 125 | + <SkButton size="sm" label="Retry" @click="scope.load()" /> | ||
| 126 | + <SkButton size="sm" variant="danger" label="Remove from skilly" @click="removeOpen = true" /> | ||
| 127 | + </div> | ||
| 128 | + </template> | ||
| 129 | + </SkNote> | ||
| 130 | + <SkNote v-else tone="accent"> | ||
| 131 | + <p class="m-0 font-semibold">Could not load this {{ home ? 'Home' : 'project' }}</p> | ||
| 132 | + <p class="m-0 mt-1">{{ error }}</p> | ||
| 133 | + <template #aside><SkButton size="sm" label="Retry" @click="scope.load()" /></template> | ||
| 134 | + </SkNote> | ||
| 135 | + </template> | ||
| 136 | + | ||
| 137 | + <template v-else-if="inventory"> | ||
| 138 | + <SkNote v-if="folderMissing" tone="accent"> | ||
| 139 | + <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> | ||
| 141 | + </SkNote> | ||
| 142 | + | ||
| 143 | + <SkNote v-if="inventory.errors.length && tab !== 'inventory'" tone="accent"> | ||
| 144 | + <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 | ||
| 146 | + <span class="font-normal opacity-80">· {{ scanErrorsOpen ? 'hide' : 'show' }} details</span> | ||
| 147 | + </button> | ||
| 148 | + <ScopeScanErrors v-if="scanErrorsOpen" :errors="inventory.errors" :root="rootPath" /> | ||
| 149 | + </SkNote> | ||
| 150 | + | ||
| 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> | ||
| 170 | + </template> | ||
| 171 | + | ||
| 172 | + <SkActionBar | ||
| 173 | + v-if="barVisible" | ||
| 174 | + :changes="pending.length" | ||
| 175 | + :conflicts="openConflicts.length" | ||
| 176 | + :saved="!saving" | ||
| 177 | + :busy="saving" | ||
| 178 | + @review="scope.openReview()" | ||
| 179 | + > | ||
| 180 | + <span v-if="!pending.length && plan?.missing.length" class="text-sk-muted">{{ plan.missing.length }} item{{ plan.missing.length > 1 ? 's' : '' }} not found in the catalog</span> | ||
| 181 | + <span v-if="saving" class="font-mono text-[11px] text-sk-faint">Saving…</span> | ||
| 182 | + </SkActionBar> | ||
| 183 | + </SkPage> | ||
| 184 | + | ||
| 185 | + <ScopeItemDrawer v-model:open="itemOpen" /> | ||
| 186 | + <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" /> | ||
| 188 | + <RemoveFromGlobalModal v-model:open="moveOpen" :items="moveItems" :statuses="statuses" @done="scope.afterCatalogChange" /> | ||
| 189 | + | ||
| 190 | + <SkModal v-model:open="removeOpen" title="Remove project from skilly" description="This only forgets the project in skilly." width="sm"> | ||
| 191 | + <p class="m-0"> | ||
| 192 | + <strong>{{ title }}</strong> will disappear from the project list. <strong>No file is deleted</strong>: | ||
| 193 | + the folder and its agent configuration stay untouched. You can add it again at any time. | ||
| 194 | + </p> | ||
| 195 | + <template #footer> | ||
| 196 | + <SkButton label="Cancel" @click="removeOpen = false" /> | ||
| 197 | + <SkButton variant="primary" label="Remove from skilly" :loading="removing" @click="confirmRemove" /> | ||
| 198 | + </template> | ||
| 199 | + </SkModal> | ||
| 200 | +</template> | ||
added
packages/ui/app/components/scope/ScopePresetsSection.vue +62 -0 | new file mode 100644 | ||
| @@ -0,0 +1,62 @@ | ||
| 1 | +<script setup lang="ts"> | |
| 2 | +/** "Presets" section: one row per enabled preset (name, size, exclusions) and the Add presets modal. */ | |
| 3 | +const scope = useScopeContext() | |
| 4 | +const catalog = useCatalog() | |
| 5 | +const addOpen = ref(false) | |
| 6 | + | |
| 7 | +const rows = computed(() => { | |
| 8 | + const config = scope.config.value | |
| 9 | + if (!config) return [] | |
| 10 | + return config.presets.map((name) => { | |
| 11 | + const preset = catalog.presets.value.find(p => p.name === name) | |
| 12 | + if (!preset) return { name, known: false, size: '', excluded: [] as string[] } | |
| 13 | + const own = CAT_TYPES.reduce((n, t) => n + (preset.items[t]?.length ?? 0), 0) | |
| 14 | + const size = `${own} item${own === 1 ? '' : 's'}${preset.includes.length ? ` + ${preset.includes.join(', ')}` : ''}` | |
| 15 | + const excluded = resolveItems(catalog.presets.value, [name]).entries | |
| 16 | + .filter(e => matchIn(config.exclude[e.type], e.name)) | |
| 17 | + .map(e => e.name) | |
| 18 | + return { name, known: true, size, excluded } | |
| 19 | + }) | |
| 20 | +}) | |
| 21 | + | |
| 22 | +function removePreset(name: string) { | |
| 23 | + scope.edit((c) => { c.presets = c.presets.filter(p => p !== name) }) | |
| 24 | +} | |
| 25 | +function addPresets(names: string[]) { | |
| 26 | + scope.edit((c) => { c.presets = [...new Set([...c.presets, ...names])] }) | |
| 27 | + addOpen.value = false | |
| 28 | +} | |
| 29 | +</script> | |
| 30 | + | |
| 31 | +<template> | |
| 32 | + <SkSection title="Presets"> | |
| 33 | + <template #actions> | |
| 34 | + <SkButton label="Add preset" @click="addOpen = true" /> | |
| 35 | + </template> | |
| 36 | + <SkPanel> | |
| 37 | + <SkRow v-for="r in rows" :key="r.name" cols="minmax(0,1fr) minmax(0,1fr) auto" class="py-3"> | |
| 38 | + <div class="min-w-0"> | |
| 39 | + <div class="truncate font-medium">{{ r.name }}</div> | |
| 40 | + <div v-if="r.known" class="text-xs text-sk-muted">{{ r.size }}</div> | |
| 41 | + <SkStatus v-else tone="accent" label="Not found in catalog" small class="text-xs text-sk-muted" /> | |
| 42 | + </div> | |
| 43 | + <span class="truncate text-center text-xs text-sk-muted" :title="r.excluded.join(', ')"> | |
| 44 | + {{ r.known ? (r.excluded.length ? `Excluded: ${r.excluded.join(', ')}` : 'No exclusions') : '' }} | |
| 45 | + </span> | |
| 46 | + <SkButton variant="ghost" size="sm" label="Remove" :aria-label="`Remove preset ${r.name}`" @click="removePreset(r.name)" /> | |
| 47 | + </SkRow> | |
| 48 | + <SkEmpty v-if="!rows.length"> | |
| 49 | + {{ catalog.exists.value || catalog.presets.value.length ? 'No preset enabled. Add one, or enable items one by one below.' : 'No catalog yet. Create the catalog, then add presets or items.' }} | |
| 50 | + </SkEmpty> | |
| 51 | + </SkPanel> | |
| 52 | + </SkSection> | |
| 53 | + | |
| 54 | + <ScopeAddPresetsModal | |
| 55 | + v-model:open="addOpen" | |
| 56 | + :presets="catalog.presets.value" | |
| 57 | + :enabled="scope.config.value?.presets ?? []" | |
| 58 | + :scope-name="scope.title.value" | |
| 59 | + :catalog-exists="catalog.exists.value" | |
| 60 | + @add="addPresets" | |
| 61 | + /> | |
| 62 | +</template> | |
| new file mode 100644 | |||
| @@ -0,0 +1,62 @@ | |||
| 1 | +<script setup lang="ts"> | ||
| 2 | +/** "Presets" section: one row per enabled preset (name, size, exclusions) and the Add presets modal. */ | ||
| 3 | +const scope = useScopeContext() | ||
| 4 | +const catalog = useCatalog() | ||
| 5 | +const addOpen = ref(false) | ||
| 6 | + | ||
| 7 | +const rows = computed(() => { | ||
| 8 | + const config = scope.config.value | ||
| 9 | + if (!config) return [] | ||
| 10 | + return config.presets.map((name) => { | ||
| 11 | + const preset = catalog.presets.value.find(p => p.name === name) | ||
| 12 | + if (!preset) return { name, known: false, size: '', excluded: [] as string[] } | ||
| 13 | + const own = CAT_TYPES.reduce((n, t) => n + (preset.items[t]?.length ?? 0), 0) | ||
| 14 | + const size = `${own} item${own === 1 ? '' : 's'}${preset.includes.length ? ` + ${preset.includes.join(', ')}` : ''}` | ||
| 15 | + const excluded = resolveItems(catalog.presets.value, [name]).entries | ||
| 16 | + .filter(e => matchIn(config.exclude[e.type], e.name)) | ||
| 17 | + .map(e => e.name) | ||
| 18 | + return { name, known: true, size, excluded } | ||
| 19 | + }) | ||
| 20 | +}) | ||
| 21 | + | ||
| 22 | +function removePreset(name: string) { | ||
| 23 | + scope.edit((c) => { c.presets = c.presets.filter(p => p !== name) }) | ||
| 24 | +} | ||
| 25 | +function addPresets(names: string[]) { | ||
| 26 | + scope.edit((c) => { c.presets = [...new Set([...c.presets, ...names])] }) | ||
| 27 | + addOpen.value = false | ||
| 28 | +} | ||
| 29 | +</script> | ||
| 30 | + | ||
| 31 | +<template> | ||
| 32 | + <SkSection title="Presets"> | ||
| 33 | + <template #actions> | ||
| 34 | + <SkButton label="Add preset" @click="addOpen = true" /> | ||
| 35 | + </template> | ||
| 36 | + <SkPanel> | ||
| 37 | + <SkRow v-for="r in rows" :key="r.name" cols="minmax(0,1fr) minmax(0,1fr) auto" class="py-3"> | ||
| 38 | + <div class="min-w-0"> | ||
| 39 | + <div class="truncate font-medium">{{ r.name }}</div> | ||
| 40 | + <div v-if="r.known" class="text-xs text-sk-muted">{{ r.size }}</div> | ||
| 41 | + <SkStatus v-else tone="accent" label="Not found in catalog" small class="text-xs text-sk-muted" /> | ||
| 42 | + </div> | ||
| 43 | + <span class="truncate text-center text-xs text-sk-muted" :title="r.excluded.join(', ')"> | ||
| 44 | + {{ r.known ? (r.excluded.length ? `Excluded: ${r.excluded.join(', ')}` : 'No exclusions') : '' }} | ||
| 45 | + </span> | ||
| 46 | + <SkButton variant="ghost" size="sm" label="Remove" :aria-label="`Remove preset ${r.name}`" @click="removePreset(r.name)" /> | ||
| 47 | + </SkRow> | ||
| 48 | + <SkEmpty v-if="!rows.length"> | ||
| 49 | + {{ catalog.exists.value || catalog.presets.value.length ? 'No preset enabled. Add one, or enable items one by one below.' : 'No catalog yet. Create the catalog, then add presets or items.' }} | ||
| 50 | + </SkEmpty> | ||
| 51 | + </SkPanel> | ||
| 52 | + </SkSection> | ||
| 53 | + | ||
| 54 | + <ScopeAddPresetsModal | ||
| 55 | + v-model:open="addOpen" | ||
| 56 | + :presets="catalog.presets.value" | ||
| 57 | + :enabled="scope.config.value?.presets ?? []" | ||
| 58 | + :scope-name="scope.title.value" | ||
| 59 | + :catalog-exists="catalog.exists.value" | ||
| 60 | + @add="addPresets" | ||
| 61 | + /> | ||
| 62 | +</template> | ||
added
packages/ui/app/components/scope/ScopeResolveBox.vue +26 -0 | new file mode 100644 | ||
| @@ -0,0 +1,26 @@ | ||
| 1 | +<script setup lang="ts"> | |
| 2 | +import type { ConflictResolution, PlanAction } from '@skilly/core' | |
| 3 | + | |
| 4 | +/** Inline drift resolution under an item row (mockup 10): headline, diff, and a staged choice per conflicting agent. */ | |
| 5 | +defineProps<{ conflicts: PlanAction[], root: string, home?: boolean }>() | |
| 6 | +const scope = useScopeContext() | |
| 7 | +</script> | |
| 8 | + | |
| 9 | +<template> | |
| 10 | + <div class="flex flex-col gap-4 rounded-2xl bg-sk-hover p-3.5"> | |
| 11 | + <div v-for="(a, i) in conflicts" :key="a.id" class="flex flex-col gap-3" :class="i > 0 && 'border-t border-sk-line pt-4'"> | |
| 12 | + <p class="m-0 flex flex-wrap items-baseline gap-x-2 font-medium"> | |
| 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 }} | |
| 15 | + </p> | |
| 16 | + <ScopeConflictCompare :action="a" :root="root" :local-label="home ? 'Home' : 'This project'" /> | |
| 17 | + <ScopeConflictChoice | |
| 18 | + :model-value="scope.resolutionFor(a.id)" | |
| 19 | + :action="a" | |
| 20 | + :home="home" | |
| 21 | + @update:model-value="(r: ConflictResolution) => scope.setResolution(a.id, r)" | |
| 22 | + /> | |
| 23 | + </div> | |
| 24 | + <p class="m-0 font-mono text-[11px] text-sk-faint">Nothing is written until you apply.</p> | |
| 25 | + </div> | |
| 26 | +</template> | |
| new file mode 100644 | |||
| @@ -0,0 +1,26 @@ | |||
| 1 | +<script setup lang="ts"> | ||
| 2 | +import type { ConflictResolution, PlanAction } from '@skilly/core' | ||
| 3 | + | ||
| 4 | +/** Inline drift resolution under an item row (mockup 10): headline, diff, and a staged choice per conflicting agent. */ | ||
| 5 | +defineProps<{ conflicts: PlanAction[], root: string, home?: boolean }>() | ||
| 6 | +const scope = useScopeContext() | ||
| 7 | +</script> | ||
| 8 | + | ||
| 9 | +<template> | ||
| 10 | + <div class="flex flex-col gap-4 rounded-2xl bg-sk-hover p-3.5"> | ||
| 11 | + <div v-for="(a, i) in conflicts" :key="a.id" class="flex flex-col gap-3" :class="i > 0 && 'border-t border-sk-line pt-4'"> | ||
| 12 | + <p class="m-0 flex flex-wrap items-baseline gap-x-2 font-medium"> | ||
| 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 }} | ||
| 15 | + </p> | ||
| 16 | + <ScopeConflictCompare :action="a" :root="root" :local-label="home ? 'Home' : 'This project'" /> | ||
| 17 | + <ScopeConflictChoice | ||
| 18 | + :model-value="scope.resolutionFor(a.id)" | ||
| 19 | + :action="a" | ||
| 20 | + :home="home" | ||
| 21 | + @update:model-value="(r: ConflictResolution) => scope.setResolution(a.id, r)" | ||
| 22 | + /> | ||
| 23 | + </div> | ||
| 24 | + <p class="m-0 font-mono text-[11px] text-sk-faint">Nothing is written until you apply.</p> | ||
| 25 | + </div> | ||
| 26 | +</template> | ||
added
packages/ui/app/components/scope/ScopeReviewDrawer.vue +204 -0 | new file mode 100644 | ||
| @@ -0,0 +1,204 @@ | ||
| 1 | +<script setup lang="ts"> | |
| 2 | +import type { ApplyResponse, ConflictResolution, PlanAction, PlanActionKind, ScopeState, SyncPlan } from '@skilly/core' | |
| 3 | +import type { HomeNames } from '../../utils/scopeRows' | |
| 4 | + | |
| 5 | +/** | |
| 6 | + * Review & apply drawer (brief 4.1, 4.2): actions grouped by kind, files and config keys, plan warnings, missing | |
| 7 | + * items, conflicts with the staged resolution and a side by side diff. Nothing is written until Apply. | |
| 8 | + * The result (applied / skipped / errors) is shown in place. | |
| 9 | + */ | |
| 10 | +const props = defineProps<{ scopeId: string, plan: SyncPlan, home: boolean }>() | |
| 11 | +const open = defineModel<boolean>('open', { default: false }) | |
| 12 | +const emit = defineEmits<{ applied: [state: ScopeState] }>() | |
| 13 | + | |
| 14 | +const scope = useScopeContext() | |
| 15 | +const { request } = useApi() | |
| 16 | +const toast = useToast() | |
| 17 | +const { home: homeInv } = useHomeInventory() | |
| 18 | +const catalog = useCatalog() | |
| 19 | + | |
| 20 | +const busy = ref(false) | |
| 21 | +const error = ref<string | null>(null) | |
| 22 | +const response = ref<ApplyResponse | null>(null) | |
| 23 | +const expanded = ref<Set<string>>(new Set()) | |
| 24 | +let appliedActions: PlanAction[] = [] | |
| 25 | + | |
| 26 | +watch(open, (v) => { | |
| 27 | + if (!v) return | |
| 28 | + response.value = null | |
| 29 | + error.value = null | |
| 30 | + expanded.value = new Set() | |
| 31 | +}) | |
| 32 | + | |
| 33 | +const hideIds = computed(() => { | |
| 34 | + const config = scope.config.value | |
| 35 | + if (!config || props.home) return new Set<string>() | |
| 36 | + const homeNames: HomeNames = { skill: new Set(), mcp: new Set() } | |
| 37 | + for (const i of homeInv.value?.items ?? []) { | |
| 38 | + if (i.type === 'skill') homeNames.skill.add(i.name) | |
| 39 | + else if (i.type === 'mcp') homeNames.mcp.add(i.name) | |
| 40 | + } | |
| 41 | + const entryKeys = new Set(resolveItems(catalog.presets.value, config.presets, config.add).entries.map(e => `${e.type}/${e.name}`)) | |
| 42 | + return new Set(props.plan.actions.filter(a => isHideAction(a, config, entryKeys, homeNames)).map(a => a.id)) | |
| 43 | +}) | |
| 44 | + | |
| 45 | +const actions = computed(() => props.plan.actions.filter(isPending)) | |
| 46 | +const groups = computed(() => PLAN_GROUPS | |
| 47 | + .map(g => ({ ...g, rows: actions.value.filter(a => a.kind === g.kind) })) | |
| 48 | + .filter(g => g.rows.length)) | |
| 49 | +const conflicts = computed(() => actions.value.filter(a => a.kind === 'conflict')) | |
| 50 | +const summary = computed(() => { | |
| 51 | + const n = actions.value.length | |
| 52 | + const c = scope.openConflicts.value.length | |
| 53 | + return `${n} change${n === 1 ? '' : 's'}${c ? ` · ${c} conflict${c === 1 ? '' : 's'}` : ''}` | |
| 54 | +}) | |
| 55 | + | |
| 56 | +function toggle(id: string) { | |
| 57 | + const s = new Set(expanded.value) | |
| 58 | + if (s.has(id)) s.delete(id) | |
| 59 | + else s.add(id) | |
| 60 | + expanded.value = s | |
| 61 | +} | |
| 62 | + | |
| 63 | +const filesOf = (a: PlanAction) => a.files.length + a.configKeys.length | |
| 64 | +const isHide = (a: PlanAction) => hideIds.value.has(a.id) | |
| 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)' | |
| 67 | + return a.conflict ? `${CONFLICT_HEADLINE[a.conflict]}${a.catalogUpdated ? ' · the catalog changed too' : ''}` : a.reason | |
| 68 | +} | |
| 69 | + | |
| 70 | +async function apply() { | |
| 71 | + appliedActions = [...props.plan.actions] | |
| 72 | + busy.value = true | |
| 73 | + error.value = null | |
| 74 | + try { | |
| 75 | + const res = await request<ApplyResponse>(`/api/scopes/${encodeURIComponent(props.scopeId)}/apply`, { | |
| 76 | + method: 'POST', | |
| 77 | + body: { resolutions: scope.resolutionBody() }, | |
| 78 | + silent: true | |
| 79 | + }) | |
| 80 | + response.value = res | |
| 81 | + emit('applied', res.state) | |
| 82 | + const done = appliedActions.filter(a => res.result.applied.includes(a.id)) | |
| 83 | + if (!res.result.errors.length) { | |
| 84 | + toast.add({ | |
| 85 | + title: `Applied ${done.length} change${done.length === 1 ? '' : 's'}`, | |
| 86 | + description: `${filesTouched(done)} file${filesTouched(done) === 1 ? '' : 's'} written`, | |
| 87 | + icon: 'i-lucide-check', | |
| 88 | + color: 'success' | |
| 89 | + }) | |
| 90 | + } | |
| 91 | + } catch (e) { | |
| 92 | + error.value = (e as Error).message | |
| 93 | + } finally { | |
| 94 | + busy.value = false | |
| 95 | + } | |
| 96 | +} | |
| 97 | + | |
| 98 | +const labelOf = (id: string) => { | |
| 99 | + const a = appliedActions.find(x => x.id === id) | |
| 100 | + return a ? `${a.type}/${a.name} · ${AGENT_LABELS[a.agent]}` : id | |
| 101 | +} | |
| 102 | +const kindLabel = (k: PlanActionKind) => PLAN_GROUPS.find(g => g.kind === k)?.label ?? k | |
| 103 | +</script> | |
| 104 | + | |
| 105 | +<template> | |
| 106 | + <SkDrawer v-model:open="open" width="xl"> | |
| 107 | + <template #header> | |
| 108 | + <div class="flex min-w-0 flex-1 flex-col gap-0.5"> | |
| 109 | + <h2 class="m-0 text-base font-semibold">{{ response ? 'Changes applied' : 'Review changes' }}</h2> | |
| 110 | + <p class="m-0 text-sk-muted">{{ response ? 'Result of the sync.' : summary }}</p> | |
| 111 | + </div> | |
| 112 | + <SkButton variant="ghost" icon="i-lucide-x" aria-label="Close" class="shrink-0" @click="open = false" /> | |
| 113 | + </template> | |
| 114 | + | |
| 115 | + <!-- Result --> | |
| 116 | + <div v-if="response" class="flex flex-col gap-4"> | |
| 117 | + <SkNote :tone="response.result.errors.length ? 'accent' : 'ok'"> | |
| 118 | + <span class="font-medium">{{ response.result.applied.length }} applied</span> | |
| 119 | + · {{ response.result.skipped.length }} skipped · {{ response.result.errors.length }} error{{ response.result.errors.length === 1 ? '' : 's' }} | |
| 120 | + </SkNote> | |
| 121 | + <div v-if="response.result.errors.length" class="flex flex-col gap-2"> | |
| 122 | + <SkSectionLabel :count="response.result.errors.length">Errors</SkSectionLabel> | |
| 123 | + <SkNote v-for="e in response.result.errors" :key="e.actionId" tone="accent"><span class="font-medium">{{ labelOf(e.actionId) }}</span>: {{ e.message }}</SkNote> | |
| 124 | + </div> | |
| 125 | + <SkTray v-if="response.result.skipped.length"> | |
| 126 | + <SkSectionLabel inset :count="response.result.skipped.length">Skipped</SkSectionLabel> | |
| 127 | + <SkRowCard v-for="s in response.result.skipped" :key="s" class="text-sk-muted">{{ labelOf(s) }}</SkRowCard> | |
| 128 | + </SkTray> | |
| 129 | + <SkTray v-if="response.result.applied.length"> | |
| 130 | + <SkSectionLabel inset :count="response.result.applied.length">Applied</SkSectionLabel> | |
| 131 | + <SkRowCard v-for="s in response.result.applied" :key="s">{{ labelOf(s) }}</SkRowCard> | |
| 132 | + </SkTray> | |
| 133 | + </div> | |
| 134 | + | |
| 135 | + <!-- Review --> | |
| 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> | |
| 138 | + <SkNote v-if="home" tone="accent">This writes into your Home agent folders (~/.claude, ~/.agents, ~/.codex). Every project sees these files.</SkNote> | |
| 139 | + <SkNote v-for="w in plan.warnings" :key="w" tone="accent">{{ w }}</SkNote> | |
| 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> | |
| 142 | + <div class="mt-2 flex flex-wrap gap-1.5"> | |
| 143 | + <SkItemChip v-for="m in plan.missing" :key="`${m.type}/${m.name}`" :name="m.name" :type="m.type" class="text-sk-text" /> | |
| 144 | + </div> | |
| 145 | + </SkNote> | |
| 146 | + | |
| 147 | + <SkTray v-for="g in groups" :key="g.kind"> | |
| 148 | + <SkSectionLabel inset :count="g.rows.length">{{ g.label }}</SkSectionLabel> | |
| 149 | + <SkRowCard v-for="a in g.rows" :key="a.id" class="flex flex-col gap-2.5"> | |
| 150 | + <div class="flex items-start gap-3"> | |
| 151 | + <div class="min-w-0 flex-1"> | |
| 152 | + <div class="flex flex-wrap items-baseline gap-x-2"> | |
| 153 | + <span class="font-medium">{{ a.name }}</span> | |
| 154 | + <SkTag v-if="isHide(a)" mono>hide</SkTag> | |
| 155 | + <SkTypeTag v-else :type="a.type" /> | |
| 156 | + <span class="text-xs text-sk-muted">{{ AGENT_LABELS[a.agent] }}</span> | |
| 157 | + <SkStatus v-if="a.catalogUpdated && a.kind !== 'conflict'" tone="info" label="Catalog updated" small class="text-xs text-sk-muted" /> | |
| 158 | + </div> | |
| 159 | + <p class="m-0 text-xs text-pretty text-sk-muted">{{ reasonOf(a) }}</p> | |
| 160 | + </div> | |
| 161 | + <SkButton | |
| 162 | + v-if="filesOf(a) || a.kind === 'conflict'" | |
| 163 | + variant="ghost" | |
| 164 | + size="xs" | |
| 165 | + :label="a.kind === 'conflict' ? 'Compare' : `${filesOf(a)} file${filesOf(a) === 1 ? '' : 's'}`" | |
| 166 | + :trailing-icon="expanded.has(a.id) ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'" | |
| 167 | + @click="toggle(a.id)" | |
| 168 | + /> | |
| 169 | + </div> | |
| 170 | + | |
| 171 | + <ScopeConflictChoice | |
| 172 | + v-if="a.kind === 'conflict'" | |
| 173 | + :model-value="scope.resolutionFor(a.id)" | |
| 174 | + :action="a" | |
| 175 | + :home="home" | |
| 176 | + @update:model-value="(r: ConflictResolution) => scope.setResolution(a.id, r)" | |
| 177 | + /> | |
| 178 | + | |
| 179 | + <template v-if="expanded.has(a.id)"> | |
| 180 | + <ul v-if="filesOf(a)" class="m-0 flex list-none flex-col gap-0.5 p-0 font-mono text-[11px] text-sk-faint"> | |
| 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> | |
| 183 | + </ul> | |
| 184 | + <ScopeConflictCompare v-if="a.kind === 'conflict'" :action="a" :root="plan.root" mode="split" :local-label="home ? 'Home' : 'This project'" /> | |
| 185 | + </template> | |
| 186 | + </SkRowCard> | |
| 187 | + </SkTray> | |
| 188 | + | |
| 189 | + <SkEmpty v-if="!groups.length" text="Nothing to apply." /> | |
| 190 | + </div> | |
| 191 | + | |
| 192 | + <template #footer> | |
| 193 | + <template v-if="response"> | |
| 194 | + <span class="mr-auto" /> | |
| 195 | + <SkButton variant="primary" label="Close" @click="open = false" /> | |
| 196 | + </template> | |
| 197 | + <template v-else> | |
| 198 | + <span class="mr-auto font-mono text-[11px] text-sk-faint">Nothing is written until you apply.</span> | |
| 199 | + <SkButton label="Cancel" @click="open = false" /> | |
| 200 | + <SkButton variant="primary" :loading="busy" :disabled="!actions.length" :label="actions.length ? `Apply ${actions.length} change${actions.length === 1 ? '' : 's'}` : 'Apply'" @click="apply" /> | |
| 201 | + </template> | |
| 202 | + </template> | |
| 203 | + </SkDrawer> | |
| 204 | +</template> | |
| new file mode 100644 | |||
| @@ -0,0 +1,204 @@ | |||
| 1 | +<script setup lang="ts"> | ||
| 2 | +import type { ApplyResponse, ConflictResolution, PlanAction, PlanActionKind, ScopeState, SyncPlan } from '@skilly/core' | ||
| 3 | +import type { HomeNames } from '../../utils/scopeRows' | ||
| 4 | + | ||
| 5 | +/** | ||
| 6 | + * Review & apply drawer (brief 4.1, 4.2): actions grouped by kind, files and config keys, plan warnings, missing | ||
| 7 | + * items, conflicts with the staged resolution and a side by side diff. Nothing is written until Apply. | ||
| 8 | + * The result (applied / skipped / errors) is shown in place. | ||
| 9 | + */ | ||
| 10 | +const props = defineProps<{ scopeId: string, plan: SyncPlan, home: boolean }>() | ||
| 11 | +const open = defineModel<boolean>('open', { default: false }) | ||
| 12 | +const emit = defineEmits<{ applied: [state: ScopeState] }>() | ||
| 13 | + | ||
| 14 | +const scope = useScopeContext() | ||
| 15 | +const { request } = useApi() | ||
| 16 | +const toast = useToast() | ||
| 17 | +const { home: homeInv } = useHomeInventory() | ||
| 18 | +const catalog = useCatalog() | ||
| 19 | + | ||
| 20 | +const busy = ref(false) | ||
| 21 | +const error = ref<string | null>(null) | ||
| 22 | +const response = ref<ApplyResponse | null>(null) | ||
| 23 | +const expanded = ref<Set<string>>(new Set()) | ||
| 24 | +let appliedActions: PlanAction[] = [] | ||
| 25 | + | ||
| 26 | +watch(open, (v) => { | ||
| 27 | + if (!v) return | ||
| 28 | + response.value = null | ||
| 29 | + error.value = null | ||
| 30 | + expanded.value = new Set() | ||
| 31 | +}) | ||
| 32 | + | ||
| 33 | +const hideIds = computed(() => { | ||
| 34 | + const config = scope.config.value | ||
| 35 | + if (!config || props.home) return new Set<string>() | ||
| 36 | + const homeNames: HomeNames = { skill: new Set(), mcp: new Set() } | ||
| 37 | + for (const i of homeInv.value?.items ?? []) { | ||
| 38 | + if (i.type === 'skill') homeNames.skill.add(i.name) | ||
| 39 | + else if (i.type === 'mcp') homeNames.mcp.add(i.name) | ||
| 40 | + } | ||
| 41 | + const entryKeys = new Set(resolveItems(catalog.presets.value, config.presets, config.add).entries.map(e => `${e.type}/${e.name}`)) | ||
| 42 | + return new Set(props.plan.actions.filter(a => isHideAction(a, config, entryKeys, homeNames)).map(a => a.id)) | ||
| 43 | +}) | ||
| 44 | + | ||
| 45 | +const actions = computed(() => props.plan.actions.filter(isPending)) | ||
| 46 | +const groups = computed(() => PLAN_GROUPS | ||
| 47 | + .map(g => ({ ...g, rows: actions.value.filter(a => a.kind === g.kind) })) | ||
| 48 | + .filter(g => g.rows.length)) | ||
| 49 | +const conflicts = computed(() => actions.value.filter(a => a.kind === 'conflict')) | ||
| 50 | +const summary = computed(() => { | ||
| 51 | + const n = actions.value.length | ||
| 52 | + const c = scope.openConflicts.value.length | ||
| 53 | + return `${n} change${n === 1 ? '' : 's'}${c ? ` · ${c} conflict${c === 1 ? '' : 's'}` : ''}` | ||
| 54 | +}) | ||
| 55 | + | ||
| 56 | +function toggle(id: string) { | ||
| 57 | + const s = new Set(expanded.value) | ||
| 58 | + if (s.has(id)) s.delete(id) | ||
| 59 | + else s.add(id) | ||
| 60 | + expanded.value = s | ||
| 61 | +} | ||
| 62 | + | ||
| 63 | +const filesOf = (a: PlanAction) => a.files.length + a.configKeys.length | ||
| 64 | +const isHide = (a: PlanAction) => hideIds.value.has(a.id) | ||
| 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)' | ||
| 67 | + return a.conflict ? `${CONFLICT_HEADLINE[a.conflict]}${a.catalogUpdated ? ' · the catalog changed too' : ''}` : a.reason | ||
| 68 | +} | ||
| 69 | + | ||
| 70 | +async function apply() { | ||
| 71 | + appliedActions = [...props.plan.actions] | ||
| 72 | + busy.value = true | ||
| 73 | + error.value = null | ||
| 74 | + try { | ||
| 75 | + const res = await request<ApplyResponse>(`/api/scopes/${encodeURIComponent(props.scopeId)}/apply`, { | ||
| 76 | + method: 'POST', | ||
| 77 | + body: { resolutions: scope.resolutionBody() }, | ||
| 78 | + silent: true | ||
| 79 | + }) | ||
| 80 | + response.value = res | ||
| 81 | + emit('applied', res.state) | ||
| 82 | + const done = appliedActions.filter(a => res.result.applied.includes(a.id)) | ||
| 83 | + if (!res.result.errors.length) { | ||
| 84 | + toast.add({ | ||
| 85 | + title: `Applied ${done.length} change${done.length === 1 ? '' : 's'}`, | ||
| 86 | + description: `${filesTouched(done)} file${filesTouched(done) === 1 ? '' : 's'} written`, | ||
| 87 | + icon: 'i-lucide-check', | ||
| 88 | + color: 'success' | ||
| 89 | + }) | ||
| 90 | + } | ||
| 91 | + } catch (e) { | ||
| 92 | + error.value = (e as Error).message | ||
| 93 | + } finally { | ||
| 94 | + busy.value = false | ||
| 95 | + } | ||
| 96 | +} | ||
| 97 | + | ||
| 98 | +const labelOf = (id: string) => { | ||
| 99 | + const a = appliedActions.find(x => x.id === id) | ||
| 100 | + return a ? `${a.type}/${a.name} · ${AGENT_LABELS[a.agent]}` : id | ||
| 101 | +} | ||
| 102 | +const kindLabel = (k: PlanActionKind) => PLAN_GROUPS.find(g => g.kind === k)?.label ?? k | ||
| 103 | +</script> | ||
| 104 | + | ||
| 105 | +<template> | ||
| 106 | + <SkDrawer v-model:open="open" width="xl"> | ||
| 107 | + <template #header> | ||
| 108 | + <div class="flex min-w-0 flex-1 flex-col gap-0.5"> | ||
| 109 | + <h2 class="m-0 text-base font-semibold">{{ response ? 'Changes applied' : 'Review changes' }}</h2> | ||
| 110 | + <p class="m-0 text-sk-muted">{{ response ? 'Result of the sync.' : summary }}</p> | ||
| 111 | + </div> | ||
| 112 | + <SkButton variant="ghost" icon="i-lucide-x" aria-label="Close" class="shrink-0" @click="open = false" /> | ||
| 113 | + </template> | ||
| 114 | + | ||
| 115 | + <!-- Result --> | ||
| 116 | + <div v-if="response" class="flex flex-col gap-4"> | ||
| 117 | + <SkNote :tone="response.result.errors.length ? 'accent' : 'ok'"> | ||
| 118 | + <span class="font-medium">{{ response.result.applied.length }} applied</span> | ||
| 119 | + · {{ response.result.skipped.length }} skipped · {{ response.result.errors.length }} error{{ response.result.errors.length === 1 ? '' : 's' }} | ||
| 120 | + </SkNote> | ||
| 121 | + <div v-if="response.result.errors.length" class="flex flex-col gap-2"> | ||
| 122 | + <SkSectionLabel :count="response.result.errors.length">Errors</SkSectionLabel> | ||
| 123 | + <SkNote v-for="e in response.result.errors" :key="e.actionId" tone="accent"><span class="font-medium">{{ labelOf(e.actionId) }}</span>: {{ e.message }}</SkNote> | ||
| 124 | + </div> | ||
| 125 | + <SkTray v-if="response.result.skipped.length"> | ||
| 126 | + <SkSectionLabel inset :count="response.result.skipped.length">Skipped</SkSectionLabel> | ||
| 127 | + <SkRowCard v-for="s in response.result.skipped" :key="s" class="text-sk-muted">{{ labelOf(s) }}</SkRowCard> | ||
| 128 | + </SkTray> | ||
| 129 | + <SkTray v-if="response.result.applied.length"> | ||
| 130 | + <SkSectionLabel inset :count="response.result.applied.length">Applied</SkSectionLabel> | ||
| 131 | + <SkRowCard v-for="s in response.result.applied" :key="s">{{ labelOf(s) }}</SkRowCard> | ||
| 132 | + </SkTray> | ||
| 133 | + </div> | ||
| 134 | + | ||
| 135 | + <!-- Review --> | ||
| 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> | ||
| 138 | + <SkNote v-if="home" tone="accent">This writes into your Home agent folders (~/.claude, ~/.agents, ~/.codex). Every project sees these files.</SkNote> | ||
| 139 | + <SkNote v-for="w in plan.warnings" :key="w" tone="accent">{{ w }}</SkNote> | ||
| 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> | ||
| 142 | + <div class="mt-2 flex flex-wrap gap-1.5"> | ||
| 143 | + <SkItemChip v-for="m in plan.missing" :key="`${m.type}/${m.name}`" :name="m.name" :type="m.type" class="text-sk-text" /> | ||
| 144 | + </div> | ||
| 145 | + </SkNote> | ||
| 146 | + | ||
| 147 | + <SkTray v-for="g in groups" :key="g.kind"> | ||
| 148 | + <SkSectionLabel inset :count="g.rows.length">{{ g.label }}</SkSectionLabel> | ||
| 149 | + <SkRowCard v-for="a in g.rows" :key="a.id" class="flex flex-col gap-2.5"> | ||
| 150 | + <div class="flex items-start gap-3"> | ||
| 151 | + <div class="min-w-0 flex-1"> | ||
| 152 | + <div class="flex flex-wrap items-baseline gap-x-2"> | ||
| 153 | + <span class="font-medium">{{ a.name }}</span> | ||
| 154 | + <SkTag v-if="isHide(a)" mono>hide</SkTag> | ||
| 155 | + <SkTypeTag v-else :type="a.type" /> | ||
| 156 | + <span class="text-xs text-sk-muted">{{ AGENT_LABELS[a.agent] }}</span> | ||
| 157 | + <SkStatus v-if="a.catalogUpdated && a.kind !== 'conflict'" tone="info" label="Catalog updated" small class="text-xs text-sk-muted" /> | ||
| 158 | + </div> | ||
| 159 | + <p class="m-0 text-xs text-pretty text-sk-muted">{{ reasonOf(a) }}</p> | ||
| 160 | + </div> | ||
| 161 | + <SkButton | ||
| 162 | + v-if="filesOf(a) || a.kind === 'conflict'" | ||
| 163 | + variant="ghost" | ||
| 164 | + size="xs" | ||
| 165 | + :label="a.kind === 'conflict' ? 'Compare' : `${filesOf(a)} file${filesOf(a) === 1 ? '' : 's'}`" | ||
| 166 | + :trailing-icon="expanded.has(a.id) ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'" | ||
| 167 | + @click="toggle(a.id)" | ||
| 168 | + /> | ||
| 169 | + </div> | ||
| 170 | + | ||
| 171 | + <ScopeConflictChoice | ||
| 172 | + v-if="a.kind === 'conflict'" | ||
| 173 | + :model-value="scope.resolutionFor(a.id)" | ||
| 174 | + :action="a" | ||
| 175 | + :home="home" | ||
| 176 | + @update:model-value="(r: ConflictResolution) => scope.setResolution(a.id, r)" | ||
| 177 | + /> | ||
| 178 | + | ||
| 179 | + <template v-if="expanded.has(a.id)"> | ||
| 180 | + <ul v-if="filesOf(a)" class="m-0 flex list-none flex-col gap-0.5 p-0 font-mono text-[11px] text-sk-faint"> | ||
| 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> | ||
| 183 | + </ul> | ||
| 184 | + <ScopeConflictCompare v-if="a.kind === 'conflict'" :action="a" :root="plan.root" mode="split" :local-label="home ? 'Home' : 'This project'" /> | ||
| 185 | + </template> | ||
| 186 | + </SkRowCard> | ||
| 187 | + </SkTray> | ||
| 188 | + | ||
| 189 | + <SkEmpty v-if="!groups.length" text="Nothing to apply." /> | ||
| 190 | + </div> | ||
| 191 | + | ||
| 192 | + <template #footer> | ||
| 193 | + <template v-if="response"> | ||
| 194 | + <span class="mr-auto" /> | ||
| 195 | + <SkButton variant="primary" label="Close" @click="open = false" /> | ||
| 196 | + </template> | ||
| 197 | + <template v-else> | ||
| 198 | + <span class="mr-auto font-mono text-[11px] text-sk-faint">Nothing is written until you apply.</span> | ||
| 199 | + <SkButton label="Cancel" @click="open = false" /> | ||
| 200 | + <SkButton variant="primary" :loading="busy" :disabled="!actions.length" :label="actions.length ? `Apply ${actions.length} change${actions.length === 1 ? '' : 's'}` : 'Apply'" @click="apply" /> | ||
| 201 | + </template> | ||
| 202 | + </template> | ||
| 203 | + </SkDrawer> | ||
| 204 | +</template> | ||
added
packages/ui/app/components/scope/ScopeRowStatus.vue +41 -0 | new file mode 100644 | ||
| @@ -0,0 +1,41 @@ | ||
| 1 | +<script setup lang="ts"> | |
| 2 | +import type { RowAction, ScopeRow } from '../../utils/scopeRows' | |
| 3 | + | |
| 4 | +/** | |
| 5 | + * Status cell of an item row: one status when the agents agree, otherwise one mono mini status per agent. | |
| 6 | + * A conflict whose resolution was chosen shows "Resolution: push to catalog" (staged, executed by Apply). | |
| 7 | + */ | |
| 8 | +const props = defineProps<{ row: ScopeRow }>() | |
| 9 | +const emit = defineEmits<{ action: [kind: RowAction] }>() | |
| 10 | +const scope = useScopeContext() | |
| 11 | + | |
| 12 | +const ACTION_LABEL: Record<RowAction, string> = { resolve: 'Resolve', review: 'Review', import: 'Import' } | |
| 13 | + | |
| 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))) | |
| 16 | +const stagedLabel = computed(() => { | |
| 17 | + const picks = [...new Set(props.row.conflicts.map(a => RESOLUTION_LABEL[scope.resolutionFor(a.id)]))] | |
| 18 | + return picks.length === 1 ? `Resolution: ${picks[0]}` : 'Resolutions staged' | |
| 19 | +}) | |
| 20 | +</script> | |
| 21 | + | |
| 22 | +<template> | |
| 23 | + <div class="flex min-w-0 flex-col items-end gap-0.5 text-right"> | |
| 24 | + <SkStatus v-if="staged" tone="info" :label="stagedLabel" class="font-mono text-xs"> | |
| 25 | + <template #action><SkLinkAction @click.stop="emit('action', 'resolve')">Change</SkLinkAction></template> | |
| 26 | + </SkStatus> | |
| 27 | + <SkStatus v-else-if="merged" :tone="merged.tone" :label="merged.label"> | |
| 28 | + <template #action> | |
| 29 | + <SkLinkAction v-if="merged.action" @click.stop="emit('action', merged.action)">{{ ACTION_LABEL[merged.action] }}</SkLinkAction> | |
| 30 | + </template> | |
| 31 | + </SkStatus> | |
| 32 | + <template v-else> | |
| 33 | + <span v-for="s in row.statuses" :key="s.agent" class="inline-flex items-center gap-1.5 font-mono text-[11px] whitespace-nowrap"> | |
| 34 | + <span class="text-sk-faint">{{ s.agent }}</span> | |
| 35 | + <SkStatusDot :tone="s.tone" small /> | |
| 36 | + <span class="text-sk-text">{{ s.short }}</span> | |
| 37 | + <SkLinkAction v-if="s.action" class="font-sans text-xs" @click.stop="emit('action', s.action)">{{ ACTION_LABEL[s.action] }}</SkLinkAction> | |
| 38 | + </span> | |
| 39 | + </template> | |
| 40 | + </div> | |
| 41 | +</template> | |
| new file mode 100644 | |||
| @@ -0,0 +1,41 @@ | |||
| 1 | +<script setup lang="ts"> | ||
| 2 | +import type { RowAction, ScopeRow } from '../../utils/scopeRows' | ||
| 3 | + | ||
| 4 | +/** | ||
| 5 | + * Status cell of an item row: one status when the agents agree, otherwise one mono mini status per agent. | ||
| 6 | + * A conflict whose resolution was chosen shows "Resolution: push to catalog" (staged, executed by Apply). | ||
| 7 | + */ | ||
| 8 | +const props = defineProps<{ row: ScopeRow }>() | ||
| 9 | +const emit = defineEmits<{ action: [kind: RowAction] }>() | ||
| 10 | +const scope = useScopeContext() | ||
| 11 | + | ||
| 12 | +const ACTION_LABEL: Record<RowAction, string> = { resolve: 'Resolve', review: 'Review', import: 'Import' } | ||
| 13 | + | ||
| 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))) | ||
| 16 | +const stagedLabel = computed(() => { | ||
| 17 | + const picks = [...new Set(props.row.conflicts.map(a => RESOLUTION_LABEL[scope.resolutionFor(a.id)]))] | ||
| 18 | + return picks.length === 1 ? `Resolution: ${picks[0]}` : 'Resolutions staged' | ||
| 19 | +}) | ||
| 20 | +</script> | ||
| 21 | + | ||
| 22 | +<template> | ||
| 23 | + <div class="flex min-w-0 flex-col items-end gap-0.5 text-right"> | ||
| 24 | + <SkStatus v-if="staged" tone="info" :label="stagedLabel" class="font-mono text-xs"> | ||
| 25 | + <template #action><SkLinkAction @click.stop="emit('action', 'resolve')">Change</SkLinkAction></template> | ||
| 26 | + </SkStatus> | ||
| 27 | + <SkStatus v-else-if="merged" :tone="merged.tone" :label="merged.label"> | ||
| 28 | + <template #action> | ||
| 29 | + <SkLinkAction v-if="merged.action" @click.stop="emit('action', merged.action)">{{ ACTION_LABEL[merged.action] }}</SkLinkAction> | ||
| 30 | + </template> | ||
| 31 | + </SkStatus> | ||
| 32 | + <template v-else> | ||
| 33 | + <span v-for="s in row.statuses" :key="s.agent" class="inline-flex items-center gap-1.5 font-mono text-[11px] whitespace-nowrap"> | ||
| 34 | + <span class="text-sk-faint">{{ s.agent }}</span> | ||
| 35 | + <SkStatusDot :tone="s.tone" small /> | ||
| 36 | + <span class="text-sk-text">{{ s.short }}</span> | ||
| 37 | + <SkLinkAction v-if="s.action" class="font-sans text-xs" @click.stop="emit('action', s.action)">{{ ACTION_LABEL[s.action] }}</SkLinkAction> | ||
| 38 | + </span> | ||
| 39 | + </template> | ||
| 40 | + </div> | ||
| 41 | +</template> | ||
added
packages/ui/app/components/scope/ScopeScanErrors.vue +16 -0 | new file mode 100644 | ||
| @@ -0,0 +1,16 @@ | ||
| 1 | +<script setup lang="ts"> | |
| 2 | +import type { ScanError } from '@skilly/core' | |
| 3 | + | |
| 4 | +/** Details of the items the scanners could not read (inside a clay SkNote). */ | |
| 5 | +defineProps<{ errors: ScanError[], root: string }>() | |
| 6 | +</script> | |
| 7 | + | |
| 8 | +<template> | |
| 9 | + <ul class="m-0 mt-2 flex list-none flex-col gap-1.5 p-0 text-xs"> | |
| 10 | + <li v-for="(e, i) in errors" :key="i"> | |
| 11 | + <span v-if="e.agent" class="mr-1.5 font-mono text-[11px] opacity-80">{{ AGENT_LABELS[e.agent] }}</span> | |
| 12 | + <span class="font-mono break-all" :title="e.path">{{ relativeTo(root, e.path) }}</span> | |
| 13 | + <span class="block opacity-80">{{ e.message }}</span> | |
| 14 | + </li> | |
| 15 | + </ul> | |
| 16 | +</template> | |
| new file mode 100644 | |||
| @@ -0,0 +1,16 @@ | |||
| 1 | +<script setup lang="ts"> | ||
| 2 | +import type { ScanError } from '@skilly/core' | ||
| 3 | + | ||
| 4 | +/** Details of the items the scanners could not read (inside a clay SkNote). */ | ||
| 5 | +defineProps<{ errors: ScanError[], root: string }>() | ||
| 6 | +</script> | ||
| 7 | + | ||
| 8 | +<template> | ||
| 9 | + <ul class="m-0 mt-2 flex list-none flex-col gap-1.5 p-0 text-xs"> | ||
| 10 | + <li v-for="(e, i) in errors" :key="i"> | ||
| 11 | + <span v-if="e.agent" class="mr-1.5 font-mono text-[11px] opacity-80">{{ AGENT_LABELS[e.agent] }}</span> | ||
| 12 | + <span class="font-mono break-all" :title="e.path">{{ relativeTo(root, e.path) }}</span> | ||
| 13 | + <span class="block opacity-80">{{ e.message }}</span> | ||
| 14 | + </li> | ||
| 15 | + </ul> | ||
| 16 | +</template> | ||
added
packages/ui/app/components/scope/ScopeTargets.vue +46 -0 | new file mode 100644 | ||
| @@ -0,0 +1,46 @@ | ||
| 1 | +<script setup lang="ts"> | |
| 2 | +import type { AgentId } from '@skilly/core' | |
| 3 | + | |
| 4 | +/** "Writes for: Claude Code, Codex" with a popover to change the target agents (ScopeConfig.targets). */ | |
| 5 | +const scope = useScopeContext() | |
| 6 | +const AGENT_LIST: AgentId[] = ['claude', 'codex'] | |
| 7 | + | |
| 8 | +const label = computed(() => { | |
| 9 | + const t = scope.config.value?.targets ?? [] | |
| 10 | + return t.length ? t.map(a => AGENT_LABELS[a]).join(', ') : 'no agent' | |
| 11 | +}) | |
| 12 | + | |
| 13 | +function toggle(a: AgentId, on: boolean | 'indeterminate') { | |
| 14 | + scope.edit((c) => { | |
| 15 | + c.targets = on === true ? [...new Set([...c.targets, a])] : c.targets.filter(t => t !== a) | |
| 16 | + }) | |
| 17 | +} | |
| 18 | +</script> | |
| 19 | + | |
| 20 | +<template> | |
| 21 | + <UPopover :content="{ align: 'start' }"> | |
| 22 | + <button | |
| 23 | + type="button" | |
| 24 | + class="inline-flex cursor-pointer items-center gap-1 rounded-md border-0 bg-transparent p-0 font-mono text-xs underline decoration-dotted underline-offset-4 hover:text-sk-text focus-visible:outline-2 focus-visible:outline-sk-accent" | |
| 25 | + :class="scope.config.value?.targets.length ? 'text-sk-faint' : 'text-sk-accent-text'" | |
| 26 | + aria-label="Change the agents this project writes for" | |
| 27 | + > | |
| 28 | + Writes for {{ label }} | |
| 29 | + <UIcon name="i-lucide-chevron-down" class="size-3" /> | |
| 30 | + </button> | |
| 31 | + <template #content> | |
| 32 | + <div class="flex w-64 flex-col gap-2.5 p-3.5 text-[13px]"> | |
| 33 | + <p class="m-0 font-semibold">Write files for</p> | |
| 34 | + <UCheckbox | |
| 35 | + v-for="a in AGENT_LIST" | |
| 36 | + :key="a" | |
| 37 | + :model-value="scope.config.value?.targets.includes(a)" | |
| 38 | + :label="AGENT_LABELS[a]" | |
| 39 | + :description="a === 'claude' ? '.claude/ · CLAUDE.md' : '.agents/ · .codex/ · AGENTS.md'" | |
| 40 | + @update:model-value="(v) => toggle(a, v)" | |
| 41 | + /> | |
| 42 | + <p v-if="!scope.config.value?.targets.length" class="m-0 text-xs text-sk-accent-text">No target agent: nothing will be written.</p> | |
| 43 | + </div> | |
| 44 | + </template> | |
| 45 | + </UPopover> | |
| 46 | +</template> | |
| new file mode 100644 | |||
| @@ -0,0 +1,46 @@ | |||
| 1 | +<script setup lang="ts"> | ||
| 2 | +import type { AgentId } from '@skilly/core' | ||
| 3 | + | ||
| 4 | +/** "Writes for: Claude Code, Codex" with a popover to change the target agents (ScopeConfig.targets). */ | ||
| 5 | +const scope = useScopeContext() | ||
| 6 | +const AGENT_LIST: AgentId[] = ['claude', 'codex'] | ||
| 7 | + | ||
| 8 | +const label = computed(() => { | ||
| 9 | + const t = scope.config.value?.targets ?? [] | ||
| 10 | + return t.length ? t.map(a => AGENT_LABELS[a]).join(', ') : 'no agent' | ||
| 11 | +}) | ||
| 12 | + | ||
| 13 | +function toggle(a: AgentId, on: boolean | 'indeterminate') { | ||
| 14 | + scope.edit((c) => { | ||
| 15 | + c.targets = on === true ? [...new Set([...c.targets, a])] : c.targets.filter(t => t !== a) | ||
| 16 | + }) | ||
| 17 | +} | ||
| 18 | +</script> | ||
| 19 | + | ||
| 20 | +<template> | ||
| 21 | + <UPopover :content="{ align: 'start' }"> | ||
| 22 | + <button | ||
| 23 | + type="button" | ||
| 24 | + class="inline-flex cursor-pointer items-center gap-1 rounded-md border-0 bg-transparent p-0 font-mono text-xs underline decoration-dotted underline-offset-4 hover:text-sk-text focus-visible:outline-2 focus-visible:outline-sk-accent" | ||
| 25 | + :class="scope.config.value?.targets.length ? 'text-sk-faint' : 'text-sk-accent-text'" | ||
| 26 | + aria-label="Change the agents this project writes for" | ||
| 27 | + > | ||
| 28 | + Writes for {{ label }} | ||
| 29 | + <UIcon name="i-lucide-chevron-down" class="size-3" /> | ||
| 30 | + </button> | ||
| 31 | + <template #content> | ||
| 32 | + <div class="flex w-64 flex-col gap-2.5 p-3.5 text-[13px]"> | ||
| 33 | + <p class="m-0 font-semibold">Write files for</p> | ||
| 34 | + <UCheckbox | ||
| 35 | + v-for="a in AGENT_LIST" | ||
| 36 | + :key="a" | ||
| 37 | + :model-value="scope.config.value?.targets.includes(a)" | ||
| 38 | + :label="AGENT_LABELS[a]" | ||
| 39 | + :description="a === 'claude' ? '.claude/ · CLAUDE.md' : '.agents/ · .codex/ · AGENTS.md'" | ||
| 40 | + @update:model-value="(v) => toggle(a, v)" | ||
| 41 | + /> | ||
| 42 | + <p v-if="!scope.config.value?.targets.length" class="m-0 text-xs text-sk-accent-text">No target agent: nothing will be written.</p> | ||
| 43 | + </div> | ||
| 44 | + </template> | ||
| 45 | + </UPopover> | ||
| 46 | +</template> | ||
modified
packages/ui/app/composables/useCatalogGit.ts +10 -2 | @@ -2,12 +2,20 @@ import type { GitStatus } from '@skilly/core' | ||
| 2 | 2 | |
| 3 | 3 | export type GitAction = 'remote' | 'commit' | 'pull' | 'push' | 'sync' | 'resolve' |
| 4 | 4 | |
| 5 | -/** Catalog git actions shared by the sidebar indicator and the git dialog. */ | |
| 5 | +/** | |
| 6 | + * Catalog git actions shared by the header pill and the Sync page (`/sync`). | |
| 7 | + * `open` is kept for older callers: reading it tells whether the Sync page is shown, setting it to true opens the page | |
| 8 | + * (there is no git dialog any more). | |
| 9 | + */ | |
| 6 | 10 | export function useCatalogGit() { |
| 7 | 11 | const { request } = useApi() |
| 8 | 12 | const { setGit, refresh } = useCatalog() |
| 9 | 13 | const toast = useToast() |
| 10 | - const open = useState('skilly-git-open', () => false) | |
| 14 | + const route = useRoute() | |
| 15 | + const open = computed({ | |
| 16 | + get: () => route.path === '/sync', | |
| 17 | + set: (v: boolean) => { if (v && route.path !== '/sync') navigateTo('/sync') } | |
| 18 | + }) | |
| 11 | 19 | const busy = useState<GitAction | null>('skilly-git-busy', () => null) |
| 12 | 20 | const error = useState<string | null>('skilly-git-error', () => null) |
| 13 | 21 | |
| @@ -2,12 +2,20 @@ import type { GitStatus } from '@skilly/core' | |||
| 2 | 2 | ||
| 3 | export type GitAction = 'remote' | 'commit' | 'pull' | 'push' | 'sync' | 'resolve' | 3 | export type GitAction = 'remote' | 'commit' | 'pull' | 'push' | 'sync' | 'resolve' |
| 4 | 4 | ||
| 5 | -/** Catalog git actions shared by the sidebar indicator and the git dialog. */ | 5 | +/** |
| 6 | + * Catalog git actions shared by the header pill and the Sync page (`/sync`). | ||
| 7 | + * `open` is kept for older callers: reading it tells whether the Sync page is shown, setting it to true opens the page | ||
| 8 | + * (there is no git dialog any more). | ||
| 9 | + */ | ||
| 6 | export function useCatalogGit() { | 10 | export function useCatalogGit() { |
| 7 | const { request } = useApi() | 11 | const { request } = useApi() |
| 8 | const { setGit, refresh } = useCatalog() | 12 | const { setGit, refresh } = useCatalog() |
| 9 | const toast = useToast() | 13 | const toast = useToast() |
| 10 | - const open = useState('skilly-git-open', () => false) | 14 | + const route = useRoute() |
| 15 | + const open = computed({ | ||
| 16 | + get: () => route.path === '/sync', | ||
| 17 | + set: (v: boolean) => { if (v && route.path !== '/sync') navigateTo('/sync') } | ||
| 18 | + }) | ||
| 11 | const busy = useState<GitAction | null>('skilly-git-busy', () => null) | 19 | const busy = useState<GitAction | null>('skilly-git-busy', () => null) |
| 12 | const error = useState<string | null>('skilly-git-error', () => null) | 20 | const error = useState<string | null>('skilly-git-error', () => null) |
| 13 | 21 | ||
added
packages/ui/app/composables/useScopeState.ts +202 -0 | new file mode 100644 | ||
| @@ -0,0 +1,202 @@ | ||
| 1 | +import type { AgentId, ConflictResolution, InventoryItem, ScopeConfig, ScopeState, SyncPlan } from '@skilly/core' | |
| 2 | +import type { InjectionKey } from 'vue' | |
| 3 | + | |
| 4 | +/** | |
| 5 | + * State of one scope page (Home or a project): `GET /api/scopes/:scope/state`, the local editable copy of the | |
| 6 | + * config (saved debounced with `PUT .../config`), the staged conflict resolutions, and the overlays the page owns | |
| 7 | + * (item drawer, review drawer, import and remove-from-global modals). | |
| 8 | + * | |
| 9 | + * `ScopePage` creates it and provides it; the scope components read it with `useScopeContext()`. | |
| 10 | + * Nothing is written to disk by this composable except `skilly.json` / `home.json` (the config); files change on Apply only. | |
| 11 | + */ | |
| 12 | +export function createScope(kind: 'home' | 'project', projectId: () => string | undefined) { | |
| 13 | + const { request } = useApi() | |
| 14 | + const { projects, refresh: refreshProjects } = useProjects() | |
| 15 | + const catalog = useCatalog() | |
| 16 | + const toast = useToast() | |
| 17 | + | |
| 18 | + const scopeId = computed(() => (kind === 'home' ? 'home' : projectId() ?? '')) | |
| 19 | + const state = ref<ScopeState | null>(null) | |
| 20 | + const config = ref<ScopeConfig | null>(null) | |
| 21 | + const loading = ref(true) | |
| 22 | + const error = ref<string | null>(null) | |
| 23 | + const saving = ref(false) | |
| 24 | + | |
| 25 | + const inventory = computed(() => state.value?.inventory ?? null) | |
| 26 | + const statuses = computed(() => state.value?.statuses ?? {}) | |
| 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)) | |
| 29 | + const folderMissing = computed(() => kind === 'project' && !!project.value && !project.value.exists) | |
| 30 | + const title = computed(() => (kind === 'home' ? 'Home' : project.value?.name ?? scopeId.value)) | |
| 31 | + const rootPath = computed(() => inventory.value?.root ?? project.value?.path ?? '') | |
| 32 | + | |
| 33 | + async function load(showLoading = true) { | |
| 34 | + if (showLoading) loading.value = true | |
| 35 | + try { | |
| 36 | + state.value = await request<ScopeState>(`/api/scopes/${encodeURIComponent(scopeId.value)}/state`, { silent: true }) | |
| 37 | + config.value = state.value.config ? cloneConfig(state.value.config) : null | |
| 38 | + error.value = null | |
| 39 | + } catch (e) { | |
| 40 | + error.value = (e as Error).message | |
| 41 | + // The folder may have disappeared: refresh the summaries so `exists` is up to date. | |
| 42 | + if (kind === 'project') await refreshProjects() | |
| 43 | + } finally { | |
| 44 | + loading.value = false | |
| 45 | + } | |
| 46 | + } | |
| 47 | + | |
| 48 | + async function rescan() { | |
| 49 | + await load(false) | |
| 50 | + if (!error.value) toast.add({ title: 'Rescan complete', color: 'success', icon: 'i-lucide-check' }) | |
| 51 | + } | |
| 52 | + | |
| 53 | + /* ---------------------------------------------------------------- config autosave */ | |
| 54 | + | |
| 55 | + let timer: ReturnType<typeof setTimeout> | undefined | |
| 56 | + let seq = 0 | |
| 57 | + let adopted = 0 | |
| 58 | + let inflight: Promise<void> = Promise.resolve() | |
| 59 | + | |
| 60 | + async function runSave(mine: number) { | |
| 61 | + const body = config.value | |
| 62 | + if (!body) return | |
| 63 | + try { | |
| 64 | + const res = await request<ScopeState>(`/api/scopes/${encodeURIComponent(scopeId.value)}/config`, { method: 'PUT', body, silent: true }) | |
| 65 | + // Ignore answers that arrive out of order. | |
| 66 | + if (mine >= adopted) { | |
| 67 | + adopted = mine | |
| 68 | + state.value = res | |
| 69 | + // Only adopt the server copy when no newer edit happened meanwhile. | |
| 70 | + if (mine === seq) config.value = res.config ? cloneConfig(res.config) : body | |
| 71 | + } | |
| 72 | + } catch (e) { | |
| 73 | + toast.add({ title: 'Could not save the configuration', description: (e as Error).message, color: 'error', icon: 'i-lucide-circle-alert' }) | |
| 74 | + if (mine === seq) await load(false) | |
| 75 | + } finally { | |
| 76 | + if (mine === seq) saving.value = false | |
| 77 | + } | |
| 78 | + } | |
| 79 | + | |
| 80 | + /** Replaces the config: shown at once, saved in the background (debounced unless `immediate`). */ | |
| 81 | + function saveConfig(next: ScopeConfig, immediate = false) { | |
| 82 | + config.value = next | |
| 83 | + const mine = ++seq | |
| 84 | + saving.value = true | |
| 85 | + clearTimeout(timer) | |
| 86 | + timer = undefined | |
| 87 | + if (immediate) inflight = runSave(mine) | |
| 88 | + else timer = setTimeout(() => { timer = undefined; inflight = runSave(mine) }, 400) | |
| 89 | + } | |
| 90 | + | |
| 91 | + /** Runs a pending save now and waits for it (before opening the review). */ | |
| 92 | + async function flush() { | |
| 93 | + if (timer) { | |
| 94 | + clearTimeout(timer) | |
| 95 | + timer = undefined | |
| 96 | + inflight = runSave(seq) | |
| 97 | + } | |
| 98 | + await inflight | |
| 99 | + } | |
| 100 | + | |
| 101 | + /** Edits a copy of the config and saves it. */ | |
| 102 | + function edit(fn: (c: ScopeConfig) => void) { | |
| 103 | + if (!config.value) return | |
| 104 | + const c = cloneConfig(config.value) | |
| 105 | + fn(c) | |
| 106 | + saveConfig(c) | |
| 107 | + } | |
| 108 | + | |
| 109 | + onBeforeUnmount(() => clearTimeout(timer)) | |
| 110 | + | |
| 111 | + /* ---------------------------------------------------------------- conflict resolutions (staged) */ | |
| 112 | + | |
| 113 | + const resolutions = ref<Record<string, ConflictResolution>>({}) | |
| 114 | + /** Actions for which the user explicitly chose a resolution (the default keep-local is not a decision). */ | |
| 115 | + const decided = ref<Set<string>>(new Set()) | |
| 116 | + | |
| 117 | + function resolutionFor(actionId: string): ConflictResolution { | |
| 118 | + return resolutions.value[actionId] ?? 'keep-local' | |
| 119 | + } | |
| 120 | + function setResolution(actionId: string, r: ConflictResolution) { | |
| 121 | + resolutions.value = { ...resolutions.value, [actionId]: r } | |
| 122 | + decided.value = new Set([...decided.value, actionId]) | |
| 123 | + } | |
| 124 | + /** Resolutions sent to Apply: one per conflict of the plan, keep-local unless chosen otherwise. */ | |
| 125 | + function resolutionBody(): Record<string, ConflictResolution> { | |
| 126 | + return Object.fromEntries((plan.value?.actions ?? []).filter(a => a.kind === 'conflict').map(a => [a.id, resolutionFor(a.id)])) | |
| 127 | + } | |
| 128 | + watch(plan, (p) => { | |
| 129 | + const ids = new Set((p?.actions ?? []).map(a => a.id)) | |
| 130 | + resolutions.value = Object.fromEntries(Object.entries(resolutions.value).filter(([id]) => ids.has(id))) | |
| 131 | + decided.value = new Set([...decided.value].filter(id => ids.has(id))) | |
| 132 | + }) | |
| 133 | + | |
| 134 | + const pending = computed(() => (plan.value?.actions ?? []).filter(isPending)) | |
| 135 | + const conflictActions = computed(() => pending.value.filter(a => a.kind === 'conflict')) | |
| 136 | + /** Conflicts still waiting for a decision. */ | |
| 137 | + const openConflicts = computed(() => conflictActions.value.filter(a => !decided.value.has(a.id))) | |
| 138 | + | |
| 139 | + /* ---------------------------------------------------------------- overlays owned by the page */ | |
| 140 | + | |
| 141 | + const selectedItem = ref<InventoryItem | null>(null) | |
| 142 | + const itemOpen = ref(false) | |
| 143 | + const reviewOpen = ref(false) | |
| 144 | + const importItems = ref<InventoryItem[]>([]) | |
| 145 | + const importOpen = ref(false) | |
| 146 | + const moveItems = ref<InventoryItem[]>([]) | |
| 147 | + const moveOpen = ref(false) | |
| 148 | + | |
| 149 | + function openItem(item: InventoryItem) { | |
| 150 | + selectedItem.value = item | |
| 151 | + itemOpen.value = true | |
| 152 | + } | |
| 153 | + function askImport(items: InventoryItem[]) { | |
| 154 | + importItems.value = items | |
| 155 | + importOpen.value = true | |
| 156 | + } | |
| 157 | + function askMove(items: InventoryItem[]) { | |
| 158 | + moveItems.value = items | |
| 159 | + moveOpen.value = true | |
| 160 | + } | |
| 161 | + async function openReview() { | |
| 162 | + await flush() | |
| 163 | + reviewOpen.value = true | |
| 164 | + } | |
| 165 | + | |
| 166 | + /** After an import / move: refresh this scope, the catalog and the project summaries. */ | |
| 167 | + async function afterCatalogChange() { | |
| 168 | + await Promise.all([load(false), catalog.refresh(), refreshProjects()]) | |
| 169 | + } | |
| 170 | + function adoptState(next: ScopeState) { | |
| 171 | + state.value = next | |
| 172 | + config.value = next.config ? cloneConfig(next.config) : null | |
| 173 | + if (kind === 'project') refreshProjects() | |
| 174 | + catalog.refresh() | |
| 175 | + } | |
| 176 | + | |
| 177 | + /** Home skills and MCP servers by name, for the inherited section and to recognise "hide global" actions. */ | |
| 178 | + const targets = computed<AgentId[]>(() => config.value?.targets ?? []) | |
| 179 | + | |
| 180 | + return { | |
| 181 | + kind, scopeId, state, config, loading, error, saving, inventory, statuses, plan, project, folderMissing, title, rootPath, | |
| 182 | + load, rescan, saveConfig, edit, flush, | |
| 183 | + resolutions, decided, resolutionFor, setResolution, resolutionBody, | |
| 184 | + pending, conflictActions, openConflicts, targets, | |
| 185 | + selectedItem, itemOpen, reviewOpen, importItems, importOpen, moveItems, moveOpen, | |
| 186 | + openItem, askImport, askMove, openReview, afterCatalogChange, adoptState | |
| 187 | + } | |
| 188 | +} | |
| 189 | + | |
| 190 | +export type ScopeContext = ReturnType<typeof createScope> | |
| 191 | + | |
| 192 | +const SCOPE_KEY: InjectionKey<ScopeContext> = Symbol('skilly-scope') | |
| 193 | + | |
| 194 | +export function provideScope(scope: ScopeContext) { | |
| 195 | + provide(SCOPE_KEY, scope) | |
| 196 | +} | |
| 197 | + | |
| 198 | +export function useScopeContext(): ScopeContext { | |
| 199 | + const scope = inject(SCOPE_KEY) | |
| 200 | + if (!scope) throw new Error('useScopeContext() must be used inside a ScopePage') | |
| 201 | + return scope | |
| 202 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,202 @@ | |||
| 1 | +import type { AgentId, ConflictResolution, InventoryItem, ScopeConfig, ScopeState, SyncPlan } from '@skilly/core' | ||
| 2 | +import type { InjectionKey } from 'vue' | ||
| 3 | + | ||
| 4 | +/** | ||
| 5 | + * State of one scope page (Home or a project): `GET /api/scopes/:scope/state`, the local editable copy of the | ||
| 6 | + * config (saved debounced with `PUT .../config`), the staged conflict resolutions, and the overlays the page owns | ||
| 7 | + * (item drawer, review drawer, import and remove-from-global modals). | ||
| 8 | + * | ||
| 9 | + * `ScopePage` creates it and provides it; the scope components read it with `useScopeContext()`. | ||
| 10 | + * Nothing is written to disk by this composable except `skilly.json` / `home.json` (the config); files change on Apply only. | ||
| 11 | + */ | ||
| 12 | +export function createScope(kind: 'home' | 'project', projectId: () => string | undefined) { | ||
| 13 | + const { request } = useApi() | ||
| 14 | + const { projects, refresh: refreshProjects } = useProjects() | ||
| 15 | + const catalog = useCatalog() | ||
| 16 | + const toast = useToast() | ||
| 17 | + | ||
| 18 | + const scopeId = computed(() => (kind === 'home' ? 'home' : projectId() ?? '')) | ||
| 19 | + const state = ref<ScopeState | null>(null) | ||
| 20 | + const config = ref<ScopeConfig | null>(null) | ||
| 21 | + const loading = ref(true) | ||
| 22 | + const error = ref<string | null>(null) | ||
| 23 | + const saving = ref(false) | ||
| 24 | + | ||
| 25 | + const inventory = computed(() => state.value?.inventory ?? null) | ||
| 26 | + const statuses = computed(() => state.value?.statuses ?? {}) | ||
| 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)) | ||
| 29 | + const folderMissing = computed(() => kind === 'project' && !!project.value && !project.value.exists) | ||
| 30 | + const title = computed(() => (kind === 'home' ? 'Home' : project.value?.name ?? scopeId.value)) | ||
| 31 | + const rootPath = computed(() => inventory.value?.root ?? project.value?.path ?? '') | ||
| 32 | + | ||
| 33 | + async function load(showLoading = true) { | ||
| 34 | + if (showLoading) loading.value = true | ||
| 35 | + try { | ||
| 36 | + state.value = await request<ScopeState>(`/api/scopes/${encodeURIComponent(scopeId.value)}/state`, { silent: true }) | ||
| 37 | + config.value = state.value.config ? cloneConfig(state.value.config) : null | ||
| 38 | + error.value = null | ||
| 39 | + } catch (e) { | ||
| 40 | + error.value = (e as Error).message | ||
| 41 | + // The folder may have disappeared: refresh the summaries so `exists` is up to date. | ||
| 42 | + if (kind === 'project') await refreshProjects() | ||
| 43 | + } finally { | ||
| 44 | + loading.value = false | ||
| 45 | + } | ||
| 46 | + } | ||
| 47 | + | ||
| 48 | + async function rescan() { | ||
| 49 | + await load(false) | ||
| 50 | + if (!error.value) toast.add({ title: 'Rescan complete', color: 'success', icon: 'i-lucide-check' }) | ||
| 51 | + } | ||
| 52 | + | ||
| 53 | + /* ---------------------------------------------------------------- config autosave */ | ||
| 54 | + | ||
| 55 | + let timer: ReturnType<typeof setTimeout> | undefined | ||
| 56 | + let seq = 0 | ||
| 57 | + let adopted = 0 | ||
| 58 | + let inflight: Promise<void> = Promise.resolve() | ||
| 59 | + | ||
| 60 | + async function runSave(mine: number) { | ||
| 61 | + const body = config.value | ||
| 62 | + if (!body) return | ||
| 63 | + try { | ||
| 64 | + const res = await request<ScopeState>(`/api/scopes/${encodeURIComponent(scopeId.value)}/config`, { method: 'PUT', body, silent: true }) | ||
| 65 | + // Ignore answers that arrive out of order. | ||
| 66 | + if (mine >= adopted) { | ||
| 67 | + adopted = mine | ||
| 68 | + state.value = res | ||
| 69 | + // Only adopt the server copy when no newer edit happened meanwhile. | ||
| 70 | + if (mine === seq) config.value = res.config ? cloneConfig(res.config) : body | ||
| 71 | + } | ||
| 72 | + } catch (e) { | ||
| 73 | + toast.add({ title: 'Could not save the configuration', description: (e as Error).message, color: 'error', icon: 'i-lucide-circle-alert' }) | ||
| 74 | + if (mine === seq) await load(false) | ||
| 75 | + } finally { | ||
| 76 | + if (mine === seq) saving.value = false | ||
| 77 | + } | ||
| 78 | + } | ||
| 79 | + | ||
| 80 | + /** Replaces the config: shown at once, saved in the background (debounced unless `immediate`). */ | ||
| 81 | + function saveConfig(next: ScopeConfig, immediate = false) { | ||
| 82 | + config.value = next | ||
| 83 | + const mine = ++seq | ||
| 84 | + saving.value = true | ||
| 85 | + clearTimeout(timer) | ||
| 86 | + timer = undefined | ||
| 87 | + if (immediate) inflight = runSave(mine) | ||
| 88 | + else timer = setTimeout(() => { timer = undefined; inflight = runSave(mine) }, 400) | ||
| 89 | + } | ||
| 90 | + | ||
| 91 | + /** Runs a pending save now and waits for it (before opening the review). */ | ||
| 92 | + async function flush() { | ||
| 93 | + if (timer) { | ||
| 94 | + clearTimeout(timer) | ||
| 95 | + timer = undefined | ||
| 96 | + inflight = runSave(seq) | ||
| 97 | + } | ||
| 98 | + await inflight | ||
| 99 | + } | ||
| 100 | + | ||
| 101 | + /** Edits a copy of the config and saves it. */ | ||
| 102 | + function edit(fn: (c: ScopeConfig) => void) { | ||
| 103 | + if (!config.value) return | ||
| 104 | + const c = cloneConfig(config.value) | ||
| 105 | + fn(c) | ||
| 106 | + saveConfig(c) | ||
| 107 | + } | ||
| 108 | + | ||
| 109 | + onBeforeUnmount(() => clearTimeout(timer)) | ||
| 110 | + | ||
| 111 | + /* ---------------------------------------------------------------- conflict resolutions (staged) */ | ||
| 112 | + | ||
| 113 | + const resolutions = ref<Record<string, ConflictResolution>>({}) | ||
| 114 | + /** Actions for which the user explicitly chose a resolution (the default keep-local is not a decision). */ | ||
| 115 | + const decided = ref<Set<string>>(new Set()) | ||
| 116 | + | ||
| 117 | + function resolutionFor(actionId: string): ConflictResolution { | ||
| 118 | + return resolutions.value[actionId] ?? 'keep-local' | ||
| 119 | + } | ||
| 120 | + function setResolution(actionId: string, r: ConflictResolution) { | ||
| 121 | + resolutions.value = { ...resolutions.value, [actionId]: r } | ||
| 122 | + decided.value = new Set([...decided.value, actionId]) | ||
| 123 | + } | ||
| 124 | + /** Resolutions sent to Apply: one per conflict of the plan, keep-local unless chosen otherwise. */ | ||
| 125 | + function resolutionBody(): Record<string, ConflictResolution> { | ||
| 126 | + return Object.fromEntries((plan.value?.actions ?? []).filter(a => a.kind === 'conflict').map(a => [a.id, resolutionFor(a.id)])) | ||
| 127 | + } | ||
| 128 | + watch(plan, (p) => { | ||
| 129 | + const ids = new Set((p?.actions ?? []).map(a => a.id)) | ||
| 130 | + resolutions.value = Object.fromEntries(Object.entries(resolutions.value).filter(([id]) => ids.has(id))) | ||
| 131 | + decided.value = new Set([...decided.value].filter(id => ids.has(id))) | ||
| 132 | + }) | ||
| 133 | + | ||
| 134 | + const pending = computed(() => (plan.value?.actions ?? []).filter(isPending)) | ||
| 135 | + const conflictActions = computed(() => pending.value.filter(a => a.kind === 'conflict')) | ||
| 136 | + /** Conflicts still waiting for a decision. */ | ||
| 137 | + const openConflicts = computed(() => conflictActions.value.filter(a => !decided.value.has(a.id))) | ||
| 138 | + | ||
| 139 | + /* ---------------------------------------------------------------- overlays owned by the page */ | ||
| 140 | + | ||
| 141 | + const selectedItem = ref<InventoryItem | null>(null) | ||
| 142 | + const itemOpen = ref(false) | ||
| 143 | + const reviewOpen = ref(false) | ||
| 144 | + const importItems = ref<InventoryItem[]>([]) | ||
| 145 | + const importOpen = ref(false) | ||
| 146 | + const moveItems = ref<InventoryItem[]>([]) | ||
| 147 | + const moveOpen = ref(false) | ||
| 148 | + | ||
| 149 | + function openItem(item: InventoryItem) { | ||
| 150 | + selectedItem.value = item | ||
| 151 | + itemOpen.value = true | ||
| 152 | + } | ||
| 153 | + function askImport(items: InventoryItem[]) { | ||
| 154 | + importItems.value = items | ||
| 155 | + importOpen.value = true | ||
| 156 | + } | ||
| 157 | + function askMove(items: InventoryItem[]) { | ||
| 158 | + moveItems.value = items | ||
| 159 | + moveOpen.value = true | ||
| 160 | + } | ||
| 161 | + async function openReview() { | ||
| 162 | + await flush() | ||
| 163 | + reviewOpen.value = true | ||
| 164 | + } | ||
| 165 | + | ||
| 166 | + /** After an import / move: refresh this scope, the catalog and the project summaries. */ | ||
| 167 | + async function afterCatalogChange() { | ||
| 168 | + await Promise.all([load(false), catalog.refresh(), refreshProjects()]) | ||
| 169 | + } | ||
| 170 | + function adoptState(next: ScopeState) { | ||
| 171 | + state.value = next | ||
| 172 | + config.value = next.config ? cloneConfig(next.config) : null | ||
| 173 | + if (kind === 'project') refreshProjects() | ||
| 174 | + catalog.refresh() | ||
| 175 | + } | ||
| 176 | + | ||
| 177 | + /** Home skills and MCP servers by name, for the inherited section and to recognise "hide global" actions. */ | ||
| 178 | + const targets = computed<AgentId[]>(() => config.value?.targets ?? []) | ||
| 179 | + | ||
| 180 | + return { | ||
| 181 | + kind, scopeId, state, config, loading, error, saving, inventory, statuses, plan, project, folderMissing, title, rootPath, | ||
| 182 | + load, rescan, saveConfig, edit, flush, | ||
| 183 | + resolutions, decided, resolutionFor, setResolution, resolutionBody, | ||
| 184 | + pending, conflictActions, openConflicts, targets, | ||
| 185 | + selectedItem, itemOpen, reviewOpen, importItems, importOpen, moveItems, moveOpen, | ||
| 186 | + openItem, askImport, askMove, openReview, afterCatalogChange, adoptState | ||
| 187 | + } | ||
| 188 | +} | ||
| 189 | + | ||
| 190 | +export type ScopeContext = ReturnType<typeof createScope> | ||
| 191 | + | ||
| 192 | +const SCOPE_KEY: InjectionKey<ScopeContext> = Symbol('skilly-scope') | ||
| 193 | + | ||
| 194 | +export function provideScope(scope: ScopeContext) { | ||
| 195 | + provide(SCOPE_KEY, scope) | ||
| 196 | +} | ||
| 197 | + | ||
| 198 | +export function useScopeContext(): ScopeContext { | ||
| 199 | + const scope = inject(SCOPE_KEY) | ||
| 200 | + if (!scope) throw new Error('useScopeContext() must be used inside a ScopePage') | ||
| 201 | + return scope | ||
| 202 | +} | ||
modified
packages/ui/app/layouts/default.vue +0 -2 | @@ -23,8 +23,6 @@ onMounted(() => { | ||
| 23 | 23 | </main> |
| 24 | 24 | |
| 25 | 25 | <AddProjectModal v-model:open="addOpen" /> |
| 26 | - <!-- WP3: remove together with CatalogGitDialog once /sync replaces it. --> | |
| 27 | - <CatalogGitDialog /> | |
| 28 | 26 | <AppSwitcher /> |
| 29 | 27 | </div> |
| 30 | 28 | </template> |
| @@ -23,8 +23,6 @@ onMounted(() => { | |||
| 23 | </main> | 23 | </main> |
| 24 | 24 | ||
| 25 | <AddProjectModal v-model:open="addOpen" /> | 25 | <AddProjectModal v-model:open="addOpen" /> |
| 26 | - <!-- WP3: remove together with CatalogGitDialog once /sync replaces it. --> | ||
| 27 | - <CatalogGitDialog /> | ||
| 28 | <AppSwitcher /> | 26 | <AppSwitcher /> |
| 29 | </div> | 27 | </div> |
| 30 | </template> | 28 | </template> |
modified
packages/ui/app/pages/catalog/[type]/[name].vue +2 -3 | @@ -2,10 +2,9 @@ | ||
| 2 | 2 | import type { CatalogItemType } from '@skilly/core' |
| 3 | 3 | |
| 4 | 4 | const route = useRoute() |
| 5 | -const type = computed(() => String(route.params.type) as CatalogItemType) | |
| 6 | -const name = computed(() => String(route.params.name)) | |
| 5 | +const selected = computed(() => ({ type: String(route.params.type) as CatalogItemType, name: String(route.params.name) })) | |
| 7 | 6 | </script> |
| 8 | 7 | |
| 9 | 8 | <template> |
| 10 | - <CatalogItemEditor :key="`${type}/${name}`" :type="type" :name="name" /> | |
| 9 | + <CatalogBrowser :selected="selected" /> | |
| 11 | 10 | </template> |
| @@ -2,10 +2,9 @@ | |||
| 2 | import type { CatalogItemType } from '@skilly/core' | 2 | import type { CatalogItemType } from '@skilly/core' |
| 3 | 3 | ||
| 4 | const route = useRoute() | 4 | const route = useRoute() |
| 5 | -const type = computed(() => String(route.params.type) as CatalogItemType) | 5 | +const selected = computed(() => ({ type: String(route.params.type) as CatalogItemType, name: String(route.params.name) })) |
| 6 | -const name = computed(() => String(route.params.name)) | ||
| 7 | </script> | 6 | </script> |
| 8 | 7 | ||
| 9 | <template> | 8 | <template> |
| 10 | - <CatalogItemEditor :key="`${type}/${name}`" :type="type" :name="name" /> | 9 | + <CatalogBrowser :selected="selected" /> |
| 11 | </template> | 10 | </template> |
modified
packages/ui/app/pages/catalog/index.vue +1 -145 | @@ -1,147 +1,3 @@ | ||
| 1 | -<script setup lang="ts"> | |
| 2 | -import type { CatalogItem, CatalogItemType } from '@skilly/core' | |
| 3 | - | |
| 4 | -const { data, loading, error, refresh, exists, items, usage, git } = useCatalog() | |
| 5 | -const { open: gitOpen } = useCatalogGit() | |
| 6 | -const names = useScopeNames() | |
| 7 | -const route = useRoute() | |
| 8 | - | |
| 9 | -const tab = ref<CatalogItemType>(CAT_TYPES.includes(route.query.type as CatalogItemType) ? route.query.type as CatalogItemType : 'skill') | |
| 10 | -const search = ref('') | |
| 11 | -const tagFilter = ref<string[]>([]) | |
| 12 | - | |
| 13 | -onMounted(refresh) | |
| 14 | - | |
| 15 | -const byType = computed(() => { | |
| 16 | - const m = Object.fromEntries(CAT_TYPES.map(t => [t, [] as CatalogItem[]])) as Record<CatalogItemType, CatalogItem[]> | |
| 17 | - for (const i of items.value) m[i.type]?.push(i) | |
| 18 | - return m | |
| 19 | -}) | |
| 20 | - | |
| 21 | -const allTags = computed(() => [...new Set(byType.value[tab.value].flatMap(i => i.tags))].sort()) | |
| 22 | - | |
| 23 | -const rows = computed(() => { | |
| 24 | - const q = search.value.trim().toLowerCase() | |
| 25 | - return byType.value[tab.value] | |
| 26 | - .filter(i => (!q || `${i.name} ${i.description ?? ''} ${i.tags.join(' ')}`.toLowerCase().includes(q)) | |
| 27 | - && tagFilter.value.every(t => i.tags.includes(t))) | |
| 28 | - .sort((a, b) => a.name.localeCompare(b.name)) | |
| 29 | -}) | |
| 30 | - | |
| 31 | -const tabs = computed(() => CAT_TYPES.map(t => ({ | |
| 32 | - label: CAT_META[t].label, | |
| 33 | - icon: CAT_META[t].icon, | |
| 34 | - value: t, | |
| 35 | - badge: { label: String(byType.value[t].length), color: 'neutral' as const, variant: 'subtle' as const } | |
| 36 | -}))) | |
| 37 | - | |
| 38 | -watch(tab, () => { tagFilter.value = [] }) | |
| 39 | - | |
| 40 | -const ORIGIN = { | |
| 41 | - created: { label: 'created', icon: 'i-lucide-pencil-line' }, | |
| 42 | - import: { label: 'imported', icon: 'i-lucide-download' }, | |
| 43 | - marketplace: { label: 'marketplace', icon: 'i-lucide-store' } | |
| 44 | -} as const | |
| 45 | - | |
| 46 | -const usedBy = (i: CatalogItem) => usage.value[itemKey(i.type, i.name)] ?? [] | |
| 47 | -const go = (i: CatalogItem) => navigateTo(`/catalog/${i.type}/${encodeURIComponent(i.name)}`) | |
| 48 | -const TH = 'px-3 py-2 text-left text-xs font-medium uppercase tracking-wide text-muted' | |
| 49 | -</script> | |
| 50 | - | |
| 51 | 1 | <template> |
| 52 | - <UDashboardPanel id="catalog"> | |
| 53 | - <template #header> | |
| 54 | - <UDashboardNavbar title="Catalog" icon="i-lucide-library"> | |
| 55 | - <template #right> | |
| 56 | - <template v-if="exists"> | |
| 57 | - <UButton v-if="git?.isRepo" label="Git" icon="i-lucide-git-branch" color="neutral" variant="outline" @click="gitOpen = true" /> | |
| 58 | - <UButton :label="`New ${CAT_META[tab].singular.toLowerCase()}`" icon="i-lucide-plus" :to="`/catalog/new?type=${tab}`" /> | |
| 59 | - </template> | |
| 60 | - </template> | |
| 61 | - </UDashboardNavbar> | |
| 62 | - </template> | |
| 63 | - | |
| 64 | - <template #body> | |
| 65 | - <div v-if="loading && !data" class="space-y-3"> | |
| 66 | - <USkeleton class="h-10 w-full" /> | |
| 67 | - <USkeleton v-for="i in 5" :key="i" class="h-12 w-full" /> | |
| 68 | - </div> | |
| 69 | - <UAlert v-else-if="error && !data" color="error" variant="subtle" icon="i-lucide-circle-alert" title="Could not load the catalog" :description="error" :actions="[{ label: 'Retry', color: 'neutral', variant: 'outline', onClick: refresh }]" /> | |
| 70 | - <CatalogSetupCard v-else-if="data && !exists" /> | |
| 71 | - | |
| 72 | - <div v-else-if="data" class="flex flex-col gap-4"> | |
| 73 | - <UAlert | |
| 74 | - v-if="data.snapshot.errors.length" | |
| 75 | - color="warning" | |
| 76 | - variant="subtle" | |
| 77 | - icon="i-lucide-triangle-alert" | |
| 78 | - :title="`${data.snapshot.errors.length} item(s) could not be read`" | |
| 79 | - > | |
| 80 | - <template #description> | |
| 81 | - <ul class="list-disc pl-4 text-xs"> | |
| 82 | - <li v-for="e in data.snapshot.errors" :key="e.path"><span class="font-mono">{{ e.path }}</span>: {{ e.message }}</li> | |
| 83 | - </ul> | |
| 84 | - </template> | |
| 85 | - </UAlert> | |
| 86 | - | |
| 87 | - <div class="flex flex-wrap items-center gap-3"> | |
| 88 | - <UInput v-model="search" icon="i-lucide-search" placeholder="Search name, description, tag..." class="w-72"> | |
| 89 | - <template v-if="search" #trailing> | |
| 90 | - <UButton icon="i-lucide-x" size="xs" color="neutral" variant="link" aria-label="Clear search" @click="search = ''" /> | |
| 91 | - </template> | |
| 92 | - </UInput> | |
| 93 | - <USelectMenu v-if="allTags.length" v-model="tagFilter" :items="allTags" multiple placeholder="Filter by tag" icon="i-lucide-tag" class="w-52" /> | |
| 94 | - <UButton v-if="tagFilter.length" label="Clear tags" size="xs" color="neutral" variant="link" @click="tagFilter = []" /> | |
| 95 | - </div> | |
| 96 | - | |
| 97 | - <UTabs v-model="tab" :items="tabs" :content="false" variant="link" class="w-full" /> | |
| 98 | - | |
| 99 | - <div v-if="!rows.length" class="flex flex-col items-center gap-2 rounded-xl border border-dashed border-default py-12 text-center"> | |
| 100 | - <UIcon :name="search || tagFilter.length ? 'i-lucide-search-x' : CAT_META[tab].icon" class="size-8 text-dimmed" /> | |
| 101 | - <p class="text-sm text-muted"> | |
| 102 | - {{ search || tagFilter.length ? 'No result matches the current filters.' : `No ${CAT_META[tab].label.toLowerCase()} in the catalog yet.` }} | |
| 103 | - </p> | |
| 104 | - <p v-if="!search && !tagFilter.length" class="text-xs text-muted">Create one, add items from a project with "Add to catalog", or import from Marketplaces.</p> | |
| 105 | - </div> | |
| 106 | - | |
| 107 | - <div v-else class="overflow-x-auto rounded-xl border border-default"> | |
| 108 | - <table class="w-full min-w-[720px] table-fixed text-sm"> | |
| 109 | - <thead class="border-b border-default bg-elevated/40"> | |
| 110 | - <tr> | |
| 111 | - <th :class="TH" class="w-[34%]">Name</th> | |
| 112 | - <th :class="TH">Tags</th> | |
| 113 | - <th :class="TH" class="w-32">Origin</th> | |
| 114 | - <th :class="TH" class="w-28">Used by</th> | |
| 115 | - </tr> | |
| 116 | - </thead> | |
| 117 | - <tbody class="divide-y divide-default"> | |
| 118 | - <tr v-for="i in rows" :key="i.name" class="cursor-pointer transition-colors hover:bg-elevated/50" tabindex="0" @click="go(i)" @keydown.enter="go(i)"> | |
| 119 | - <td class="px-3 py-2 align-middle"> | |
| 120 | - <span class="block truncate font-medium text-highlighted">{{ i.name }}</span> | |
| 121 | - <p v-if="i.description" class="line-clamp-1 text-xs text-muted" :title="i.description">{{ i.description }}</p> | |
| 122 | - </td> | |
| 123 | - <td class="px-3 py-2 align-middle"> | |
| 124 | - <div class="flex flex-wrap gap-1"> | |
| 125 | - <UBadge v-for="t in i.tags" :key="t" :label="t" color="neutral" variant="outline" size="sm" /> | |
| 126 | - <span v-if="!i.tags.length" class="text-xs text-dimmed">-</span> | |
| 127 | - </div> | |
| 128 | - </td> | |
| 129 | - <td class="px-3 py-2 align-middle"> | |
| 130 | - <UTooltip :text="i.origin?.source ? `Source: ${i.origin.source}` : 'Created in skilly'"> | |
| 131 | - <UBadge :label="ORIGIN[i.origin?.kind ?? 'created'].label" :icon="ORIGIN[i.origin?.kind ?? 'created'].icon" color="neutral" variant="subtle" size="sm" /> | |
| 132 | - </UTooltip> | |
| 133 | - </td> | |
| 134 | - <td class="px-3 py-2 align-middle"> | |
| 135 | - <UTooltip v-if="usedBy(i).length" :text="usedBy(i).map(s => scopeLabel(s, names)).join(', ')"> | |
| 136 | - <span class="text-xs tabular-nums">{{ usedBy(i).length }} scope{{ usedBy(i).length > 1 ? 's' : '' }}</span> | |
| 137 | - </UTooltip> | |
| 138 | - <span v-else class="text-xs text-dimmed">unused</span> | |
| 139 | - </td> | |
| 140 | - </tr> | |
| 141 | - </tbody> | |
| 142 | - </table> | |
| 143 | - </div> | |
| 144 | - </div> | |
| 145 | - </template> | |
| 146 | - </UDashboardPanel> | |
| 2 | + <CatalogBrowser /> | |
| 147 | 3 | </template> |
| @@ -1,147 +1,3 @@ | |||
| 1 | -<script setup lang="ts"> | ||
| 2 | -import type { CatalogItem, CatalogItemType } from '@skilly/core' | ||
| 3 | - | ||
| 4 | -const { data, loading, error, refresh, exists, items, usage, git } = useCatalog() | ||
| 5 | -const { open: gitOpen } = useCatalogGit() | ||
| 6 | -const names = useScopeNames() | ||
| 7 | -const route = useRoute() | ||
| 8 | - | ||
| 9 | -const tab = ref<CatalogItemType>(CAT_TYPES.includes(route.query.type as CatalogItemType) ? route.query.type as CatalogItemType : 'skill') | ||
| 10 | -const search = ref('') | ||
| 11 | -const tagFilter = ref<string[]>([]) | ||
| 12 | - | ||
| 13 | -onMounted(refresh) | ||
| 14 | - | ||
| 15 | -const byType = computed(() => { | ||
| 16 | - const m = Object.fromEntries(CAT_TYPES.map(t => [t, [] as CatalogItem[]])) as Record<CatalogItemType, CatalogItem[]> | ||
| 17 | - for (const i of items.value) m[i.type]?.push(i) | ||
| 18 | - return m | ||
| 19 | -}) | ||
| 20 | - | ||
| 21 | -const allTags = computed(() => [...new Set(byType.value[tab.value].flatMap(i => i.tags))].sort()) | ||
| 22 | - | ||
| 23 | -const rows = computed(() => { | ||
| 24 | - const q = search.value.trim().toLowerCase() | ||
| 25 | - return byType.value[tab.value] | ||
| 26 | - .filter(i => (!q || `${i.name} ${i.description ?? ''} ${i.tags.join(' ')}`.toLowerCase().includes(q)) | ||
| 27 | - && tagFilter.value.every(t => i.tags.includes(t))) | ||
| 28 | - .sort((a, b) => a.name.localeCompare(b.name)) | ||
| 29 | -}) | ||
| 30 | - | ||
| 31 | -const tabs = computed(() => CAT_TYPES.map(t => ({ | ||
| 32 | - label: CAT_META[t].label, | ||
| 33 | - icon: CAT_META[t].icon, | ||
| 34 | - value: t, | ||
| 35 | - badge: { label: String(byType.value[t].length), color: 'neutral' as const, variant: 'subtle' as const } | ||
| 36 | -}))) | ||
| 37 | - | ||
| 38 | -watch(tab, () => { tagFilter.value = [] }) | ||
| 39 | - | ||
| 40 | -const ORIGIN = { | ||
| 41 | - created: { label: 'created', icon: 'i-lucide-pencil-line' }, | ||
| 42 | - import: { label: 'imported', icon: 'i-lucide-download' }, | ||
| 43 | - marketplace: { label: 'marketplace', icon: 'i-lucide-store' } | ||
| 44 | -} as const | ||
| 45 | - | ||
| 46 | -const usedBy = (i: CatalogItem) => usage.value[itemKey(i.type, i.name)] ?? [] | ||
| 47 | -const go = (i: CatalogItem) => navigateTo(`/catalog/${i.type}/${encodeURIComponent(i.name)}`) | ||
| 48 | -const TH = 'px-3 py-2 text-left text-xs font-medium uppercase tracking-wide text-muted' | ||
| 49 | -</script> | ||
| 50 | - | ||
| 51 | <template> | 1 | <template> |
| 52 | - <UDashboardPanel id="catalog"> | 2 | + <CatalogBrowser /> |
| 53 | - <template #header> | ||
| 54 | - <UDashboardNavbar title="Catalog" icon="i-lucide-library"> | ||
| 55 | - <template #right> | ||
| 56 | - <template v-if="exists"> | ||
| 57 | - <UButton v-if="git?.isRepo" label="Git" icon="i-lucide-git-branch" color="neutral" variant="outline" @click="gitOpen = true" /> | ||
| 58 | - <UButton :label="`New ${CAT_META[tab].singular.toLowerCase()}`" icon="i-lucide-plus" :to="`/catalog/new?type=${tab}`" /> | ||
| 59 | - </template> | ||
| 60 | - </template> | ||
| 61 | - </UDashboardNavbar> | ||
| 62 | - </template> | ||
| 63 | - | ||
| 64 | - <template #body> | ||
| 65 | - <div v-if="loading && !data" class="space-y-3"> | ||
| 66 | - <USkeleton class="h-10 w-full" /> | ||
| 67 | - <USkeleton v-for="i in 5" :key="i" class="h-12 w-full" /> | ||
| 68 | - </div> | ||
| 69 | - <UAlert v-else-if="error && !data" color="error" variant="subtle" icon="i-lucide-circle-alert" title="Could not load the catalog" :description="error" :actions="[{ label: 'Retry', color: 'neutral', variant: 'outline', onClick: refresh }]" /> | ||
| 70 | - <CatalogSetupCard v-else-if="data && !exists" /> | ||
| 71 | - | ||
| 72 | - <div v-else-if="data" class="flex flex-col gap-4"> | ||
| 73 | - <UAlert | ||
| 74 | - v-if="data.snapshot.errors.length" | ||
| 75 | - color="warning" | ||
| 76 | - variant="subtle" | ||
| 77 | - icon="i-lucide-triangle-alert" | ||
| 78 | - :title="`${data.snapshot.errors.length} item(s) could not be read`" | ||
| 79 | - > | ||
| 80 | - <template #description> | ||
| 81 | - <ul class="list-disc pl-4 text-xs"> | ||
| 82 | - <li v-for="e in data.snapshot.errors" :key="e.path"><span class="font-mono">{{ e.path }}</span>: {{ e.message }}</li> | ||
| 83 | - </ul> | ||
| 84 | - </template> | ||
| 85 | - </UAlert> | ||
| 86 | - | ||
| 87 | - <div class="flex flex-wrap items-center gap-3"> | ||
| 88 | - <UInput v-model="search" icon="i-lucide-search" placeholder="Search name, description, tag..." class="w-72"> | ||
| 89 | - <template v-if="search" #trailing> | ||
| 90 | - <UButton icon="i-lucide-x" size="xs" color="neutral" variant="link" aria-label="Clear search" @click="search = ''" /> | ||
| 91 | - </template> | ||
| 92 | - </UInput> | ||
| 93 | - <USelectMenu v-if="allTags.length" v-model="tagFilter" :items="allTags" multiple placeholder="Filter by tag" icon="i-lucide-tag" class="w-52" /> | ||
| 94 | - <UButton v-if="tagFilter.length" label="Clear tags" size="xs" color="neutral" variant="link" @click="tagFilter = []" /> | ||
| 95 | - </div> | ||
| 96 | - | ||
| 97 | - <UTabs v-model="tab" :items="tabs" :content="false" variant="link" class="w-full" /> | ||
| 98 | - | ||
| 99 | - <div v-if="!rows.length" class="flex flex-col items-center gap-2 rounded-xl border border-dashed border-default py-12 text-center"> | ||
| 100 | - <UIcon :name="search || tagFilter.length ? 'i-lucide-search-x' : CAT_META[tab].icon" class="size-8 text-dimmed" /> | ||
| 101 | - <p class="text-sm text-muted"> | ||
| 102 | - {{ search || tagFilter.length ? 'No result matches the current filters.' : `No ${CAT_META[tab].label.toLowerCase()} in the catalog yet.` }} | ||
| 103 | - </p> | ||
| 104 | - <p v-if="!search && !tagFilter.length" class="text-xs text-muted">Create one, add items from a project with "Add to catalog", or import from Marketplaces.</p> | ||
| 105 | - </div> | ||
| 106 | - | ||
| 107 | - <div v-else class="overflow-x-auto rounded-xl border border-default"> | ||
| 108 | - <table class="w-full min-w-[720px] table-fixed text-sm"> | ||
| 109 | - <thead class="border-b border-default bg-elevated/40"> | ||
| 110 | - <tr> | ||
| 111 | - <th :class="TH" class="w-[34%]">Name</th> | ||
| 112 | - <th :class="TH">Tags</th> | ||
| 113 | - <th :class="TH" class="w-32">Origin</th> | ||
| 114 | - <th :class="TH" class="w-28">Used by</th> | ||
| 115 | - </tr> | ||
| 116 | - </thead> | ||
| 117 | - <tbody class="divide-y divide-default"> | ||
| 118 | - <tr v-for="i in rows" :key="i.name" class="cursor-pointer transition-colors hover:bg-elevated/50" tabindex="0" @click="go(i)" @keydown.enter="go(i)"> | ||
| 119 | - <td class="px-3 py-2 align-middle"> | ||
| 120 | - <span class="block truncate font-medium text-highlighted">{{ i.name }}</span> | ||
| 121 | - <p v-if="i.description" class="line-clamp-1 text-xs text-muted" :title="i.description">{{ i.description }}</p> | ||
| 122 | - </td> | ||
| 123 | - <td class="px-3 py-2 align-middle"> | ||
| 124 | - <div class="flex flex-wrap gap-1"> | ||
| 125 | - <UBadge v-for="t in i.tags" :key="t" :label="t" color="neutral" variant="outline" size="sm" /> | ||
| 126 | - <span v-if="!i.tags.length" class="text-xs text-dimmed">-</span> | ||
| 127 | - </div> | ||
| 128 | - </td> | ||
| 129 | - <td class="px-3 py-2 align-middle"> | ||
| 130 | - <UTooltip :text="i.origin?.source ? `Source: ${i.origin.source}` : 'Created in skilly'"> | ||
| 131 | - <UBadge :label="ORIGIN[i.origin?.kind ?? 'created'].label" :icon="ORIGIN[i.origin?.kind ?? 'created'].icon" color="neutral" variant="subtle" size="sm" /> | ||
| 132 | - </UTooltip> | ||
| 133 | - </td> | ||
| 134 | - <td class="px-3 py-2 align-middle"> | ||
| 135 | - <UTooltip v-if="usedBy(i).length" :text="usedBy(i).map(s => scopeLabel(s, names)).join(', ')"> | ||
| 136 | - <span class="text-xs tabular-nums">{{ usedBy(i).length }} scope{{ usedBy(i).length > 1 ? 's' : '' }}</span> | ||
| 137 | - </UTooltip> | ||
| 138 | - <span v-else class="text-xs text-dimmed">unused</span> | ||
| 139 | - </td> | ||
| 140 | - </tr> | ||
| 141 | - </tbody> | ||
| 142 | - </table> | ||
| 143 | - </div> | ||
| 144 | - </div> | ||
| 145 | - </template> | ||
| 146 | - </UDashboardPanel> | ||
| 147 | </template> | 3 | </template> |
modified
packages/ui/app/pages/catalog/new.vue +1 -1 | @@ -6,5 +6,5 @@ const type = computed<CatalogItemType>(() => (CAT_TYPES.includes(route.query.typ | ||
| 6 | 6 | </script> |
| 7 | 7 | |
| 8 | 8 | <template> |
| 9 | - <CatalogItemEditor :key="type" :type="type" /> | |
| 9 | + <CatalogBrowser :creating="type" /> | |
| 10 | 10 | </template> |
| @@ -6,5 +6,5 @@ const type = computed<CatalogItemType>(() => (CAT_TYPES.includes(route.query.typ | |||
| 6 | </script> | 6 | </script> |
| 7 | 7 | ||
| 8 | <template> | 8 | <template> |
| 9 | - <CatalogItemEditor :key="type" :type="type" /> | 9 | + <CatalogBrowser :creating="type" /> |
| 10 | </template> | 10 | </template> |
modified
packages/ui/app/pages/catalog/presets/index.vue +53 -67 | @@ -1,80 +1,66 @@ | ||
| 1 | 1 | <script setup lang="ts"> |
| 2 | -import type { CatalogItemType } from '@skilly/core' | |
| 2 | +import type { CatalogItemType, Preset } from '@skilly/core' | |
| 3 | 3 | |
| 4 | 4 | const { data, loading, error, refresh, exists, presets, presetUsedBy } = useCatalog() |
| 5 | -const names = useScopeNames() | |
| 6 | 5 | |
| 7 | 6 | onMounted(refresh) |
| 8 | 7 | |
| 9 | 8 | const sorted = computed(() => [...presets.value].sort((a, b) => a.name.localeCompare(b.name))) |
| 10 | -const count = (p: (typeof presets.value)[number], t: CatalogItemType) => p.items[t]?.length ?? 0 | |
| 11 | -const total = (p: (typeof presets.value)[number]) => CAT_TYPES.reduce((n, t) => n + count(p, t), 0) | |
| 12 | -const TH = 'px-3 py-2 text-left text-xs font-medium uppercase tracking-wide text-muted' | |
| 9 | +const MAX_CHIPS = 12 | |
| 10 | + | |
| 11 | +function chips(p: Preset) { | |
| 12 | + return CAT_ORDER.flatMap(t => (p.items[t] ?? []).map(name => ({ type: t as CatalogItemType, name }))) | |
| 13 | +} | |
| 14 | +const used = (name: string) => { | |
| 15 | + const n = presetUsedBy(name).length | |
| 16 | + return n ? `Enabled in ${n} project${n > 1 ? 's' : ''}` : 'Not enabled anywhere yet' | |
| 17 | +} | |
| 13 | 18 | </script> |
| 14 | 19 | |
| 15 | 20 | <template> |
| 16 | - <UDashboardPanel id="presets"> | |
| 17 | - <template #header> | |
| 18 | - <UDashboardNavbar title="Presets" icon="i-lucide-layers"> | |
| 19 | - <template #right> | |
| 20 | - <UButton v-if="exists" label="New preset" icon="i-lucide-plus" to="/catalog/presets/new" /> | |
| 21 | - </template> | |
| 22 | - </UDashboardNavbar> | |
| 23 | - </template> | |
| 24 | - <template #body> | |
| 25 | - <div v-if="loading && !data" class="space-y-3"> | |
| 26 | - <USkeleton v-for="i in 4" :key="i" class="h-12 w-full" /> | |
| 27 | - </div> | |
| 28 | - <UAlert v-else-if="error && !data" color="error" variant="subtle" icon="i-lucide-circle-alert" title="Could not load the catalog" :description="error" /> | |
| 29 | - <CatalogSetupCard v-else-if="data && !exists" /> | |
| 30 | - <div v-else class="space-y-4"> | |
| 31 | - <p class="text-sm text-muted">A preset is a named selection of catalog items. Enable it in a project to write all its items at once.</p> | |
| 32 | - <div v-if="!sorted.length" class="flex flex-col items-center gap-2 rounded-xl border border-dashed border-default py-12 text-center"> | |
| 33 | - <UIcon name="i-lucide-layers" class="size-8 text-dimmed" /> | |
| 34 | - <p class="text-sm text-muted">No preset yet.</p> | |
| 35 | - <UButton label="Create a preset" icon="i-lucide-plus" to="/catalog/presets/new" size="sm" /> | |
| 21 | + <SkPage wide> | |
| 22 | + <CatalogHeader tab="presets" /> | |
| 23 | + | |
| 24 | + <div v-if="loading && !data" class="grid gap-3 [grid-template-columns:repeat(auto-fill,minmax(300px,1fr))]"> | |
| 25 | + <USkeleton v-for="i in 4" :key="i" class="h-40 rounded-[10px]" /> | |
| 26 | + </div> | |
| 27 | + <SkNote v-else-if="error && !data" tone="accent"> | |
| 28 | + <p class="m-0 font-medium">Could not load the catalog</p> | |
| 29 | + <p class="mt-1 mb-0 text-xs">{{ error }}</p> | |
| 30 | + <template #aside><SkButton size="sm" label="Retry" @click="refresh" /></template> | |
| 31 | + </SkNote> | |
| 32 | + <CatalogSetupCard v-else-if="data && !exists" /> | |
| 33 | + | |
| 34 | + <div v-else-if="data" class="grid gap-3 [grid-template-columns:repeat(auto-fill,minmax(300px,1fr))]"> | |
| 35 | + <NuxtLink | |
| 36 | + to="/catalog/presets/new" | |
| 37 | + class="flex min-h-[150px] flex-col items-center justify-center gap-1 rounded-[10px] border border-dashed border-sk-line p-[18px] text-sk-muted transition-colors hover:border-sk-faint hover:text-sk-text" | |
| 38 | + > | |
| 39 | + <span class="text-xl leading-none" aria-hidden="true">+</span> | |
| 40 | + <span class="font-medium">New preset</span> | |
| 41 | + </NuxtLink> | |
| 42 | + | |
| 43 | + <NuxtLink | |
| 44 | + v-for="p in sorted" | |
| 45 | + :key="p.name" | |
| 46 | + :to="`/catalog/presets/${encodeURIComponent(p.name)}`" | |
| 47 | + class="flex flex-col gap-3 rounded-[10px] bg-sk-panel p-[18px] text-sk-text shadow-sk-ring transition-shadow hover:shadow-sk-ring-hover" | |
| 48 | + > | |
| 49 | + <div class="flex flex-col gap-[3px]"> | |
| 50 | + <span class="text-sm font-semibold">{{ p.name }}</span> | |
| 51 | + <span v-if="p.description" class="text-pretty text-sk-muted">{{ p.description }}</span> | |
| 36 | 52 | </div> |
| 37 | - <div v-else class="overflow-x-auto rounded-xl border border-default"> | |
| 38 | - <table class="w-full min-w-[720px] table-fixed text-sm"> | |
| 39 | - <thead class="border-b border-default bg-elevated/40"> | |
| 40 | - <tr> | |
| 41 | - <th :class="TH" class="w-[30%]">Name</th> | |
| 42 | - <th :class="TH">Items</th> | |
| 43 | - <th :class="TH" class="w-40">Includes</th> | |
| 44 | - <th :class="TH" class="w-28">Used by</th> | |
| 45 | - </tr> | |
| 46 | - </thead> | |
| 47 | - <tbody class="divide-y divide-default"> | |
| 48 | - <tr v-for="p in sorted" :key="p.name" class="cursor-pointer transition-colors hover:bg-elevated/50" tabindex="0" @click="navigateTo(`/catalog/presets/${encodeURIComponent(p.name)}`)" @keydown.enter="navigateTo(`/catalog/presets/${encodeURIComponent(p.name)}`)"> | |
| 49 | - <td class="px-3 py-2 align-middle"> | |
| 50 | - <span class="block truncate font-medium text-highlighted">{{ p.name }}</span> | |
| 51 | - <p v-if="p.description" class="line-clamp-1 text-xs text-muted" :title="p.description">{{ p.description }}</p> | |
| 52 | - </td> | |
| 53 | - <td class="px-3 py-2 align-middle"> | |
| 54 | - <div class="flex flex-wrap gap-x-3 gap-y-1 text-xs"> | |
| 55 | - <span v-for="t in CAT_TYPES.filter(t => count(p, t))" :key="t" class="flex items-center gap-1"> | |
| 56 | - <UIcon :name="CAT_META[t].icon" class="size-3.5 text-muted" /><span class="tabular-nums font-medium">{{ count(p, t) }}</span><span class="text-muted">{{ CAT_META[t].label }}</span> | |
| 57 | - </span> | |
| 58 | - <span v-if="!total(p)" class="text-dimmed">empty</span> | |
| 59 | - </div> | |
| 60 | - </td> | |
| 61 | - <td class="px-3 py-2 align-middle"> | |
| 62 | - <div class="flex flex-wrap gap-1"> | |
| 63 | - <UBadge v-for="i in p.includes" :key="i" :label="i" size="sm" color="neutral" variant="outline" /> | |
| 64 | - <span v-if="!p.includes.length" class="text-xs text-dimmed">-</span> | |
| 65 | - </div> | |
| 66 | - </td> | |
| 67 | - <td class="px-3 py-2 align-middle"> | |
| 68 | - <UTooltip v-if="presetUsedBy(p.name).length" :text="presetUsedBy(p.name).map(s => scopeLabel(s, names)).join(', ')"> | |
| 69 | - <span class="text-xs tabular-nums">{{ presetUsedBy(p.name).length }} scope{{ presetUsedBy(p.name).length > 1 ? 's' : '' }}</span> | |
| 70 | - </UTooltip> | |
| 71 | - <span v-else class="text-xs text-dimmed">unused</span> | |
| 72 | - </td> | |
| 73 | - </tr> | |
| 74 | - </tbody> | |
| 75 | - </table> | |
| 53 | + <span v-if="p.includes.length" class="text-xs text-sk-muted"> | |
| 54 | + Includes preset | |
| 55 | + <template v-for="(inc, i) in p.includes" :key="inc"><span class="font-medium text-sk-text">{{ inc }}</span><template v-if="i < p.includes.length - 1">, </template></template> | |
| 56 | + </span> | |
| 57 | + <div class="flex flex-wrap gap-1"> | |
| 58 | + <SkItemChip v-for="c in chips(p).slice(0, MAX_CHIPS)" :key="`${c.type}/${c.name}`" :name="c.name" :type="c.type" /> | |
| 59 | + <span v-if="chips(p).length > MAX_CHIPS" class="px-1 py-[3px] text-xs text-sk-faint">+{{ chips(p).length - MAX_CHIPS }} more</span> | |
| 60 | + <span v-if="!chips(p).length" class="text-xs text-sk-faint">{{ p.includes.length ? 'Only the included presets' : 'No items yet' }}</span> | |
| 76 | 61 | </div> |
| 77 | - </div> | |
| 78 | - </template> | |
| 79 | - </UDashboardPanel> | |
| 62 | + <span class="mt-auto text-xs text-sk-faint">{{ used(p.name) }}</span> | |
| 63 | + </NuxtLink> | |
| 64 | + </div> | |
| 65 | + </SkPage> | |
| 80 | 66 | </template> |
| @@ -1,80 +1,66 @@ | |||
| 1 | <script setup lang="ts"> | 1 | <script setup lang="ts"> |
| 2 | -import type { CatalogItemType } from '@skilly/core' | 2 | +import type { CatalogItemType, Preset } from '@skilly/core' |
| 3 | 3 | ||
| 4 | const { data, loading, error, refresh, exists, presets, presetUsedBy } = useCatalog() | 4 | const { data, loading, error, refresh, exists, presets, presetUsedBy } = useCatalog() |
| 5 | -const names = useScopeNames() | ||
| 6 | 5 | ||
| 7 | onMounted(refresh) | 6 | onMounted(refresh) |
| 8 | 7 | ||
| 9 | const sorted = computed(() => [...presets.value].sort((a, b) => a.name.localeCompare(b.name))) | 8 | const sorted = computed(() => [...presets.value].sort((a, b) => a.name.localeCompare(b.name))) |
| 10 | -const count = (p: (typeof presets.value)[number], t: CatalogItemType) => p.items[t]?.length ?? 0 | 9 | +const MAX_CHIPS = 12 |
| 11 | -const total = (p: (typeof presets.value)[number]) => CAT_TYPES.reduce((n, t) => n + count(p, t), 0) | 10 | + |
| 12 | -const TH = 'px-3 py-2 text-left text-xs font-medium uppercase tracking-wide text-muted' | 11 | +function chips(p: Preset) { |
| 12 | + return CAT_ORDER.flatMap(t => (p.items[t] ?? []).map(name => ({ type: t as CatalogItemType, name }))) | ||
| 13 | +} | ||
| 14 | +const used = (name: string) => { | ||
| 15 | + const n = presetUsedBy(name).length | ||
| 16 | + return n ? `Enabled in ${n} project${n > 1 ? 's' : ''}` : 'Not enabled anywhere yet' | ||
| 17 | +} | ||
| 13 | </script> | 18 | </script> |
| 14 | 19 | ||
| 15 | <template> | 20 | <template> |
| 16 | - <UDashboardPanel id="presets"> | 21 | + <SkPage wide> |
| 17 | - <template #header> | 22 | + <CatalogHeader tab="presets" /> |
| 18 | - <UDashboardNavbar title="Presets" icon="i-lucide-layers"> | 23 | + |
| 19 | - <template #right> | 24 | + <div v-if="loading && !data" class="grid gap-3 [grid-template-columns:repeat(auto-fill,minmax(300px,1fr))]"> |
| 20 | - <UButton v-if="exists" label="New preset" icon="i-lucide-plus" to="/catalog/presets/new" /> | 25 | + <USkeleton v-for="i in 4" :key="i" class="h-40 rounded-[10px]" /> |
| 21 | - </template> | 26 | + </div> |
| 22 | - </UDashboardNavbar> | 27 | + <SkNote v-else-if="error && !data" tone="accent"> |
| 23 | - </template> | 28 | + <p class="m-0 font-medium">Could not load the catalog</p> |
| 24 | - <template #body> | 29 | + <p class="mt-1 mb-0 text-xs">{{ error }}</p> |
| 25 | - <div v-if="loading && !data" class="space-y-3"> | 30 | + <template #aside><SkButton size="sm" label="Retry" @click="refresh" /></template> |
| 26 | - <USkeleton v-for="i in 4" :key="i" class="h-12 w-full" /> | 31 | + </SkNote> |
| 27 | - </div> | 32 | + <CatalogSetupCard v-else-if="data && !exists" /> |
| 28 | - <UAlert v-else-if="error && !data" color="error" variant="subtle" icon="i-lucide-circle-alert" title="Could not load the catalog" :description="error" /> | 33 | + |
| 29 | - <CatalogSetupCard v-else-if="data && !exists" /> | 34 | + <div v-else-if="data" class="grid gap-3 [grid-template-columns:repeat(auto-fill,minmax(300px,1fr))]"> |
| 30 | - <div v-else class="space-y-4"> | 35 | + <NuxtLink |
| 31 | - <p class="text-sm text-muted">A preset is a named selection of catalog items. Enable it in a project to write all its items at once.</p> | 36 | + to="/catalog/presets/new" |
| 32 | - <div v-if="!sorted.length" class="flex flex-col items-center gap-2 rounded-xl border border-dashed border-default py-12 text-center"> | 37 | + class="flex min-h-[150px] flex-col items-center justify-center gap-1 rounded-[10px] border border-dashed border-sk-line p-[18px] text-sk-muted transition-colors hover:border-sk-faint hover:text-sk-text" |
| 33 | - <UIcon name="i-lucide-layers" class="size-8 text-dimmed" /> | 38 | + > |
| 34 | - <p class="text-sm text-muted">No preset yet.</p> | 39 | + <span class="text-xl leading-none" aria-hidden="true">+</span> |
| 35 | - <UButton label="Create a preset" icon="i-lucide-plus" to="/catalog/presets/new" size="sm" /> | 40 | + <span class="font-medium">New preset</span> |
| 41 | + </NuxtLink> | ||
| 42 | + | ||
| 43 | + <NuxtLink | ||
| 44 | + v-for="p in sorted" | ||
| 45 | + :key="p.name" | ||
| 46 | + :to="`/catalog/presets/${encodeURIComponent(p.name)}`" | ||
| 47 | + class="flex flex-col gap-3 rounded-[10px] bg-sk-panel p-[18px] text-sk-text shadow-sk-ring transition-shadow hover:shadow-sk-ring-hover" | ||
| 48 | + > | ||
| 49 | + <div class="flex flex-col gap-[3px]"> | ||
| 50 | + <span class="text-sm font-semibold">{{ p.name }}</span> | ||
| 51 | + <span v-if="p.description" class="text-pretty text-sk-muted">{{ p.description }}</span> | ||
| 36 | </div> | 52 | </div> |
| 37 | - <div v-else class="overflow-x-auto rounded-xl border border-default"> | 53 | + <span v-if="p.includes.length" class="text-xs text-sk-muted"> |
| 38 | - <table class="w-full min-w-[720px] table-fixed text-sm"> | 54 | + Includes preset |
| 39 | - <thead class="border-b border-default bg-elevated/40"> | 55 | + <template v-for="(inc, i) in p.includes" :key="inc"><span class="font-medium text-sk-text">{{ inc }}</span><template v-if="i < p.includes.length - 1">, </template></template> |
| 40 | - <tr> | 56 | + </span> |
| 41 | - <th :class="TH" class="w-[30%]">Name</th> | 57 | + <div class="flex flex-wrap gap-1"> |
| 42 | - <th :class="TH">Items</th> | 58 | + <SkItemChip v-for="c in chips(p).slice(0, MAX_CHIPS)" :key="`${c.type}/${c.name}`" :name="c.name" :type="c.type" /> |
| 43 | - <th :class="TH" class="w-40">Includes</th> | 59 | + <span v-if="chips(p).length > MAX_CHIPS" class="px-1 py-[3px] text-xs text-sk-faint">+{{ chips(p).length - MAX_CHIPS }} more</span> |
| 44 | - <th :class="TH" class="w-28">Used by</th> | 60 | + <span v-if="!chips(p).length" class="text-xs text-sk-faint">{{ p.includes.length ? 'Only the included presets' : 'No items yet' }}</span> |
| 45 | - </tr> | ||
| 46 | - </thead> | ||
| 47 | - <tbody class="divide-y divide-default"> | ||
| 48 | - <tr v-for="p in sorted" :key="p.name" class="cursor-pointer transition-colors hover:bg-elevated/50" tabindex="0" @click="navigateTo(`/catalog/presets/${encodeURIComponent(p.name)}`)" @keydown.enter="navigateTo(`/catalog/presets/${encodeURIComponent(p.name)}`)"> | ||
| 49 | - <td class="px-3 py-2 align-middle"> | ||
| 50 | - <span class="block truncate font-medium text-highlighted">{{ p.name }}</span> | ||
| 51 | - <p v-if="p.description" class="line-clamp-1 text-xs text-muted" :title="p.description">{{ p.description }}</p> | ||
| 52 | - </td> | ||
| 53 | - <td class="px-3 py-2 align-middle"> | ||
| 54 | - <div class="flex flex-wrap gap-x-3 gap-y-1 text-xs"> | ||
| 55 | - <span v-for="t in CAT_TYPES.filter(t => count(p, t))" :key="t" class="flex items-center gap-1"> | ||
| 56 | - <UIcon :name="CAT_META[t].icon" class="size-3.5 text-muted" /><span class="tabular-nums font-medium">{{ count(p, t) }}</span><span class="text-muted">{{ CAT_META[t].label }}</span> | ||
| 57 | - </span> | ||
| 58 | - <span v-if="!total(p)" class="text-dimmed">empty</span> | ||
| 59 | - </div> | ||
| 60 | - </td> | ||
| 61 | - <td class="px-3 py-2 align-middle"> | ||
| 62 | - <div class="flex flex-wrap gap-1"> | ||
| 63 | - <UBadge v-for="i in p.includes" :key="i" :label="i" size="sm" color="neutral" variant="outline" /> | ||
| 64 | - <span v-if="!p.includes.length" class="text-xs text-dimmed">-</span> | ||
| 65 | - </div> | ||
| 66 | - </td> | ||
| 67 | - <td class="px-3 py-2 align-middle"> | ||
| 68 | - <UTooltip v-if="presetUsedBy(p.name).length" :text="presetUsedBy(p.name).map(s => scopeLabel(s, names)).join(', ')"> | ||
| 69 | - <span class="text-xs tabular-nums">{{ presetUsedBy(p.name).length }} scope{{ presetUsedBy(p.name).length > 1 ? 's' : '' }}</span> | ||
| 70 | - </UTooltip> | ||
| 71 | - <span v-else class="text-xs text-dimmed">unused</span> | ||
| 72 | - </td> | ||
| 73 | - </tr> | ||
| 74 | - </tbody> | ||
| 75 | - </table> | ||
| 76 | </div> | 61 | </div> |
| 77 | - </div> | 62 | + <span class="mt-auto text-xs text-sk-faint">{{ used(p.name) }}</span> |
| 78 | - </template> | 63 | + </NuxtLink> |
| 79 | - </UDashboardPanel> | 64 | + </div> |
| 65 | + </SkPage> | ||
| 80 | </template> | 66 | </template> |
modified
packages/ui/app/pages/home.vue +1 -1 | @@ -1,3 +1,3 @@ | ||
| 1 | 1 | <template> |
| 2 | - <ScopeView kind="home" /> | |
| 2 | + <ScopePage kind="home" /> | |
| 3 | 3 | </template> |
| @@ -1,3 +1,3 @@ | |||
| 1 | <template> | 1 | <template> |
| 2 | - <ScopeView kind="home" /> | 2 | + <ScopePage kind="home" /> |
| 3 | </template> | 3 | </template> |
modified
packages/ui/app/pages/index.vue +14 -3 | @@ -1,7 +1,18 @@ | ||
| 1 | 1 | <script setup lang="ts"> |
| 2 | -// The dashboard is gone: Projects is the landing page (brief section 5). | |
| 3 | -// WP3: redirect to /onboarding on first run (no catalog and no project yet) from here. | |
| 4 | -definePageMeta({ redirect: '/projects' }) | |
| 2 | +/** | |
| 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 | + */ | |
| 6 | +const { refresh: refreshProjects } = useProjects() | |
| 7 | +const { refresh: refreshCatalog } = useCatalog() | |
| 8 | + | |
| 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 */ } | |
| 14 | + | |
| 15 | +await navigateTo(!catalogExists && !hasProjects && !done ? '/onboarding' : '/projects', { replace: true }) | |
| 5 | 16 | </script> |
| 6 | 17 | |
| 7 | 18 | <template> |
| @@ -1,7 +1,18 @@ | |||
| 1 | <script setup lang="ts"> | 1 | <script setup lang="ts"> |
| 2 | -// The dashboard is gone: Projects is the landing page (brief section 5). | 2 | +/** |
| 3 | -// WP3: redirect to /onboarding on first run (no catalog and no project yet) from here. | 3 | + * Landing: Projects, or the onboarding on first run (no catalog and no project yet), unless the user skipped or |
| 4 | -definePageMeta({ redirect: '/projects' }) | 4 | + * finished it once (`skilly-onboarding-done`). |
| 5 | + */ | ||
| 6 | +const { refresh: refreshProjects } = useProjects() | ||
| 7 | +const { refresh: refreshCatalog } = useCatalog() | ||
| 8 | + | ||
| 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 */ } | ||
| 14 | + | ||
| 15 | +await navigateTo(!catalogExists && !hasProjects && !done ? '/onboarding' : '/projects', { replace: true }) | ||
| 5 | </script> | 16 | </script> |
| 6 | 17 | ||
| 7 | <template> | 18 | <template> |
modified
packages/ui/app/pages/marketplaces/index.vue +283 -175 | @@ -1,9 +1,15 @@ | ||
| 1 | 1 | <script setup lang="ts"> |
| 2 | 2 | import type { ImportResult, MarketplaceSkill, MarketplaceSkillDetail, MarketplaceSource } from '@skilly/core' |
| 3 | 3 | |
| 4 | +/** | |
| 5 | + * Marketplaces (brief 3.1): two panes like the Catalog. Left: sources (built-in + yours). Right: the skills of the | |
| 6 | + * selected source, grouped by plugin for Claude marketplaces, with multi-select import and a preview drawer. | |
| 7 | + */ | |
| 4 | 8 | const { request } = useApi() |
| 5 | 9 | const catalog = useCatalog() |
| 6 | 10 | const toast = useToast() |
| 11 | +const route = useRoute() | |
| 12 | +const router = useRouter() | |
| 7 | 13 | |
| 8 | 14 | const sources = ref<MarketplaceSource[]>([]) |
| 9 | 15 | const sourcesLoading = ref(true) |
| @@ -16,16 +22,30 @@ const skillsLoading = ref(false) | ||
| 16 | 22 | const skillsError = ref<string | null>(null) |
| 17 | 23 | const refreshing = ref(false) |
| 18 | 24 | const search = ref('') |
| 25 | +const onlyNew = ref<'all' | 'new'>('all') | |
| 19 | 26 | const picked = ref<Set<string>>(new Set()) |
| 20 | 27 | const LIMIT = 300 |
| 21 | 28 | const showAll = ref(false) |
| 22 | 29 | |
| 30 | +/** Seconds since a fetch started, so a slow clone reads as "working" and not as "stuck". */ | |
| 31 | +const elapsed = ref(0) | |
| 32 | +let timer: ReturnType<typeof setInterval> | undefined | |
| 33 | +const fetching = computed(() => refreshing.value || skillsLoading.value) | |
| 34 | +watch(fetching, (on) => { | |
| 35 | + clearInterval(timer) | |
| 36 | + elapsed.value = 0 | |
| 37 | + if (on) timer = setInterval(() => { elapsed.value++ }, 1000) | |
| 38 | +}) | |
| 39 | +onBeforeUnmount(() => clearInterval(timer)) | |
| 40 | + | |
| 23 | 41 | async function loadSources() { |
| 24 | 42 | sourcesLoading.value = true |
| 25 | 43 | try { |
| 26 | 44 | sources.value = await request<MarketplaceSource[]>('/api/marketplaces', { silent: true }) |
| 27 | 45 | sourcesError.value = null |
| 28 | - if (!selectedId.value && sources.value.length) await select(sources.value[0]!.id) | |
| 46 | + const wanted = typeof route.query.source === 'string' ? route.query.source : null | |
| 47 | + const initial = sources.value.find(s => s.id === (selectedId.value ?? wanted)) ?? sources.value[0] | |
| 48 | + if (initial && initial.id !== selectedId.value) await select(initial.id) | |
| 29 | 49 | } catch (e) { |
| 30 | 50 | sourcesError.value = (e as Error).message |
| 31 | 51 | } finally { |
| @@ -35,15 +55,19 @@ async function loadSources() { | ||
| 35 | 55 | |
| 36 | 56 | async function loadSkills() { |
| 37 | 57 | if (!selectedId.value) return |
| 58 | + const id = selectedId.value | |
| 38 | 59 | skillsLoading.value = true |
| 39 | 60 | skillsError.value = null |
| 40 | 61 | try { |
| 41 | - skills.value = await request<MarketplaceSkill[]>(`/api/marketplaces/${encodeURIComponent(selectedId.value)}/skills`, { silent: true }) | |
| 62 | + const list = await request<MarketplaceSkill[]>(`/api/marketplaces/${encodeURIComponent(id)}/skills`, { silent: true }) | |
| 63 | + if (id === selectedId.value) skills.value = list | |
| 42 | 64 | } catch (e) { |
| 43 | - skills.value = [] | |
| 44 | - skillsError.value = (e as Error).message | |
| 65 | + if (id === selectedId.value) { | |
| 66 | + skills.value = [] | |
| 67 | + skillsError.value = (e as Error).message | |
| 68 | + } | |
| 45 | 69 | } finally { |
| 46 | - skillsLoading.value = false | |
| 70 | + if (id === selectedId.value) skillsLoading.value = false | |
| 47 | 71 | } |
| 48 | 72 | } |
| 49 | 73 | |
| @@ -51,18 +75,33 @@ async function select(id: string) { | ||
| 51 | 75 | selectedId.value = id |
| 52 | 76 | picked.value = new Set() |
| 53 | 77 | search.value = '' |
| 78 | + onlyNew.value = 'all' | |
| 54 | 79 | showAll.value = false |
| 80 | + skills.value = [] | |
| 81 | + if (route.query.source !== id) router.replace({ query: { ...route.query, source: id } }) | |
| 55 | 82 | await loadSkills() |
| 83 | + // The listing may have fetched the source for the first time: pick up its "fetched" date. | |
| 84 | + if (!skillsError.value) await reloadSource() | |
| 85 | +} | |
| 86 | + | |
| 87 | +async function reloadSource() { | |
| 88 | + try { | |
| 89 | + const list = await request<MarketplaceSource[]>('/api/marketplaces', { silent: true }) | |
| 90 | + sources.value = list | |
| 91 | + if (!list.some(s => s.id === selectedId.value)) selectedId.value = null | |
| 92 | + } catch { /* the list on screen stays valid */ } | |
| 56 | 93 | } |
| 57 | 94 | |
| 58 | 95 | async function refreshSource() { |
| 59 | - if (!selected.value) return | |
| 96 | + if (!selected.value || refreshing.value) return | |
| 97 | + const id = selected.value.id | |
| 60 | 98 | refreshing.value = true |
| 61 | 99 | try { |
| 62 | - const updated = await request<MarketplaceSource>(`/api/marketplaces/${encodeURIComponent(selected.value.id)}/refresh`, { method: 'POST', silent: true }) | |
| 100 | + const updated = await request<MarketplaceSource>(`/api/marketplaces/${encodeURIComponent(id)}/refresh`, { method: 'POST', silent: true }) | |
| 63 | 101 | sources.value = sources.value.map(s => (s.id === updated.id ? updated : s)) |
| 102 | + skillsLoading.value = true | |
| 64 | 103 | await loadSkills() |
| 65 | - toast.add({ title: 'Source refreshed', description: updated.name, icon: 'i-lucide-check', color: 'success' }) | |
| 104 | + toast.add({ title: 'Source refreshed', description: `${updated.name} · ${skills.value.length} skills`, icon: 'i-lucide-check', color: 'success' }) | |
| 66 | 105 | } catch (e) { |
| 67 | 106 | toast.add({ title: 'Refresh failed', description: (e as Error).message, color: 'error', icon: 'i-lucide-circle-alert' }) |
| 68 | 107 | } finally { |
| @@ -79,7 +118,10 @@ onMounted(() => { | ||
| 79 | 118 | |
| 80 | 119 | const filtered = computed(() => { |
| 81 | 120 | const q = search.value.trim().toLowerCase() |
| 82 | - return q ? skills.value.filter(s => `${s.name} ${s.description ?? ''} ${s.plugin ?? ''}`.toLowerCase().includes(q)) : skills.value | |
| 121 | + return skills.value.filter(s => | |
| 122 | + (onlyNew.value === 'all' || !s.inCatalog) | |
| 123 | + && (!q || `${s.name} ${s.description ?? ''} ${s.plugin ?? ''}`.toLowerCase().includes(q)) | |
| 124 | + ) | |
| 83 | 125 | }) |
| 84 | 126 | const shown = computed(() => (showAll.value ? filtered.value : filtered.value.slice(0, LIMIT))) |
| 85 | 127 | const groups = computed(() => { |
| @@ -91,6 +133,7 @@ const groups = computed(() => { | ||
| 91 | 133 | } |
| 92 | 134 | return [...m.entries()].map(([plugin, rows]) => ({ plugin, rows })) |
| 93 | 135 | }) |
| 136 | +const inCatalogCount = computed(() => skills.value.filter(s => s.inCatalog).length) | |
| 94 | 137 | |
| 95 | 138 | function toggle(path: string, on: boolean | 'indeterminate') { |
| 96 | 139 | const s = new Set(picked.value) |
| @@ -108,14 +151,23 @@ const groupState = (rows: MarketplaceSkill[]) => { | ||
| 108 | 151 | return n === 0 ? false : n === rows.length ? true : 'indeterminate' as const |
| 109 | 152 | } |
| 110 | 153 | |
| 154 | +const stateOf = (s: MarketplaceSkill) => | |
| 155 | + s.inCatalog === 'same' | |
| 156 | + ? { tone: 'ok' as const, label: 'In catalog' } | |
| 157 | + : s.inCatalog === 'different' | |
| 158 | + ? { tone: 'accent' as const, label: 'In catalog · different' } | |
| 159 | + : null | |
| 160 | + | |
| 111 | 161 | /* ------------------------------------------------ Import */ |
| 112 | 162 | |
| 113 | -const differing = computed(() => skills.value.filter(s => picked.value.has(s.path) && s.inCatalog === 'different')) | |
| 163 | +const toImport = ref<string[]>([]) | |
| 164 | +const differing = computed(() => skills.value.filter(s => toImport.value.includes(s.path) && s.inCatalog === 'different')) | |
| 114 | 165 | const confirmOpen = ref(false) |
| 115 | 166 | const importing = ref(false) |
| 116 | 167 | |
| 117 | -function startImport() { | |
| 118 | - if (differing.value.length) confirmOpen.value = true | |
| 168 | +function startImport(paths: string[]) { | |
| 169 | + toImport.value = paths | |
| 170 | + if (skills.value.some(s => paths.includes(s.path) && s.inCatalog === 'different')) confirmOpen.value = true | |
| 119 | 171 | else doImport(false) |
| 120 | 172 | } |
| 121 | 173 | |
| @@ -125,17 +177,19 @@ async function doImport(overwrite: boolean) { | ||
| 125 | 177 | try { |
| 126 | 178 | const res = await request<ImportResult>(`/api/marketplaces/${encodeURIComponent(selected.value.id)}/import`, { |
| 127 | 179 | method: 'POST', |
| 128 | - body: { paths: [...picked.value], overwrite }, | |
| 180 | + body: { paths: toImport.value, overwrite }, | |
| 129 | 181 | silent: true |
| 130 | 182 | }) |
| 131 | 183 | confirmOpen.value = false |
| 184 | + previewOpen.value = false | |
| 132 | 185 | toast.add({ |
| 133 | - title: `${res.imported.length} imported`, | |
| 134 | - description: res.skipped.length ? `${res.skipped.length} skipped: ${res.skipped.map(s => `${s.id} (${s.reason})`).join('; ')}` : 'Added to the catalog.', | |
| 186 | + title: `${res.imported.length} skill${res.imported.length === 1 ? '' : 's'} imported`, | |
| 187 | + description: res.skipped.length ? `${res.skipped.length} skipped: ${res.skipped.map(s => `${s.id} (${s.reason})`).join('; ')}` : 'Added to the catalog. Commit them from the Sync page.', | |
| 135 | 188 | icon: 'i-lucide-check', |
| 136 | 189 | color: res.skipped.length ? 'warning' : 'success' |
| 137 | 190 | }) |
| 138 | - picked.value = new Set() | |
| 191 | + const done = new Set(toImport.value) | |
| 192 | + picked.value = new Set([...picked.value].filter(p => !done.has(p) || res.skipped.some(s => s.id === p))) | |
| 139 | 193 | await Promise.all([loadSkills(), catalog.refresh()]) |
| 140 | 194 | } catch (e) { |
| 141 | 195 | toast.add({ title: 'Import failed', description: (e as Error).message, color: 'error', icon: 'i-lucide-circle-alert' }) |
| @@ -150,10 +204,6 @@ const addOpen = ref(false) | ||
| 150 | 204 | const adding = ref(false) |
| 151 | 205 | const addError = ref<string | null>(null) |
| 152 | 206 | const addForm = reactive({ name: '', kind: 'git' as MarketplaceSource['kind'], url: '', ref: '' }) |
| 153 | -const KINDS = [ | |
| 154 | - { label: 'Git repository (skill folders)', value: 'git' }, | |
| 155 | - { label: 'Claude plugin marketplace', value: 'claude-marketplace' } | |
| 156 | -] | |
| 157 | 207 | |
| 158 | 208 | async function addSource() { |
| 159 | 209 | adding.value = true |
| @@ -166,7 +216,7 @@ async function addSource() { | ||
| 166 | 216 | }) |
| 167 | 217 | addOpen.value = false |
| 168 | 218 | Object.assign(addForm, { name: '', kind: 'git', url: '', ref: '' }) |
| 169 | - await loadSources() | |
| 219 | + sources.value = await request<MarketplaceSource[]>('/api/marketplaces', { silent: true }) | |
| 170 | 220 | await select(created.id) |
| 171 | 221 | } catch (e) { |
| 172 | 222 | addError.value = (e as Error).message |
| @@ -182,205 +232,263 @@ async function removeSource() { | ||
| 182 | 232 | try { |
| 183 | 233 | await request(`/api/marketplaces/${encodeURIComponent(s.id)}`, { method: 'DELETE' }) |
| 184 | 234 | removeTarget.value = null |
| 185 | - if (selectedId.value === s.id) { selectedId.value = null; skills.value = [] } | |
| 186 | - await loadSources() | |
| 235 | + const wasSelected = selectedId.value === s.id | |
| 236 | + sources.value = await request<MarketplaceSource[]>('/api/marketplaces', { silent: true }) | |
| 237 | + if (wasSelected) { | |
| 238 | + selectedId.value = null | |
| 239 | + skills.value = [] | |
| 240 | + if (sources.value[0]) await select(sources.value[0].id) | |
| 241 | + } | |
| 187 | 242 | } catch { /* toast shown */ } |
| 188 | 243 | } |
| 189 | 244 | |
| 190 | 245 | /* ------------------------------------------------ Preview */ |
| 191 | 246 | |
| 192 | 247 | const previewOpen = ref(false) |
| 248 | +const previewSkill = ref<MarketplaceSkill | null>(null) | |
| 193 | 249 | const preview = ref<MarketplaceSkillDetail | null>(null) |
| 194 | 250 | const previewLoading = ref(false) |
| 195 | 251 | const previewError = ref<string | null>(null) |
| 196 | -const previewHtml = computed(() => (preview.value ? renderMarkdown(splitFrontmatter(preview.value.content).body) : '')) | |
| 197 | -const previewFm = computed(() => (preview.value ? splitFrontmatter(preview.value.content).frontmatter : null)) | |
| 252 | +const previewParts = computed(() => (preview.value ? splitFrontmatter(preview.value.content) : null)) | |
| 198 | 253 | |
| 199 | 254 | async function openPreview(s: MarketplaceSkill) { |
| 200 | 255 | previewOpen.value = true |
| 256 | + previewSkill.value = s | |
| 201 | 257 | preview.value = null |
| 202 | 258 | previewError.value = null |
| 203 | 259 | previewLoading.value = true |
| 204 | 260 | try { |
| 205 | - preview.value = await request<MarketplaceSkillDetail>(`/api/marketplaces/${encodeURIComponent(s.sourceId)}/skill`, { query: { path: s.path }, silent: true }) | |
| 261 | + const detail = await request<MarketplaceSkillDetail>(`/api/marketplaces/${encodeURIComponent(s.sourceId)}/skill`, { query: { path: s.path }, silent: true }) | |
| 262 | + if (previewSkill.value === s) preview.value = detail | |
| 206 | 263 | } catch (e) { |
| 207 | - previewError.value = (e as Error).message | |
| 264 | + if (previewSkill.value === s) previewError.value = (e as Error).message | |
| 208 | 265 | } finally { |
| 209 | - previewLoading.value = false | |
| 266 | + if (previewSkill.value === s) previewLoading.value = false | |
| 210 | 267 | } |
| 211 | 268 | } |
| 212 | 269 | |
| 213 | -const sinceFetched = (s: MarketplaceSource) => (s.lastFetchedAt ? `fetched ${formatDate(s.lastFetchedAt)}` : 'never fetched') | |
| 270 | +const KIND_LABEL = { git: 'Git repository', 'claude-marketplace': 'Claude marketplace' } as const | |
| 271 | +const sinceFetched = (s: MarketplaceSource) => (s.lastFetchedAt ? `fetched ${timeAgo(s.lastFetchedAt)}` : 'not fetched yet') | |
| 272 | +const plural = (n: number, w: string) => `${n} ${w}${n === 1 ? '' : 's'}` | |
| 214 | 273 | </script> |
| 215 | 274 | |
| 216 | 275 | <template> |
| 217 | - <UDashboardPanel id="marketplaces"> | |
| 218 | - <template #header> | |
| 219 | - <UDashboardNavbar title="Marketplaces" icon="i-lucide-store"> | |
| 220 | - <template #right> | |
| 221 | - <UButton label="Add source" icon="i-lucide-plus" color="neutral" variant="outline" @click="addOpen = true" /> | |
| 222 | - </template> | |
| 223 | - </UDashboardNavbar> | |
| 224 | - </template> | |
| 225 | - | |
| 226 | - <template #body> | |
| 227 | - <USkeleton v-if="sourcesLoading && !sources.length" class="h-64 w-full" /> | |
| 228 | - <UAlert v-else-if="sourcesError" color="error" variant="subtle" icon="i-lucide-circle-alert" title="Could not load marketplaces" :description="sourcesError" :actions="[{ label: 'Retry', color: 'neutral', variant: 'outline', onClick: loadSources }]" /> | |
| 229 | - | |
| 230 | - <div v-else class="grid items-start gap-6 lg:grid-cols-[16rem_minmax(0,1fr)]"> | |
| 231 | - <!-- Sources --> | |
| 232 | - <aside class="space-y-1"> | |
| 233 | - <div | |
| 276 | + <SkPage wide> | |
| 277 | + <SkPageHeader title="Marketplaces" subtitle="Skills from git repositories and Claude plugin marketplaces. Import them into your catalog." /> | |
| 278 | + | |
| 279 | + <SkPanel v-if="sourcesLoading && !sources.length" padded><USkeleton class="h-64 w-full" /></SkPanel> | |
| 280 | + <SkNote v-else-if="sourcesError" tone="accent"> | |
| 281 | + <div class="flex items-center gap-3"> | |
| 282 | + <span class="flex-1">Could not load the sources: {{ sourcesError }}</span> | |
| 283 | + <SkButton size="xs" label="Retry" @click="loadSources" /> | |
| 284 | + </div> | |
| 285 | + </SkNote> | |
| 286 | + | |
| 287 | + <div v-else class="grid items-start gap-4 lg:grid-cols-[280px_minmax(0,1fr)]"> | |
| 288 | + <!-- Sources --> | |
| 289 | + <aside class="flex min-w-0 flex-col gap-2.5 lg:sticky lg:top-[72px]"> | |
| 290 | + <SkPanel> | |
| 291 | + <SkRow | |
| 234 | 292 | v-for="s in sources" |
| 235 | 293 | :key="s.id" |
| 236 | - class="group flex cursor-pointer items-start gap-2 rounded-lg border px-3 py-2 transition-colors" | |
| 237 | - :class="s.id === selectedId ? 'border-primary bg-primary/5' : 'border-default hover:bg-elevated/50'" | |
| 294 | + interactive | |
| 295 | + :selected="s.id === selectedId" | |
| 296 | + class="!gap-2 !py-2.5" | |
| 297 | + role="button" | |
| 238 | 298 | tabindex="0" |
| 299 | + :aria-current="s.id === selectedId ? 'true' : undefined" | |
| 239 | 300 | @click="select(s.id)" |
| 240 | 301 | @keydown.enter="select(s.id)" |
| 241 | 302 | > |
| 242 | - <UIcon :name="s.kind === 'git' ? 'i-lucide-git-fork' : 'i-lucide-puzzle'" class="mt-0.5 size-4 shrink-0 text-muted" /> | |
| 243 | 303 | <div class="min-w-0 flex-1"> |
| 244 | 304 | <div class="flex items-center gap-1.5"> |
| 245 | - <span class="truncate text-sm font-medium">{{ s.name }}</span> | |
| 246 | - <UBadge v-if="s.builtIn" label="built-in" size="sm" color="neutral" variant="subtle" /> | |
| 305 | + <span class="truncate font-medium">{{ s.name }}</span> | |
| 306 | + <SkTag v-if="s.builtIn">built-in</SkTag> | |
| 247 | 307 | </div> |
| 248 | - <p class="truncate text-xs text-muted" :title="s.url">{{ s.url }}</p> | |
| 308 | + <p class="m-0 truncate font-mono text-[11px] text-sk-faint" :title="s.url">{{ s.url }}</p> | |
| 249 | 309 | </div> |
| 250 | - <UButton v-if="!s.builtIn" icon="i-lucide-trash-2" size="xs" color="neutral" variant="ghost" aria-label="Remove source" @click.stop="removeTarget = s" /> | |
| 251 | - </div> | |
| 252 | - </aside> | |
| 253 | - | |
| 254 | - <!-- Selected source --> | |
| 255 | - <section v-if="selected" class="min-w-0 space-y-4"> | |
| 310 | + <UTooltip v-if="!s.builtIn" text="Remove source"> | |
| 311 | + <SkButton size="xs" variant="ghost" icon="i-lucide-trash-2" :aria-label="`Remove ${s.name}`" @click.stop="removeTarget = s" /> | |
| 312 | + </UTooltip> | |
| 313 | + </SkRow> | |
| 314 | + </SkPanel> | |
| 315 | + <SkButton label="Add source" icon="i-lucide-plus" @click="addOpen = true" /> | |
| 316 | + </aside> | |
| 317 | + | |
| 318 | + <!-- Selected source --> | |
| 319 | + <section v-if="selected" class="flex min-w-0 flex-col gap-3.5"> | |
| 320 | + <SkPanel padded class="flex flex-col gap-3.5"> | |
| 256 | 321 | <div class="flex flex-wrap items-center gap-3"> |
| 257 | - <div class="min-w-0"> | |
| 258 | - <h2 class="truncate font-semibold">{{ selected.name }}</h2> | |
| 259 | - <p class="text-xs text-muted">{{ selected.kind === 'git' ? 'Git repository' : 'Claude plugin marketplace' }}<span v-if="selected.ref"> - {{ selected.ref }}</span> - {{ sinceFetched(selected) }}</p> | |
| 322 | + <div class="min-w-0 flex-1"> | |
| 323 | + <h2 class="m-0 truncate text-[18px] leading-tight font-semibold tracking-[-0.01em]">{{ selected.name }}</h2> | |
| 324 | + <p class="m-0 mt-0.5 text-sk-muted"> | |
| 325 | + {{ KIND_LABEL[selected.kind] }}<template v-if="selected.ref"> · <span class="font-mono text-xs">{{ selected.ref }}</span></template> · {{ sinceFetched(selected) }} | |
| 326 | + </p> | |
| 260 | 327 | </div> |
| 261 | - <UButton class="ml-auto" label="Refresh" icon="i-lucide-refresh-cw" color="neutral" variant="outline" :loading="refreshing" @click="refreshSource" /> | |
| 262 | - </div> | |
| 263 | - <UAlert v-if="refreshing" color="info" variant="subtle" icon="i-lucide-loader" title="Fetching the repository" description="Cloning or updating can take tens of seconds for large repositories." /> | |
| 264 | - | |
| 265 | - <div class="flex flex-wrap items-center gap-3"> | |
| 266 | - <UInput v-model="search" icon="i-lucide-search" placeholder="Search skills..." class="w-72" /> | |
| 267 | - <span class="text-xs text-muted">{{ filtered.length }} skill{{ filtered.length === 1 ? '' : 's' }}</span> | |
| 268 | - <UButton class="ml-auto" :label="`Import ${picked.size || ''}`.trim()" icon="i-lucide-download" :disabled="!picked.size" :loading="importing" @click="startImport" /> | |
| 328 | + <SkButton icon="i-lucide-refresh-cw" label="Refresh" :loading="refreshing" :disabled="skillsLoading" @click="refreshSource" /> | |
| 269 | 329 | </div> |
| 270 | 330 | |
| 271 | - <div v-if="skillsLoading" class="space-y-2"> | |
| 272 | - <UAlert color="info" variant="subtle" icon="i-lucide-loader" title="Loading skills" description="The first load clones the repository; this can take a while." /> | |
| 273 | - <USkeleton v-for="i in 4" :key="i" class="h-12 w-full" /> | |
| 274 | - </div> | |
| 275 | - <UAlert v-else-if="skillsError" color="error" variant="subtle" icon="i-lucide-circle-alert" title="Could not load the skills" :description="skillsError" :actions="[{ label: 'Refresh source', color: 'neutral', variant: 'outline', onClick: refreshSource }]" /> | |
| 276 | - <div v-else-if="!filtered.length" class="flex flex-col items-center gap-2 rounded-xl border border-dashed border-default py-12 text-center"> | |
| 277 | - <UIcon :name="search ? 'i-lucide-search-x' : 'i-lucide-store'" class="size-8 text-dimmed" /> | |
| 278 | - <p class="text-sm text-muted">{{ search ? 'No skill matches the search.' : 'No skill found. Try Refresh.' }}</p> | |
| 331 | + <SkNote v-if="fetching" tone="info"> | |
| 332 | + <p class="m-0 font-medium">{{ refreshing ? 'Fetching the repository…' : 'Loading the skills…' }} <span class="font-mono text-xs text-sk-faint">{{ elapsed }}s</span></p> | |
| 333 | + <p class="m-0 text-xs text-sk-muted">The first load clones the repository, which can take a minute for large ones. You can keep using skilly meanwhile.</p> | |
| 334 | + </SkNote> | |
| 335 | + | |
| 336 | + <div class="flex flex-wrap items-center gap-2.5"> | |
| 337 | + <UInput v-model="search" icon="i-lucide-search" placeholder="Search name, plugin or description" aria-label="Search skills" class="min-w-0 flex-1 basis-64" /> | |
| 338 | + <SkSeg | |
| 339 | + v-model="onlyNew" | |
| 340 | + size="md" | |
| 341 | + aria-label="Filter" | |
| 342 | + :options="[{ value: 'all', label: 'All', count: skills.length }, { value: 'new', label: 'Not in catalog', count: skills.length - inCatalogCount }]" | |
| 343 | + /> | |
| 279 | 344 | </div> |
| 345 | + </SkPanel> | |
| 280 | 346 | |
| 281 | - <div v-else class="space-y-3"> | |
| 282 | - <div v-for="g in groups" :key="g.plugin ?? 'all'" class="overflow-hidden rounded-xl border border-default"> | |
| 283 | - <div v-if="g.plugin" class="flex items-center gap-2 border-b border-default bg-elevated/40 px-3 py-1.5 text-xs font-medium text-muted"> | |
| 284 | - <UCheckbox :model-value="groupState(g.rows)" :aria-label="`Select plugin ${g.plugin}`" @update:model-value="(v) => toggleGroup(g.rows, v)" /> | |
| 285 | - <UIcon name="i-lucide-puzzle" class="size-3.5" />{{ g.plugin }} ({{ g.rows.length }}) | |
| 286 | - </div> | |
| 287 | - <ul class="divide-y divide-default"> | |
| 288 | - <li v-for="s in g.rows" :key="s.path" class="flex items-start gap-3 px-3 py-2 hover:bg-elevated/40"> | |
| 289 | - <UCheckbox class="mt-0.5" :model-value="picked.has(s.path)" :aria-label="`Select ${s.name}`" @update:model-value="(v) => toggle(s.path, v)" /> | |
| 290 | - <button type="button" class="min-w-0 flex-1 text-left" @click="openPreview(s)"> | |
| 291 | - <div class="flex items-center gap-2"> | |
| 292 | - <span class="truncate text-sm font-medium text-highlighted">{{ s.name }}</span> | |
| 293 | - <UBadge v-if="s.inCatalog === 'same'" label="In catalog" icon="i-lucide-check" size="sm" color="success" variant="subtle" /> | |
| 294 | - <UBadge v-else-if="s.inCatalog === 'different'" label="In catalog (different)" icon="i-lucide-git-compare" size="sm" color="warning" variant="subtle" /> | |
| 295 | - </div> | |
| 296 | - <p v-if="s.description" class="line-clamp-2 text-xs text-muted">{{ s.description }}</p> | |
| 297 | - <p class="truncate font-mono text-[11px] text-dimmed">{{ s.path }}</p> | |
| 298 | - </button> | |
| 299 | - <UButton icon="i-lucide-eye" size="xs" color="neutral" variant="ghost" aria-label="Preview" @click="openPreview(s)" /> | |
| 300 | - </li> | |
| 301 | - </ul> | |
| 302 | - </div> | |
| 303 | - <div v-if="filtered.length > shown.length" class="text-center"> | |
| 304 | - <UButton :label="`Show all ${filtered.length}`" size="xs" color="neutral" variant="link" @click="showAll = true" /> | |
| 347 | + <SkNote v-if="skillsError && !skillsLoading" tone="accent"> | |
| 348 | + <div class="flex items-start gap-3"> | |
| 349 | + <div class="min-w-0 flex-1"> | |
| 350 | + <p class="m-0 font-semibold">Could not load the skills</p> | |
| 351 | + <p class="m-0 font-mono text-xs break-words whitespace-pre-wrap">{{ skillsError }}</p> | |
| 305 | 352 | </div> |
| 353 | + <SkButton size="xs" label="Refresh source" :disabled="refreshing" @click="refreshSource" /> | |
| 306 | 354 | </div> |
| 307 | - </section> | |
| 308 | - </div> | |
| 355 | + </SkNote> | |
| 309 | 356 | |
| 310 | - <!-- Preview --> | |
| 311 | - <USlideover v-model:open="previewOpen" :title="preview?.name ?? 'Preview'" :description="preview?.description" :ui="{ content: 'sm:max-w-2xl', description: 'line-clamp-3' }"> | |
| 312 | - <template #body> | |
| 313 | - <div v-if="previewLoading" class="space-y-2"><USkeleton class="h-4 w-1/3" /><USkeleton class="h-4 w-full" /><USkeleton class="h-4 w-5/6" /></div> | |
| 314 | - <UAlert v-else-if="previewError" color="error" variant="subtle" icon="i-lucide-circle-alert" title="Could not load the preview" :description="previewError" /> | |
| 315 | - <div v-else-if="preview" class="space-y-4"> | |
| 316 | - <div v-if="previewFm" class="overflow-hidden rounded-lg border border-default"> | |
| 317 | - <table class="w-full text-xs"> | |
| 318 | - <tbody class="divide-y divide-default"> | |
| 319 | - <tr v-for="(v, k) in previewFm" :key="k"> | |
| 320 | - <th class="w-1/4 bg-elevated/40 px-2 py-1.5 text-left align-top font-medium text-muted">{{ k }}</th> | |
| 321 | - <td class="px-2 py-1.5 break-words">{{ v }}</td> | |
| 322 | - </tr> | |
| 323 | - </tbody> | |
| 324 | - </table> | |
| 325 | - </div> | |
| 326 | - <!-- eslint-disable-next-line vue/no-v-html --> | |
| 327 | - <div class="md-body" v-html="previewHtml" /> | |
| 328 | - <div v-if="preview.files.length" class="space-y-1"> | |
| 329 | - <h3 class="text-sm font-semibold">Files ({{ preview.files.length }})</h3> | |
| 330 | - <ul class="max-h-48 overflow-auto rounded-lg border border-default text-xs"> | |
| 331 | - <li v-for="f in preview.files" :key="f" class="flex items-center gap-2 px-3 py-1.5 font-mono"><UIcon name="i-lucide-file" class="size-3.5 text-muted" />{{ f }}</li> | |
| 332 | - </ul> | |
| 357 | + <SkPanel v-else-if="skillsLoading && !skills.length" padded class="flex flex-col gap-2"> | |
| 358 | + <USkeleton v-for="i in 5" :key="i" class="h-11 w-full" /> | |
| 359 | + </SkPanel> | |
| 360 | + | |
| 361 | + <SkPanel v-else-if="!filtered.length"> | |
| 362 | + <SkEmpty :text="search || onlyNew === 'new' ? 'Nothing matches these filters.' : 'No skill found in this source. Try Refresh.'" /> | |
| 363 | + </SkPanel> | |
| 364 | + | |
| 365 | + <SkTray v-else> | |
| 366 | + <template v-for="g in groups" :key="g.plugin ?? 'all'"> | |
| 367 | + <div v-if="g.plugin" class="flex items-center gap-2 px-2.5 pt-2 pb-1"> | |
| 368 | + <UCheckbox :model-value="groupState(g.rows)" :aria-label="`Select every skill of ${g.plugin}`" @update:model-value="(v: boolean | 'indeterminate') => toggleGroup(g.rows, v)" /> | |
| 369 | + <SkSectionLabel :count="g.rows.length">{{ g.plugin }}</SkSectionLabel> | |
| 333 | 370 | </div> |
| 371 | + <SkRowCard | |
| 372 | + v-for="s in g.rows" | |
| 373 | + :key="s.path" | |
| 374 | + cols="16px minmax(0,1fr) auto" | |
| 375 | + interactive | |
| 376 | + role="button" | |
| 377 | + tabindex="0" | |
| 378 | + @click="openPreview(s)" | |
| 379 | + @keydown.enter.self="openPreview(s)" | |
| 380 | + > | |
| 381 | + <span class="flex" @click.stop> | |
| 382 | + <UCheckbox :model-value="picked.has(s.path)" :aria-label="`Select ${s.name}`" @update:model-value="(v: boolean | 'indeterminate') => toggle(s.path, v)" /> | |
| 383 | + </span> | |
| 384 | + <div class="min-w-0"> | |
| 385 | + <span class="block truncate font-medium">{{ s.name }}</span> | |
| 386 | + <p v-if="s.description" class="m-0 line-clamp-2 text-xs text-pretty text-sk-muted">{{ s.description }}</p> | |
| 387 | + <p class="m-0 truncate font-mono text-[11px] text-sk-faint" :title="s.path">{{ s.path }}</p> | |
| 388 | + </div> | |
| 389 | + <SkStatus v-if="stateOf(s)" :tone="stateOf(s)!.tone" :label="stateOf(s)!.label" muted /> | |
| 390 | + </SkRowCard> | |
| 391 | + </template> | |
| 392 | + <div v-if="filtered.length > shown.length" class="py-2 text-center"> | |
| 393 | + <SkButton size="sm" variant="ghost" :label="`Show all ${filtered.length}`" @click="showAll = true" /> | |
| 334 | 394 | </div> |
| 335 | - </template> | |
| 336 | - </USlideover> | |
| 337 | - | |
| 338 | - <!-- Overwrite confirmation --> | |
| 339 | - <UModal v-model:open="confirmOpen" title="Overwrite catalog items?" description="Some selected skills already exist in the catalog with different content."> | |
| 340 | - <template #body> | |
| 341 | - <ul class="list-disc space-y-0.5 pl-4 text-sm"> | |
| 342 | - <li v-for="s in differing" :key="s.path">{{ s.name }}</li> | |
| 343 | - </ul> | |
| 344 | - <p class="mt-3 text-sm text-muted">Overwriting replaces the catalog version with the marketplace version. Local edits in the catalog are lost (git history keeps them).</p> | |
| 345 | - </template> | |
| 346 | - <template #footer> | |
| 347 | - <div class="flex w-full justify-end gap-2"> | |
| 348 | - <UButton label="Cancel" color="neutral" variant="outline" @click="confirmOpen = false" /> | |
| 349 | - <UButton label="Overwrite and import" color="warning" :loading="importing" @click="doImport(true)" /> | |
| 350 | - </div> | |
| 351 | - </template> | |
| 352 | - </UModal> | |
| 353 | - | |
| 354 | - <!-- Add source --> | |
| 355 | - <UModal v-model:open="addOpen" title="Add a source" description="A git repository containing skills, or a Claude Code plugin marketplace."> | |
| 356 | - <template #body> | |
| 357 | - <form id="add-source" class="space-y-3" @submit.prevent="addSource"> | |
| 358 | - <UAlert v-if="addError" color="error" variant="subtle" icon="i-lucide-circle-alert" :title="addError" /> | |
| 359 | - <UFormField label="Name" required><UInput v-model="addForm.name" class="w-full" placeholder="My skills" /></UFormField> | |
| 360 | - <UFormField label="Kind"><USelect v-model="addForm.kind" :items="KINDS" class="w-full" /></UFormField> | |
| 361 | - <UFormField label="URL" required><UInput v-model="addForm.url" class="w-full" placeholder="https://github.com/owner/repo.git" :ui="{ base: 'font-mono' }" /></UFormField> | |
| 362 | - <UFormField label="Ref" description="Branch or tag; default branch when empty."><UInput v-model="addForm.ref" class="w-full" placeholder="main" :ui="{ base: 'font-mono' }" /></UFormField> | |
| 363 | - </form> | |
| 364 | - </template> | |
| 365 | - <template #footer> | |
| 366 | - <div class="flex w-full justify-end gap-2"> | |
| 367 | - <UButton label="Cancel" color="neutral" variant="outline" @click="addOpen = false" /> | |
| 368 | - <UButton type="submit" form="add-source" label="Add source" :loading="adding" :disabled="!addForm.name.trim() || !addForm.url.trim()" /> | |
| 395 | + </SkTray> | |
| 396 | + </section> | |
| 397 | + <SkPanel v-else padded><SkEmpty text="Select a source to browse its skills." /></SkPanel> | |
| 398 | + </div> | |
| 399 | + | |
| 400 | + <SkActionBar v-if="picked.size" :changes="picked.size" noun="selected" label="Import to catalog" :busy="importing" @review="startImport([...picked])" /> | |
| 401 | + | |
| 402 | + <!-- Preview --> | |
| 403 | + <SkDrawer v-model:open="previewOpen" width="lg"> | |
| 404 | + <template #header> | |
| 405 | + <div class="flex min-w-0 flex-1 flex-col gap-1"> | |
| 406 | + <div class="flex items-baseline gap-2"> | |
| 407 | + <h2 class="m-0 truncate text-[18px] leading-tight font-semibold tracking-[-0.01em]">{{ preview?.name ?? previewSkill?.name ?? 'Preview' }}</h2> | |
| 408 | + <span class="font-mono text-[11px] text-sk-faint">Skill</span> | |
| 369 | 409 | </div> |
| 370 | - </template> | |
| 371 | - </UModal> | |
| 372 | - | |
| 373 | - <UModal :open="!!removeTarget" title="Remove source" description="Imported catalog items are kept." @update:open="(v: boolean) => { if (!v) removeTarget = null }"> | |
| 374 | - <template #body> | |
| 375 | - <p class="text-sm">Remove <strong>{{ removeTarget?.name }}</strong> from the list of sources?</p> | |
| 376 | - </template> | |
| 377 | - <template #footer> | |
| 378 | - <div class="flex w-full justify-end gap-2"> | |
| 379 | - <UButton label="Cancel" color="neutral" variant="outline" @click="removeTarget = null" /> | |
| 380 | - <UButton label="Remove" color="error" @click="removeSource" /> | |
| 410 | + <p v-if="preview?.description ?? previewSkill?.description" class="m-0 text-pretty text-sk-muted">{{ preview?.description ?? previewSkill?.description }}</p> | |
| 411 | + </div> | |
| 412 | + </template> | |
| 413 | + | |
| 414 | + <div v-if="previewLoading" class="flex flex-col gap-2"><USkeleton class="h-4 w-1/3" /><USkeleton class="h-4 w-full" /><USkeleton class="h-4 w-5/6" /></div> | |
| 415 | + <SkNote v-else-if="previewError" tone="accent">Could not load the preview: {{ previewError }}</SkNote> | |
| 416 | + <div v-else-if="preview" class="flex flex-col gap-4"> | |
| 417 | + <SkMetaGrid label-width="100px"> | |
| 418 | + <SkMeta label="Source">{{ selected?.name }}</SkMeta> | |
| 419 | + <SkMeta v-if="previewSkill?.plugin" label="Plugin" mono>{{ previewSkill.plugin }}</SkMeta> | |
| 420 | + <SkMeta label="Path" mono>{{ preview.path }}</SkMeta> | |
| 421 | + <SkMeta v-if="previewSkill && stateOf(previewSkill)" label="Catalog"><SkStatus :tone="stateOf(previewSkill)!.tone" :label="stateOf(previewSkill)!.label" /></SkMeta> | |
| 422 | + <SkMeta v-for="(v, k) in previewParts?.frontmatter ?? {}" :key="k" :label="String(k)">{{ typeof v === 'string' ? v : JSON.stringify(v) }}</SkMeta> | |
| 423 | + </SkMetaGrid> | |
| 424 | + <MarkdownBody :source="previewParts?.body ?? ''" /> | |
| 425 | + <div v-if="preview.files.length" class="flex flex-col gap-2"> | |
| 426 | + <SkSectionLabel :count="preview.files.length">Files</SkSectionLabel> | |
| 427 | + <SkTray class="max-h-52 overflow-auto"> | |
| 428 | + <SkRowCard v-for="f in preview.files" :key="f" class="truncate font-mono text-xs">{{ f }}</SkRowCard> | |
| 429 | + </SkTray> | |
| 430 | + </div> | |
| 431 | + </div> | |
| 432 | + | |
| 433 | + <template #footer> | |
| 434 | + <span class="mr-auto text-xs text-sk-faint">Imports the whole skill folder.</span> | |
| 435 | + <SkButton label="Close" @click="previewOpen = false" /> | |
| 436 | + <SkButton variant="primary" label="Import to catalog" :loading="importing" :disabled="!previewSkill || !!previewError" @click="previewSkill && startImport([previewSkill.path])" /> | |
| 437 | + </template> | |
| 438 | + </SkDrawer> | |
| 439 | + | |
| 440 | + <!-- Overwrite confirmation --> | |
| 441 | + <SkModal v-model:open="confirmOpen" :title="`Overwrite ${plural(differing.length, 'catalog skill')}?`" width="sm"> | |
| 442 | + <div class="flex flex-col gap-3.5"> | |
| 443 | + <div class="flex items-start gap-2.5"> | |
| 444 | + <SkStepBadge>1</SkStepBadge> | |
| 445 | + <div class="min-w-0"> | |
| 446 | + <p class="m-0 font-medium">These skills already exist with different content</p> | |
| 447 | + <ul class="m-0 mt-1 list-none p-0 font-mono text-xs text-sk-muted"> | |
| 448 | + <li v-for="s in differing" :key="s.path" class="truncate">{{ s.name }}</li> | |
| 449 | + </ul> | |
| 381 | 450 | </div> |
| 382 | - </template> | |
| 383 | - </UModal> | |
| 384 | - </template> | |
| 385 | - </UDashboardPanel> | |
| 451 | + </div> | |
| 452 | + <div class="flex items-start gap-2.5"> | |
| 453 | + <SkStepBadge>2</SkStepBadge> | |
| 454 | + <p class="m-0 text-pretty text-sk-muted">The catalog version is replaced by the marketplace version. Local edits are lost from the working folder; git history keeps them. Projects using the skill will show an update.</p> | |
| 455 | + </div> | |
| 456 | + </div> | |
| 457 | + <template #footer> | |
| 458 | + <SkButton label="Cancel" @click="confirmOpen = false" /> | |
| 459 | + <SkButton variant="primary" label="Overwrite and import" :loading="importing" @click="doImport(true)" /> | |
| 460 | + </template> | |
| 461 | + </SkModal> | |
| 462 | + | |
| 463 | + <!-- Add source --> | |
| 464 | + <SkModal v-model:open="addOpen" title="Add a source" description="A git repository containing skills, or a Claude Code plugin marketplace." width="sm"> | |
| 465 | + <form id="add-source" class="flex flex-col gap-3.5" @submit.prevent="addSource"> | |
| 466 | + <SkNote v-if="addError" tone="accent">{{ addError }}</SkNote> | |
| 467 | + <label class="flex flex-col gap-1.5"><SkSectionLabel>Name</SkSectionLabel><UInput v-model="addForm.name" placeholder="My skills" /></label> | |
| 468 | + <div class="flex flex-col gap-1.5"> | |
| 469 | + <SkSectionLabel>Kind</SkSectionLabel> | |
| 470 | + <SkSeg v-model="addForm.kind" size="md" aria-label="Kind" :options="[{ value: 'git', label: 'Git repository' }, { value: 'claude-marketplace', label: 'Claude marketplace' }]" /> | |
| 471 | + </div> | |
| 472 | + <label class="flex flex-col gap-1.5"><SkSectionLabel>URL</SkSectionLabel><UInput v-model="addForm.url" placeholder="https://github.com/owner/repo.git" :ui="{ base: 'font-mono' }" /></label> | |
| 473 | + <label class="flex flex-col gap-1.5"> | |
| 474 | + <SkSectionLabel>Ref</SkSectionLabel> | |
| 475 | + <UInput v-model="addForm.ref" placeholder="main" :ui="{ base: 'font-mono' }" /> | |
| 476 | + <span class="text-xs text-sk-faint">Branch or tag. The default branch when empty.</span> | |
| 477 | + </label> | |
| 478 | + </form> | |
| 479 | + <template #footer> | |
| 480 | + <SkButton label="Cancel" @click="addOpen = false" /> | |
| 481 | + <SkButton type="submit" form="add-source" variant="primary" label="Add source" :loading="adding" :disabled="!addForm.name.trim() || !addForm.url.trim()" /> | |
| 482 | + </template> | |
| 483 | + </SkModal> | |
| 484 | + | |
| 485 | + <!-- Remove source --> | |
| 486 | + <SkModal :open="!!removeTarget" title="Remove source" width="sm" @update:open="(v: boolean) => { if (!v) removeTarget = null }"> | |
| 487 | + <p class="m-0 text-pretty">Remove <strong>{{ removeTarget?.name }}</strong> from your sources? Skills already imported stay in the catalog.</p> | |
| 488 | + <template #footer> | |
| 489 | + <SkButton label="Cancel" @click="removeTarget = null" /> | |
| 490 | + <SkButton variant="primary" label="Remove source" @click="removeSource" /> | |
| 491 | + </template> | |
| 492 | + </SkModal> | |
| 493 | + </SkPage> | |
| 386 | 494 | </template> |
| @@ -1,9 +1,15 @@ | |||
| 1 | <script setup lang="ts"> | 1 | <script setup lang="ts"> |
| 2 | import type { ImportResult, MarketplaceSkill, MarketplaceSkillDetail, MarketplaceSource } from '@skilly/core' | 2 | import type { ImportResult, MarketplaceSkill, MarketplaceSkillDetail, MarketplaceSource } from '@skilly/core' |
| 3 | 3 | ||
| 4 | +/** | ||
| 5 | + * Marketplaces (brief 3.1): two panes like the Catalog. Left: sources (built-in + yours). Right: the skills of the | ||
| 6 | + * selected source, grouped by plugin for Claude marketplaces, with multi-select import and a preview drawer. | ||
| 7 | + */ | ||
| 4 | const { request } = useApi() | 8 | const { request } = useApi() |
| 5 | const catalog = useCatalog() | 9 | const catalog = useCatalog() |
| 6 | const toast = useToast() | 10 | const toast = useToast() |
| 11 | +const route = useRoute() | ||
| 12 | +const router = useRouter() | ||
| 7 | 13 | ||
| 8 | const sources = ref<MarketplaceSource[]>([]) | 14 | const sources = ref<MarketplaceSource[]>([]) |
| 9 | const sourcesLoading = ref(true) | 15 | const sourcesLoading = ref(true) |
| @@ -16,16 +22,30 @@ const skillsLoading = ref(false) | |||
| 16 | const skillsError = ref<string | null>(null) | 22 | const skillsError = ref<string | null>(null) |
| 17 | const refreshing = ref(false) | 23 | const refreshing = ref(false) |
| 18 | const search = ref('') | 24 | const search = ref('') |
| 25 | +const onlyNew = ref<'all' | 'new'>('all') | ||
| 19 | const picked = ref<Set<string>>(new Set()) | 26 | const picked = ref<Set<string>>(new Set()) |
| 20 | const LIMIT = 300 | 27 | const LIMIT = 300 |
| 21 | const showAll = ref(false) | 28 | const showAll = ref(false) |
| 22 | 29 | ||
| 30 | +/** Seconds since a fetch started, so a slow clone reads as "working" and not as "stuck". */ | ||
| 31 | +const elapsed = ref(0) | ||
| 32 | +let timer: ReturnType<typeof setInterval> | undefined | ||
| 33 | +const fetching = computed(() => refreshing.value || skillsLoading.value) | ||
| 34 | +watch(fetching, (on) => { | ||
| 35 | + clearInterval(timer) | ||
| 36 | + elapsed.value = 0 | ||
| 37 | + if (on) timer = setInterval(() => { elapsed.value++ }, 1000) | ||
| 38 | +}) | ||
| 39 | +onBeforeUnmount(() => clearInterval(timer)) | ||
| 40 | + | ||
| 23 | async function loadSources() { | 41 | async function loadSources() { |
| 24 | sourcesLoading.value = true | 42 | sourcesLoading.value = true |
| 25 | try { | 43 | try { |
| 26 | sources.value = await request<MarketplaceSource[]>('/api/marketplaces', { silent: true }) | 44 | sources.value = await request<MarketplaceSource[]>('/api/marketplaces', { silent: true }) |
| 27 | sourcesError.value = null | 45 | sourcesError.value = null |
| 28 | - if (!selectedId.value && sources.value.length) await select(sources.value[0]!.id) | 46 | + const wanted = typeof route.query.source === 'string' ? route.query.source : null |
| 47 | + const initial = sources.value.find(s => s.id === (selectedId.value ?? wanted)) ?? sources.value[0] | ||
| 48 | + if (initial && initial.id !== selectedId.value) await select(initial.id) | ||
| 29 | } catch (e) { | 49 | } catch (e) { |
| 30 | sourcesError.value = (e as Error).message | 50 | sourcesError.value = (e as Error).message |
| 31 | } finally { | 51 | } finally { |
| @@ -35,15 +55,19 @@ async function loadSources() { | |||
| 35 | 55 | ||
| 36 | async function loadSkills() { | 56 | async function loadSkills() { |
| 37 | if (!selectedId.value) return | 57 | if (!selectedId.value) return |
| 58 | + const id = selectedId.value | ||
| 38 | skillsLoading.value = true | 59 | skillsLoading.value = true |
| 39 | skillsError.value = null | 60 | skillsError.value = null |
| 40 | try { | 61 | try { |
| 41 | - skills.value = await request<MarketplaceSkill[]>(`/api/marketplaces/${encodeURIComponent(selectedId.value)}/skills`, { silent: true }) | 62 | + const list = await request<MarketplaceSkill[]>(`/api/marketplaces/${encodeURIComponent(id)}/skills`, { silent: true }) |
| 63 | + if (id === selectedId.value) skills.value = list | ||
| 42 | } catch (e) { | 64 | } catch (e) { |
| 43 | - skills.value = [] | 65 | + if (id === selectedId.value) { |
| 44 | - skillsError.value = (e as Error).message | 66 | + skills.value = [] |
| 67 | + skillsError.value = (e as Error).message | ||
| 68 | + } | ||
| 45 | } finally { | 69 | } finally { |
| 46 | - skillsLoading.value = false | 70 | + if (id === selectedId.value) skillsLoading.value = false |
| 47 | } | 71 | } |
| 48 | } | 72 | } |
| 49 | 73 | ||
| @@ -51,18 +75,33 @@ async function select(id: string) { | |||
| 51 | selectedId.value = id | 75 | selectedId.value = id |
| 52 | picked.value = new Set() | 76 | picked.value = new Set() |
| 53 | search.value = '' | 77 | search.value = '' |
| 78 | + onlyNew.value = 'all' | ||
| 54 | showAll.value = false | 79 | showAll.value = false |
| 80 | + skills.value = [] | ||
| 81 | + if (route.query.source !== id) router.replace({ query: { ...route.query, source: id } }) | ||
| 55 | await loadSkills() | 82 | await loadSkills() |
| 83 | + // The listing may have fetched the source for the first time: pick up its "fetched" date. | ||
| 84 | + if (!skillsError.value) await reloadSource() | ||
| 85 | +} | ||
| 86 | + | ||
| 87 | +async function reloadSource() { | ||
| 88 | + try { | ||
| 89 | + const list = await request<MarketplaceSource[]>('/api/marketplaces', { silent: true }) | ||
| 90 | + sources.value = list | ||
| 91 | + if (!list.some(s => s.id === selectedId.value)) selectedId.value = null | ||
| 92 | + } catch { /* the list on screen stays valid */ } | ||
| 56 | } | 93 | } |
| 57 | 94 | ||
| 58 | async function refreshSource() { | 95 | async function refreshSource() { |
| 59 | - if (!selected.value) return | 96 | + if (!selected.value || refreshing.value) return |
| 97 | + const id = selected.value.id | ||
| 60 | refreshing.value = true | 98 | refreshing.value = true |
| 61 | try { | 99 | try { |
| 62 | - const updated = await request<MarketplaceSource>(`/api/marketplaces/${encodeURIComponent(selected.value.id)}/refresh`, { method: 'POST', silent: true }) | 100 | + const updated = await request<MarketplaceSource>(`/api/marketplaces/${encodeURIComponent(id)}/refresh`, { method: 'POST', silent: true }) |
| 63 | sources.value = sources.value.map(s => (s.id === updated.id ? updated : s)) | 101 | sources.value = sources.value.map(s => (s.id === updated.id ? updated : s)) |
| 102 | + skillsLoading.value = true | ||
| 64 | await loadSkills() | 103 | await loadSkills() |
| 65 | - toast.add({ title: 'Source refreshed', description: updated.name, icon: 'i-lucide-check', color: 'success' }) | 104 | + toast.add({ title: 'Source refreshed', description: `${updated.name} · ${skills.value.length} skills`, icon: 'i-lucide-check', color: 'success' }) |
| 66 | } catch (e) { | 105 | } catch (e) { |
| 67 | toast.add({ title: 'Refresh failed', description: (e as Error).message, color: 'error', icon: 'i-lucide-circle-alert' }) | 106 | toast.add({ title: 'Refresh failed', description: (e as Error).message, color: 'error', icon: 'i-lucide-circle-alert' }) |
| 68 | } finally { | 107 | } finally { |
| @@ -79,7 +118,10 @@ onMounted(() => { | |||
| 79 | 118 | ||
| 80 | const filtered = computed(() => { | 119 | const filtered = computed(() => { |
| 81 | const q = search.value.trim().toLowerCase() | 120 | const q = search.value.trim().toLowerCase() |
| 82 | - return q ? skills.value.filter(s => `${s.name} ${s.description ?? ''} ${s.plugin ?? ''}`.toLowerCase().includes(q)) : skills.value | 121 | + return skills.value.filter(s => |
| 122 | + (onlyNew.value === 'all' || !s.inCatalog) | ||
| 123 | + && (!q || `${s.name} ${s.description ?? ''} ${s.plugin ?? ''}`.toLowerCase().includes(q)) | ||
| 124 | + ) | ||
| 83 | }) | 125 | }) |
| 84 | const shown = computed(() => (showAll.value ? filtered.value : filtered.value.slice(0, LIMIT))) | 126 | const shown = computed(() => (showAll.value ? filtered.value : filtered.value.slice(0, LIMIT))) |
| 85 | const groups = computed(() => { | 127 | const groups = computed(() => { |
| @@ -91,6 +133,7 @@ const groups = computed(() => { | |||
| 91 | } | 133 | } |
| 92 | return [...m.entries()].map(([plugin, rows]) => ({ plugin, rows })) | 134 | return [...m.entries()].map(([plugin, rows]) => ({ plugin, rows })) |
| 93 | }) | 135 | }) |
| 136 | +const inCatalogCount = computed(() => skills.value.filter(s => s.inCatalog).length) | ||
| 94 | 137 | ||
| 95 | function toggle(path: string, on: boolean | 'indeterminate') { | 138 | function toggle(path: string, on: boolean | 'indeterminate') { |
| 96 | const s = new Set(picked.value) | 139 | const s = new Set(picked.value) |
| @@ -108,14 +151,23 @@ const groupState = (rows: MarketplaceSkill[]) => { | |||
| 108 | return n === 0 ? false : n === rows.length ? true : 'indeterminate' as const | 151 | return n === 0 ? false : n === rows.length ? true : 'indeterminate' as const |
| 109 | } | 152 | } |
| 110 | 153 | ||
| 154 | +const stateOf = (s: MarketplaceSkill) => | ||
| 155 | + s.inCatalog === 'same' | ||
| 156 | + ? { tone: 'ok' as const, label: 'In catalog' } | ||
| 157 | + : s.inCatalog === 'different' | ||
| 158 | + ? { tone: 'accent' as const, label: 'In catalog · different' } | ||
| 159 | + : null | ||
| 160 | + | ||
| 111 | /* ------------------------------------------------ Import */ | 161 | /* ------------------------------------------------ Import */ |
| 112 | 162 | ||
| 113 | -const differing = computed(() => skills.value.filter(s => picked.value.has(s.path) && s.inCatalog === 'different')) | 163 | +const toImport = ref<string[]>([]) |
| 164 | +const differing = computed(() => skills.value.filter(s => toImport.value.includes(s.path) && s.inCatalog === 'different')) | ||
| 114 | const confirmOpen = ref(false) | 165 | const confirmOpen = ref(false) |
| 115 | const importing = ref(false) | 166 | const importing = ref(false) |
| 116 | 167 | ||
| 117 | -function startImport() { | 168 | +function startImport(paths: string[]) { |
| 118 | - if (differing.value.length) confirmOpen.value = true | 169 | + toImport.value = paths |
| 170 | + if (skills.value.some(s => paths.includes(s.path) && s.inCatalog === 'different')) confirmOpen.value = true | ||
| 119 | else doImport(false) | 171 | else doImport(false) |
| 120 | } | 172 | } |
| 121 | 173 | ||
| @@ -125,17 +177,19 @@ async function doImport(overwrite: boolean) { | |||
| 125 | try { | 177 | try { |
| 126 | const res = await request<ImportResult>(`/api/marketplaces/${encodeURIComponent(selected.value.id)}/import`, { | 178 | const res = await request<ImportResult>(`/api/marketplaces/${encodeURIComponent(selected.value.id)}/import`, { |
| 127 | method: 'POST', | 179 | method: 'POST', |
| 128 | - body: { paths: [...picked.value], overwrite }, | 180 | + body: { paths: toImport.value, overwrite }, |
| 129 | silent: true | 181 | silent: true |
| 130 | }) | 182 | }) |
| 131 | confirmOpen.value = false | 183 | confirmOpen.value = false |
| 184 | + previewOpen.value = false | ||
| 132 | toast.add({ | 185 | toast.add({ |
| 133 | - title: `${res.imported.length} imported`, | 186 | + title: `${res.imported.length} skill${res.imported.length === 1 ? '' : 's'} imported`, |
| 134 | - description: res.skipped.length ? `${res.skipped.length} skipped: ${res.skipped.map(s => `${s.id} (${s.reason})`).join('; ')}` : 'Added to the catalog.', | 187 | + description: res.skipped.length ? `${res.skipped.length} skipped: ${res.skipped.map(s => `${s.id} (${s.reason})`).join('; ')}` : 'Added to the catalog. Commit them from the Sync page.', |
| 135 | icon: 'i-lucide-check', | 188 | icon: 'i-lucide-check', |
| 136 | color: res.skipped.length ? 'warning' : 'success' | 189 | color: res.skipped.length ? 'warning' : 'success' |
| 137 | }) | 190 | }) |
| 138 | - picked.value = new Set() | 191 | + const done = new Set(toImport.value) |
| 192 | + picked.value = new Set([...picked.value].filter(p => !done.has(p) || res.skipped.some(s => s.id === p))) | ||
| 139 | await Promise.all([loadSkills(), catalog.refresh()]) | 193 | await Promise.all([loadSkills(), catalog.refresh()]) |
| 140 | } catch (e) { | 194 | } catch (e) { |
| 141 | toast.add({ title: 'Import failed', description: (e as Error).message, color: 'error', icon: 'i-lucide-circle-alert' }) | 195 | toast.add({ title: 'Import failed', description: (e as Error).message, color: 'error', icon: 'i-lucide-circle-alert' }) |
| @@ -150,10 +204,6 @@ const addOpen = ref(false) | |||
| 150 | const adding = ref(false) | 204 | const adding = ref(false) |
| 151 | const addError = ref<string | null>(null) | 205 | const addError = ref<string | null>(null) |
| 152 | const addForm = reactive({ name: '', kind: 'git' as MarketplaceSource['kind'], url: '', ref: '' }) | 206 | const addForm = reactive({ name: '', kind: 'git' as MarketplaceSource['kind'], url: '', ref: '' }) |
| 153 | -const KINDS = [ | ||
| 154 | - { label: 'Git repository (skill folders)', value: 'git' }, | ||
| 155 | - { label: 'Claude plugin marketplace', value: 'claude-marketplace' } | ||
| 156 | -] | ||
| 157 | 207 | ||
| 158 | async function addSource() { | 208 | async function addSource() { |
| 159 | adding.value = true | 209 | adding.value = true |
| @@ -166,7 +216,7 @@ async function addSource() { | |||
| 166 | }) | 216 | }) |
| 167 | addOpen.value = false | 217 | addOpen.value = false |
| 168 | Object.assign(addForm, { name: '', kind: 'git', url: '', ref: '' }) | 218 | Object.assign(addForm, { name: '', kind: 'git', url: '', ref: '' }) |
| 169 | - await loadSources() | 219 | + sources.value = await request<MarketplaceSource[]>('/api/marketplaces', { silent: true }) |
| 170 | await select(created.id) | 220 | await select(created.id) |
| 171 | } catch (e) { | 221 | } catch (e) { |
| 172 | addError.value = (e as Error).message | 222 | addError.value = (e as Error).message |
| @@ -182,205 +232,263 @@ async function removeSource() { | |||
| 182 | try { | 232 | try { |
| 183 | await request(`/api/marketplaces/${encodeURIComponent(s.id)}`, { method: 'DELETE' }) | 233 | await request(`/api/marketplaces/${encodeURIComponent(s.id)}`, { method: 'DELETE' }) |
| 184 | removeTarget.value = null | 234 | removeTarget.value = null |
| 185 | - if (selectedId.value === s.id) { selectedId.value = null; skills.value = [] } | 235 | + const wasSelected = selectedId.value === s.id |
| 186 | - await loadSources() | 236 | + sources.value = await request<MarketplaceSource[]>('/api/marketplaces', { silent: true }) |
| 237 | + if (wasSelected) { | ||
| 238 | + selectedId.value = null | ||
| 239 | + skills.value = [] | ||
| 240 | + if (sources.value[0]) await select(sources.value[0].id) | ||
| 241 | + } | ||
| 187 | } catch { /* toast shown */ } | 242 | } catch { /* toast shown */ } |
| 188 | } | 243 | } |
| 189 | 244 | ||
| 190 | /* ------------------------------------------------ Preview */ | 245 | /* ------------------------------------------------ Preview */ |
| 191 | 246 | ||
| 192 | const previewOpen = ref(false) | 247 | const previewOpen = ref(false) |
| 248 | +const previewSkill = ref<MarketplaceSkill | null>(null) | ||
| 193 | const preview = ref<MarketplaceSkillDetail | null>(null) | 249 | const preview = ref<MarketplaceSkillDetail | null>(null) |
| 194 | const previewLoading = ref(false) | 250 | const previewLoading = ref(false) |
| 195 | const previewError = ref<string | null>(null) | 251 | const previewError = ref<string | null>(null) |
| 196 | -const previewHtml = computed(() => (preview.value ? renderMarkdown(splitFrontmatter(preview.value.content).body) : '')) | 252 | +const previewParts = computed(() => (preview.value ? splitFrontmatter(preview.value.content) : null)) |
| 197 | -const previewFm = computed(() => (preview.value ? splitFrontmatter(preview.value.content).frontmatter : null)) | ||
| 198 | 253 | ||
| 199 | async function openPreview(s: MarketplaceSkill) { | 254 | async function openPreview(s: MarketplaceSkill) { |
| 200 | previewOpen.value = true | 255 | previewOpen.value = true |
| 256 | + previewSkill.value = s | ||
| 201 | preview.value = null | 257 | preview.value = null |
| 202 | previewError.value = null | 258 | previewError.value = null |
| 203 | previewLoading.value = true | 259 | previewLoading.value = true |
| 204 | try { | 260 | try { |
| 205 | - preview.value = await request<MarketplaceSkillDetail>(`/api/marketplaces/${encodeURIComponent(s.sourceId)}/skill`, { query: { path: s.path }, silent: true }) | 261 | + const detail = await request<MarketplaceSkillDetail>(`/api/marketplaces/${encodeURIComponent(s.sourceId)}/skill`, { query: { path: s.path }, silent: true }) |
| 262 | + if (previewSkill.value === s) preview.value = detail | ||
| 206 | } catch (e) { | 263 | } catch (e) { |
| 207 | - previewError.value = (e as Error).message | 264 | + if (previewSkill.value === s) previewError.value = (e as Error).message |
| 208 | } finally { | 265 | } finally { |
| 209 | - previewLoading.value = false | 266 | + if (previewSkill.value === s) previewLoading.value = false |
| 210 | } | 267 | } |
| 211 | } | 268 | } |
| 212 | 269 | ||
| 213 | -const sinceFetched = (s: MarketplaceSource) => (s.lastFetchedAt ? `fetched ${formatDate(s.lastFetchedAt)}` : 'never fetched') | 270 | +const KIND_LABEL = { git: 'Git repository', 'claude-marketplace': 'Claude marketplace' } as const |
| 271 | +const sinceFetched = (s: MarketplaceSource) => (s.lastFetchedAt ? `fetched ${timeAgo(s.lastFetchedAt)}` : 'not fetched yet') | ||
| 272 | +const plural = (n: number, w: string) => `${n} ${w}${n === 1 ? '' : 's'}` | ||
| 214 | </script> | 273 | </script> |
| 215 | 274 | ||
| 216 | <template> | 275 | <template> |
| 217 | - <UDashboardPanel id="marketplaces"> | 276 | + <SkPage wide> |
| 218 | - <template #header> | 277 | + <SkPageHeader title="Marketplaces" subtitle="Skills from git repositories and Claude plugin marketplaces. Import them into your catalog." /> |
| 219 | - <UDashboardNavbar title="Marketplaces" icon="i-lucide-store"> | 278 | + |
| 220 | - <template #right> | 279 | + <SkPanel v-if="sourcesLoading && !sources.length" padded><USkeleton class="h-64 w-full" /></SkPanel> |
| 221 | - <UButton label="Add source" icon="i-lucide-plus" color="neutral" variant="outline" @click="addOpen = true" /> | 280 | + <SkNote v-else-if="sourcesError" tone="accent"> |
| 222 | - </template> | 281 | + <div class="flex items-center gap-3"> |
| 223 | - </UDashboardNavbar> | 282 | + <span class="flex-1">Could not load the sources: {{ sourcesError }}</span> |
| 224 | - </template> | 283 | + <SkButton size="xs" label="Retry" @click="loadSources" /> |
| 225 | - | 284 | + </div> |
| 226 | - <template #body> | 285 | + </SkNote> |
| 227 | - <USkeleton v-if="sourcesLoading && !sources.length" class="h-64 w-full" /> | 286 | + |
| 228 | - <UAlert v-else-if="sourcesError" color="error" variant="subtle" icon="i-lucide-circle-alert" title="Could not load marketplaces" :description="sourcesError" :actions="[{ label: 'Retry', color: 'neutral', variant: 'outline', onClick: loadSources }]" /> | 287 | + <div v-else class="grid items-start gap-4 lg:grid-cols-[280px_minmax(0,1fr)]"> |
| 229 | - | 288 | + <!-- Sources --> |
| 230 | - <div v-else class="grid items-start gap-6 lg:grid-cols-[16rem_minmax(0,1fr)]"> | 289 | + <aside class="flex min-w-0 flex-col gap-2.5 lg:sticky lg:top-[72px]"> |
| 231 | - <!-- Sources --> | 290 | + <SkPanel> |
| 232 | - <aside class="space-y-1"> | 291 | + <SkRow |
| 233 | - <div | ||
| 234 | v-for="s in sources" | 292 | v-for="s in sources" |
| 235 | :key="s.id" | 293 | :key="s.id" |
| 236 | - class="group flex cursor-pointer items-start gap-2 rounded-lg border px-3 py-2 transition-colors" | 294 | + interactive |
| 237 | - :class="s.id === selectedId ? 'border-primary bg-primary/5' : 'border-default hover:bg-elevated/50'" | 295 | + :selected="s.id === selectedId" |
| 296 | + class="!gap-2 !py-2.5" | ||
| 297 | + role="button" | ||
| 238 | tabindex="0" | 298 | tabindex="0" |
| 299 | + :aria-current="s.id === selectedId ? 'true' : undefined" | ||
| 239 | @click="select(s.id)" | 300 | @click="select(s.id)" |
| 240 | @keydown.enter="select(s.id)" | 301 | @keydown.enter="select(s.id)" |
| 241 | > | 302 | > |
| 242 | - <UIcon :name="s.kind === 'git' ? 'i-lucide-git-fork' : 'i-lucide-puzzle'" class="mt-0.5 size-4 shrink-0 text-muted" /> | ||
| 243 | <div class="min-w-0 flex-1"> | 303 | <div class="min-w-0 flex-1"> |
| 244 | <div class="flex items-center gap-1.5"> | 304 | <div class="flex items-center gap-1.5"> |
| 245 | - <span class="truncate text-sm font-medium">{{ s.name }}</span> | 305 | + <span class="truncate font-medium">{{ s.name }}</span> |
| 246 | - <UBadge v-if="s.builtIn" label="built-in" size="sm" color="neutral" variant="subtle" /> | 306 | + <SkTag v-if="s.builtIn">built-in</SkTag> |
| 247 | </div> | 307 | </div> |
| 248 | - <p class="truncate text-xs text-muted" :title="s.url">{{ s.url }}</p> | 308 | + <p class="m-0 truncate font-mono text-[11px] text-sk-faint" :title="s.url">{{ s.url }}</p> |
| 249 | </div> | 309 | </div> |
| 250 | - <UButton v-if="!s.builtIn" icon="i-lucide-trash-2" size="xs" color="neutral" variant="ghost" aria-label="Remove source" @click.stop="removeTarget = s" /> | 310 | + <UTooltip v-if="!s.builtIn" text="Remove source"> |
| 251 | - </div> | 311 | + <SkButton size="xs" variant="ghost" icon="i-lucide-trash-2" :aria-label="`Remove ${s.name}`" @click.stop="removeTarget = s" /> |
| 252 | - </aside> | 312 | + </UTooltip> |
| 253 | - | 313 | + </SkRow> |
| 254 | - <!-- Selected source --> | 314 | + </SkPanel> |
| 255 | - <section v-if="selected" class="min-w-0 space-y-4"> | 315 | + <SkButton label="Add source" icon="i-lucide-plus" @click="addOpen = true" /> |
| 316 | + </aside> | ||
| 317 | + | ||
| 318 | + <!-- Selected source --> | ||
| 319 | + <section v-if="selected" class="flex min-w-0 flex-col gap-3.5"> | ||
| 320 | + <SkPanel padded class="flex flex-col gap-3.5"> | ||
| 256 | <div class="flex flex-wrap items-center gap-3"> | 321 | <div class="flex flex-wrap items-center gap-3"> |
| 257 | - <div class="min-w-0"> | 322 | + <div class="min-w-0 flex-1"> |
| 258 | - <h2 class="truncate font-semibold">{{ selected.name }}</h2> | 323 | + <h2 class="m-0 truncate text-[18px] leading-tight font-semibold tracking-[-0.01em]">{{ selected.name }}</h2> |
| 259 | - <p class="text-xs text-muted">{{ selected.kind === 'git' ? 'Git repository' : 'Claude plugin marketplace' }}<span v-if="selected.ref"> - {{ selected.ref }}</span> - {{ sinceFetched(selected) }}</p> | 324 | + <p class="m-0 mt-0.5 text-sk-muted"> |
| 325 | + {{ KIND_LABEL[selected.kind] }}<template v-if="selected.ref"> · <span class="font-mono text-xs">{{ selected.ref }}</span></template> · {{ sinceFetched(selected) }} | ||
| 326 | + </p> | ||
| 260 | </div> | 327 | </div> |
| 261 | - <UButton class="ml-auto" label="Refresh" icon="i-lucide-refresh-cw" color="neutral" variant="outline" :loading="refreshing" @click="refreshSource" /> | 328 | + <SkButton icon="i-lucide-refresh-cw" label="Refresh" :loading="refreshing" :disabled="skillsLoading" @click="refreshSource" /> |
| 262 | - </div> | ||
| 263 | - <UAlert v-if="refreshing" color="info" variant="subtle" icon="i-lucide-loader" title="Fetching the repository" description="Cloning or updating can take tens of seconds for large repositories." /> | ||
| 264 | - | ||
| 265 | - <div class="flex flex-wrap items-center gap-3"> | ||
| 266 | - <UInput v-model="search" icon="i-lucide-search" placeholder="Search skills..." class="w-72" /> | ||
| 267 | - <span class="text-xs text-muted">{{ filtered.length }} skill{{ filtered.length === 1 ? '' : 's' }}</span> | ||
| 268 | - <UButton class="ml-auto" :label="`Import ${picked.size || ''}`.trim()" icon="i-lucide-download" :disabled="!picked.size" :loading="importing" @click="startImport" /> | ||
| 269 | </div> | 329 | </div> |
| 270 | 330 | ||
| 271 | - <div v-if="skillsLoading" class="space-y-2"> | 331 | + <SkNote v-if="fetching" tone="info"> |
| 272 | - <UAlert color="info" variant="subtle" icon="i-lucide-loader" title="Loading skills" description="The first load clones the repository; this can take a while." /> | 332 | + <p class="m-0 font-medium">{{ refreshing ? 'Fetching the repository…' : 'Loading the skills…' }} <span class="font-mono text-xs text-sk-faint">{{ elapsed }}s</span></p> |
| 273 | - <USkeleton v-for="i in 4" :key="i" class="h-12 w-full" /> | 333 | + <p class="m-0 text-xs text-sk-muted">The first load clones the repository, which can take a minute for large ones. You can keep using skilly meanwhile.</p> |
| 274 | - </div> | 334 | + </SkNote> |
| 275 | - <UAlert v-else-if="skillsError" color="error" variant="subtle" icon="i-lucide-circle-alert" title="Could not load the skills" :description="skillsError" :actions="[{ label: 'Refresh source', color: 'neutral', variant: 'outline', onClick: refreshSource }]" /> | 335 | + |
| 276 | - <div v-else-if="!filtered.length" class="flex flex-col items-center gap-2 rounded-xl border border-dashed border-default py-12 text-center"> | 336 | + <div class="flex flex-wrap items-center gap-2.5"> |
| 277 | - <UIcon :name="search ? 'i-lucide-search-x' : 'i-lucide-store'" class="size-8 text-dimmed" /> | 337 | + <UInput v-model="search" icon="i-lucide-search" placeholder="Search name, plugin or description" aria-label="Search skills" class="min-w-0 flex-1 basis-64" /> |
| 278 | - <p class="text-sm text-muted">{{ search ? 'No skill matches the search.' : 'No skill found. Try Refresh.' }}</p> | 338 | + <SkSeg |
| 339 | + v-model="onlyNew" | ||
| 340 | + size="md" | ||
| 341 | + aria-label="Filter" | ||
| 342 | + :options="[{ value: 'all', label: 'All', count: skills.length }, { value: 'new', label: 'Not in catalog', count: skills.length - inCatalogCount }]" | ||
| 343 | + /> | ||
| 279 | </div> | 344 | </div> |
| 345 | + </SkPanel> | ||
| 280 | 346 | ||
| 281 | - <div v-else class="space-y-3"> | 347 | + <SkNote v-if="skillsError && !skillsLoading" tone="accent"> |
| 282 | - <div v-for="g in groups" :key="g.plugin ?? 'all'" class="overflow-hidden rounded-xl border border-default"> | 348 | + <div class="flex items-start gap-3"> |
| 283 | - <div v-if="g.plugin" class="flex items-center gap-2 border-b border-default bg-elevated/40 px-3 py-1.5 text-xs font-medium text-muted"> | 349 | + <div class="min-w-0 flex-1"> |
| 284 | - <UCheckbox :model-value="groupState(g.rows)" :aria-label="`Select plugin ${g.plugin}`" @update:model-value="(v) => toggleGroup(g.rows, v)" /> | 350 | + <p class="m-0 font-semibold">Could not load the skills</p> |
| 285 | - <UIcon name="i-lucide-puzzle" class="size-3.5" />{{ g.plugin }} ({{ g.rows.length }}) | 351 | + <p class="m-0 font-mono text-xs break-words whitespace-pre-wrap">{{ skillsError }}</p> |
| 286 | - </div> | ||
| 287 | - <ul class="divide-y divide-default"> | ||
| 288 | - <li v-for="s in g.rows" :key="s.path" class="flex items-start gap-3 px-3 py-2 hover:bg-elevated/40"> | ||
| 289 | - <UCheckbox class="mt-0.5" :model-value="picked.has(s.path)" :aria-label="`Select ${s.name}`" @update:model-value="(v) => toggle(s.path, v)" /> | ||
| 290 | - <button type="button" class="min-w-0 flex-1 text-left" @click="openPreview(s)"> | ||
| 291 | - <div class="flex items-center gap-2"> | ||
| 292 | - <span class="truncate text-sm font-medium text-highlighted">{{ s.name }}</span> | ||
| 293 | - <UBadge v-if="s.inCatalog === 'same'" label="In catalog" icon="i-lucide-check" size="sm" color="success" variant="subtle" /> | ||
| 294 | - <UBadge v-else-if="s.inCatalog === 'different'" label="In catalog (different)" icon="i-lucide-git-compare" size="sm" color="warning" variant="subtle" /> | ||
| 295 | - </div> | ||
| 296 | - <p v-if="s.description" class="line-clamp-2 text-xs text-muted">{{ s.description }}</p> | ||
| 297 | - <p class="truncate font-mono text-[11px] text-dimmed">{{ s.path }}</p> | ||
| 298 | - </button> | ||
| 299 | - <UButton icon="i-lucide-eye" size="xs" color="neutral" variant="ghost" aria-label="Preview" @click="openPreview(s)" /> | ||
| 300 | - </li> | ||
| 301 | - </ul> | ||
| 302 | - </div> | ||
| 303 | - <div v-if="filtered.length > shown.length" class="text-center"> | ||
| 304 | - <UButton :label="`Show all ${filtered.length}`" size="xs" color="neutral" variant="link" @click="showAll = true" /> | ||
| 305 | </div> | 352 | </div> |
| 353 | + <SkButton size="xs" label="Refresh source" :disabled="refreshing" @click="refreshSource" /> | ||
| 306 | </div> | 354 | </div> |
| 307 | - </section> | 355 | + </SkNote> |
| 308 | - </div> | ||
| 309 | 356 | ||
| 310 | - <!-- Preview --> | 357 | + <SkPanel v-else-if="skillsLoading && !skills.length" padded class="flex flex-col gap-2"> |
| 311 | - <USlideover v-model:open="previewOpen" :title="preview?.name ?? 'Preview'" :description="preview?.description" :ui="{ content: 'sm:max-w-2xl', description: 'line-clamp-3' }"> | 358 | + <USkeleton v-for="i in 5" :key="i" class="h-11 w-full" /> |
| 312 | - <template #body> | 359 | + </SkPanel> |
| 313 | - <div v-if="previewLoading" class="space-y-2"><USkeleton class="h-4 w-1/3" /><USkeleton class="h-4 w-full" /><USkeleton class="h-4 w-5/6" /></div> | 360 | + |
| 314 | - <UAlert v-else-if="previewError" color="error" variant="subtle" icon="i-lucide-circle-alert" title="Could not load the preview" :description="previewError" /> | 361 | + <SkPanel v-else-if="!filtered.length"> |
| 315 | - <div v-else-if="preview" class="space-y-4"> | 362 | + <SkEmpty :text="search || onlyNew === 'new' ? 'Nothing matches these filters.' : 'No skill found in this source. Try Refresh.'" /> |
| 316 | - <div v-if="previewFm" class="overflow-hidden rounded-lg border border-default"> | 363 | + </SkPanel> |
| 317 | - <table class="w-full text-xs"> | 364 | + |
| 318 | - <tbody class="divide-y divide-default"> | 365 | + <SkTray v-else> |
| 319 | - <tr v-for="(v, k) in previewFm" :key="k"> | 366 | + <template v-for="g in groups" :key="g.plugin ?? 'all'"> |
| 320 | - <th class="w-1/4 bg-elevated/40 px-2 py-1.5 text-left align-top font-medium text-muted">{{ k }}</th> | 367 | + <div v-if="g.plugin" class="flex items-center gap-2 px-2.5 pt-2 pb-1"> |
| 321 | - <td class="px-2 py-1.5 break-words">{{ v }}</td> | 368 | + <UCheckbox :model-value="groupState(g.rows)" :aria-label="`Select every skill of ${g.plugin}`" @update:model-value="(v: boolean | 'indeterminate') => toggleGroup(g.rows, v)" /> |
| 322 | - </tr> | 369 | + <SkSectionLabel :count="g.rows.length">{{ g.plugin }}</SkSectionLabel> |
| 323 | - </tbody> | ||
| 324 | - </table> | ||
| 325 | - </div> | ||
| 326 | - <!-- eslint-disable-next-line vue/no-v-html --> | ||
| 327 | - <div class="md-body" v-html="previewHtml" /> | ||
| 328 | - <div v-if="preview.files.length" class="space-y-1"> | ||
| 329 | - <h3 class="text-sm font-semibold">Files ({{ preview.files.length }})</h3> | ||
| 330 | - <ul class="max-h-48 overflow-auto rounded-lg border border-default text-xs"> | ||
| 331 | - <li v-for="f in preview.files" :key="f" class="flex items-center gap-2 px-3 py-1.5 font-mono"><UIcon name="i-lucide-file" class="size-3.5 text-muted" />{{ f }}</li> | ||
| 332 | - </ul> | ||
| 333 | </div> | 370 | </div> |
| 371 | + <SkRowCard | ||
| 372 | + v-for="s in g.rows" | ||
| 373 | + :key="s.path" | ||
| 374 | + cols="16px minmax(0,1fr) auto" | ||
| 375 | + interactive | ||
| 376 | + role="button" | ||
| 377 | + tabindex="0" | ||
| 378 | + @click="openPreview(s)" | ||
| 379 | + @keydown.enter.self="openPreview(s)" | ||
| 380 | + > | ||
| 381 | + <span class="flex" @click.stop> | ||
| 382 | + <UCheckbox :model-value="picked.has(s.path)" :aria-label="`Select ${s.name}`" @update:model-value="(v: boolean | 'indeterminate') => toggle(s.path, v)" /> | ||
| 383 | + </span> | ||
| 384 | + <div class="min-w-0"> | ||
| 385 | + <span class="block truncate font-medium">{{ s.name }}</span> | ||
| 386 | + <p v-if="s.description" class="m-0 line-clamp-2 text-xs text-pretty text-sk-muted">{{ s.description }}</p> | ||
| 387 | + <p class="m-0 truncate font-mono text-[11px] text-sk-faint" :title="s.path">{{ s.path }}</p> | ||
| 388 | + </div> | ||
| 389 | + <SkStatus v-if="stateOf(s)" :tone="stateOf(s)!.tone" :label="stateOf(s)!.label" muted /> | ||
| 390 | + </SkRowCard> | ||
| 391 | + </template> | ||
| 392 | + <div v-if="filtered.length > shown.length" class="py-2 text-center"> | ||
| 393 | + <SkButton size="sm" variant="ghost" :label="`Show all ${filtered.length}`" @click="showAll = true" /> | ||
| 334 | </div> | 394 | </div> |
| 335 | - </template> | 395 | + </SkTray> |
| 336 | - </USlideover> | 396 | + </section> |
| 337 | - | 397 | + <SkPanel v-else padded><SkEmpty text="Select a source to browse its skills." /></SkPanel> |
| 338 | - <!-- Overwrite confirmation --> | 398 | + </div> |
| 339 | - <UModal v-model:open="confirmOpen" title="Overwrite catalog items?" description="Some selected skills already exist in the catalog with different content."> | 399 | + |
| 340 | - <template #body> | 400 | + <SkActionBar v-if="picked.size" :changes="picked.size" noun="selected" label="Import to catalog" :busy="importing" @review="startImport([...picked])" /> |
| 341 | - <ul class="list-disc space-y-0.5 pl-4 text-sm"> | 401 | + |
| 342 | - <li v-for="s in differing" :key="s.path">{{ s.name }}</li> | 402 | + <!-- Preview --> |
| 343 | - </ul> | 403 | + <SkDrawer v-model:open="previewOpen" width="lg"> |
| 344 | - <p class="mt-3 text-sm text-muted">Overwriting replaces the catalog version with the marketplace version. Local edits in the catalog are lost (git history keeps them).</p> | 404 | + <template #header> |
| 345 | - </template> | 405 | + <div class="flex min-w-0 flex-1 flex-col gap-1"> |
| 346 | - <template #footer> | 406 | + <div class="flex items-baseline gap-2"> |
| 347 | - <div class="flex w-full justify-end gap-2"> | 407 | + <h2 class="m-0 truncate text-[18px] leading-tight font-semibold tracking-[-0.01em]">{{ preview?.name ?? previewSkill?.name ?? 'Preview' }}</h2> |
| 348 | - <UButton label="Cancel" color="neutral" variant="outline" @click="confirmOpen = false" /> | 408 | + <span class="font-mono text-[11px] text-sk-faint">Skill</span> |
| 349 | - <UButton label="Overwrite and import" color="warning" :loading="importing" @click="doImport(true)" /> | ||
| 350 | - </div> | ||
| 351 | - </template> | ||
| 352 | - </UModal> | ||
| 353 | - | ||
| 354 | - <!-- Add source --> | ||
| 355 | - <UModal v-model:open="addOpen" title="Add a source" description="A git repository containing skills, or a Claude Code plugin marketplace."> | ||
| 356 | - <template #body> | ||
| 357 | - <form id="add-source" class="space-y-3" @submit.prevent="addSource"> | ||
| 358 | - <UAlert v-if="addError" color="error" variant="subtle" icon="i-lucide-circle-alert" :title="addError" /> | ||
| 359 | - <UFormField label="Name" required><UInput v-model="addForm.name" class="w-full" placeholder="My skills" /></UFormField> | ||
| 360 | - <UFormField label="Kind"><USelect v-model="addForm.kind" :items="KINDS" class="w-full" /></UFormField> | ||
| 361 | - <UFormField label="URL" required><UInput v-model="addForm.url" class="w-full" placeholder="https://github.com/owner/repo.git" :ui="{ base: 'font-mono' }" /></UFormField> | ||
| 362 | - <UFormField label="Ref" description="Branch or tag; default branch when empty."><UInput v-model="addForm.ref" class="w-full" placeholder="main" :ui="{ base: 'font-mono' }" /></UFormField> | ||
| 363 | - </form> | ||
| 364 | - </template> | ||
| 365 | - <template #footer> | ||
| 366 | - <div class="flex w-full justify-end gap-2"> | ||
| 367 | - <UButton label="Cancel" color="neutral" variant="outline" @click="addOpen = false" /> | ||
| 368 | - <UButton type="submit" form="add-source" label="Add source" :loading="adding" :disabled="!addForm.name.trim() || !addForm.url.trim()" /> | ||
| 369 | </div> | 409 | </div> |
| 370 | - </template> | 410 | + <p v-if="preview?.description ?? previewSkill?.description" class="m-0 text-pretty text-sk-muted">{{ preview?.description ?? previewSkill?.description }}</p> |
| 371 | - </UModal> | 411 | + </div> |
| 372 | - | 412 | + </template> |
| 373 | - <UModal :open="!!removeTarget" title="Remove source" description="Imported catalog items are kept." @update:open="(v: boolean) => { if (!v) removeTarget = null }"> | 413 | + |
| 374 | - <template #body> | 414 | + <div v-if="previewLoading" class="flex flex-col gap-2"><USkeleton class="h-4 w-1/3" /><USkeleton class="h-4 w-full" /><USkeleton class="h-4 w-5/6" /></div> |
| 375 | - <p class="text-sm">Remove <strong>{{ removeTarget?.name }}</strong> from the list of sources?</p> | 415 | + <SkNote v-else-if="previewError" tone="accent">Could not load the preview: {{ previewError }}</SkNote> |
| 376 | - </template> | 416 | + <div v-else-if="preview" class="flex flex-col gap-4"> |
| 377 | - <template #footer> | 417 | + <SkMetaGrid label-width="100px"> |
| 378 | - <div class="flex w-full justify-end gap-2"> | 418 | + <SkMeta label="Source">{{ selected?.name }}</SkMeta> |
| 379 | - <UButton label="Cancel" color="neutral" variant="outline" @click="removeTarget = null" /> | 419 | + <SkMeta v-if="previewSkill?.plugin" label="Plugin" mono>{{ previewSkill.plugin }}</SkMeta> |
| 380 | - <UButton label="Remove" color="error" @click="removeSource" /> | 420 | + <SkMeta label="Path" mono>{{ preview.path }}</SkMeta> |
| 421 | + <SkMeta v-if="previewSkill && stateOf(previewSkill)" label="Catalog"><SkStatus :tone="stateOf(previewSkill)!.tone" :label="stateOf(previewSkill)!.label" /></SkMeta> | ||
| 422 | + <SkMeta v-for="(v, k) in previewParts?.frontmatter ?? {}" :key="k" :label="String(k)">{{ typeof v === 'string' ? v : JSON.stringify(v) }}</SkMeta> | ||
| 423 | + </SkMetaGrid> | ||
| 424 | + <MarkdownBody :source="previewParts?.body ?? ''" /> | ||
| 425 | + <div v-if="preview.files.length" class="flex flex-col gap-2"> | ||
| 426 | + <SkSectionLabel :count="preview.files.length">Files</SkSectionLabel> | ||
| 427 | + <SkTray class="max-h-52 overflow-auto"> | ||
| 428 | + <SkRowCard v-for="f in preview.files" :key="f" class="truncate font-mono text-xs">{{ f }}</SkRowCard> | ||
| 429 | + </SkTray> | ||
| 430 | + </div> | ||
| 431 | + </div> | ||
| 432 | + | ||
| 433 | + <template #footer> | ||
| 434 | + <span class="mr-auto text-xs text-sk-faint">Imports the whole skill folder.</span> | ||
| 435 | + <SkButton label="Close" @click="previewOpen = false" /> | ||
| 436 | + <SkButton variant="primary" label="Import to catalog" :loading="importing" :disabled="!previewSkill || !!previewError" @click="previewSkill && startImport([previewSkill.path])" /> | ||
| 437 | + </template> | ||
| 438 | + </SkDrawer> | ||
| 439 | + | ||
| 440 | + <!-- Overwrite confirmation --> | ||
| 441 | + <SkModal v-model:open="confirmOpen" :title="`Overwrite ${plural(differing.length, 'catalog skill')}?`" width="sm"> | ||
| 442 | + <div class="flex flex-col gap-3.5"> | ||
| 443 | + <div class="flex items-start gap-2.5"> | ||
| 444 | + <SkStepBadge>1</SkStepBadge> | ||
| 445 | + <div class="min-w-0"> | ||
| 446 | + <p class="m-0 font-medium">These skills already exist with different content</p> | ||
| 447 | + <ul class="m-0 mt-1 list-none p-0 font-mono text-xs text-sk-muted"> | ||
| 448 | + <li v-for="s in differing" :key="s.path" class="truncate">{{ s.name }}</li> | ||
| 449 | + </ul> | ||
| 381 | </div> | 450 | </div> |
| 382 | - </template> | 451 | + </div> |
| 383 | - </UModal> | 452 | + <div class="flex items-start gap-2.5"> |
| 384 | - </template> | 453 | + <SkStepBadge>2</SkStepBadge> |
| 385 | - </UDashboardPanel> | 454 | + <p class="m-0 text-pretty text-sk-muted">The catalog version is replaced by the marketplace version. Local edits are lost from the working folder; git history keeps them. Projects using the skill will show an update.</p> |
| 455 | + </div> | ||
| 456 | + </div> | ||
| 457 | + <template #footer> | ||
| 458 | + <SkButton label="Cancel" @click="confirmOpen = false" /> | ||
| 459 | + <SkButton variant="primary" label="Overwrite and import" :loading="importing" @click="doImport(true)" /> | ||
| 460 | + </template> | ||
| 461 | + </SkModal> | ||
| 462 | + | ||
| 463 | + <!-- Add source --> | ||
| 464 | + <SkModal v-model:open="addOpen" title="Add a source" description="A git repository containing skills, or a Claude Code plugin marketplace." width="sm"> | ||
| 465 | + <form id="add-source" class="flex flex-col gap-3.5" @submit.prevent="addSource"> | ||
| 466 | + <SkNote v-if="addError" tone="accent">{{ addError }}</SkNote> | ||
| 467 | + <label class="flex flex-col gap-1.5"><SkSectionLabel>Name</SkSectionLabel><UInput v-model="addForm.name" placeholder="My skills" /></label> | ||
| 468 | + <div class="flex flex-col gap-1.5"> | ||
| 469 | + <SkSectionLabel>Kind</SkSectionLabel> | ||
| 470 | + <SkSeg v-model="addForm.kind" size="md" aria-label="Kind" :options="[{ value: 'git', label: 'Git repository' }, { value: 'claude-marketplace', label: 'Claude marketplace' }]" /> | ||
| 471 | + </div> | ||
| 472 | + <label class="flex flex-col gap-1.5"><SkSectionLabel>URL</SkSectionLabel><UInput v-model="addForm.url" placeholder="https://github.com/owner/repo.git" :ui="{ base: 'font-mono' }" /></label> | ||
| 473 | + <label class="flex flex-col gap-1.5"> | ||
| 474 | + <SkSectionLabel>Ref</SkSectionLabel> | ||
| 475 | + <UInput v-model="addForm.ref" placeholder="main" :ui="{ base: 'font-mono' }" /> | ||
| 476 | + <span class="text-xs text-sk-faint">Branch or tag. The default branch when empty.</span> | ||
| 477 | + </label> | ||
| 478 | + </form> | ||
| 479 | + <template #footer> | ||
| 480 | + <SkButton label="Cancel" @click="addOpen = false" /> | ||
| 481 | + <SkButton type="submit" form="add-source" variant="primary" label="Add source" :loading="adding" :disabled="!addForm.name.trim() || !addForm.url.trim()" /> | ||
| 482 | + </template> | ||
| 483 | + </SkModal> | ||
| 484 | + | ||
| 485 | + <!-- Remove source --> | ||
| 486 | + <SkModal :open="!!removeTarget" title="Remove source" width="sm" @update:open="(v: boolean) => { if (!v) removeTarget = null }"> | ||
| 487 | + <p class="m-0 text-pretty">Remove <strong>{{ removeTarget?.name }}</strong> from your sources? Skills already imported stay in the catalog.</p> | ||
| 488 | + <template #footer> | ||
| 489 | + <SkButton label="Cancel" @click="removeTarget = null" /> | ||
| 490 | + <SkButton variant="primary" label="Remove source" @click="removeSource" /> | ||
| 491 | + </template> | ||
| 492 | + </SkModal> | ||
| 493 | + </SkPage> | ||
| 386 | </template> | 494 | </template> |
modified
packages/ui/app/pages/onboarding.vue +376 -4 | @@ -1,12 +1,384 @@ | ||
| 1 | 1 | <script setup lang="ts"> |
| 2 | -// PLACEHOLDER (WP3 owns this page). Uses the bare layout (no header): the 860px card of the mockup goes here. | |
| 2 | +import type { CatalogResponse, DiscoveredProject, FsDiscovery, FsListing, Settings } from '@skilly/core' | |
| 3 | + | |
| 4 | +/** | |
| 5 | + * Onboarding (design/BRIEF.md 2.1 + 4.8), bare layout. Five steps: Welcome, Catalog (restore = git clone, or start | |
| 6 | + * a new one), Agents (read-only detection summary), Projects (discovered folders), Ready. Skippable at any time. | |
| 7 | + */ | |
| 3 | 8 | definePageMeta({ layout: 'bare' }) |
| 9 | + | |
| 10 | +const { request } = useApi() | |
| 11 | +const catalogState = useCatalog() | |
| 12 | +const { projects, refresh: refreshProjects, add: addProject } = useProjects() | |
| 13 | +const { home, refresh: refreshHome } = useHomeInventory() | |
| 14 | +const router = useRouter() | |
| 15 | + | |
| 16 | +const plural = (n: number, w: string) => `${n} ${w}${n === 1 ? '' : 's'}` | |
| 17 | +const STEPS = ['Welcome', 'Catalog', 'Agents', 'Projects', 'Ready'] as const | |
| 18 | +const step = ref(0) | |
| 19 | +const mode = ref<'restore' | 'fresh' | null>(null) | |
| 20 | + | |
| 21 | +/* ------------------------------------------------ Catalog */ | |
| 22 | + | |
| 23 | +const settings = ref<Settings | null>(null) | |
| 24 | +const catalogPath = ref('') | |
| 25 | +const remoteUrl = ref('') | |
| 26 | +const urlTouched = ref(false) | |
| 27 | +const connecting = ref(false) | |
| 28 | +const connectError = ref<string | null>(null) | |
| 29 | +const result = ref<CatalogResponse | null>(null) | |
| 30 | +const folderPicker = ref(false) | |
| 31 | + | |
| 32 | +const URL_RE = /^(?:(?:https?|ssh|git|file):\/\/\S+|[\w.-]+@[\w.-]+:\S+|[A-Za-z]:[\\/]\S*|\/\S+)$/ | |
| 33 | +const urlValid = computed(() => URL_RE.test(remoteUrl.value.trim())) | |
| 34 | +const urlError = computed(() => (urlTouched.value && remoteUrl.value.trim() && !urlValid.value | |
| 35 | + ? 'Use an SSH address (git@github.com:you/catalog.git) or an HTTPS URL.' | |
| 36 | + : null)) | |
| 37 | +const connected = computed(() => !!result.value?.git.isRepo && mode.value === 'restore') | |
| 38 | +const existing = computed(() => (catalogState.data.value?.snapshot.exists ? catalogState.data.value : null)) | |
| 39 | + | |
| 40 | +onMounted(async () => { | |
| 41 | + try { | |
| 42 | + settings.value = await request<Settings>('/api/settings', { silent: true }) | |
| 43 | + catalogPath.value = settings.value.catalogPath | |
| 44 | + } catch { /* the step shows an error when acting */ } | |
| 45 | + await Promise.all([catalogState.refresh(), refreshProjects(), refreshHome()]) | |
| 46 | + if (catalogState.exists.value) result.value = catalogState.data.value | |
| 47 | +}) | |
| 48 | + | |
| 49 | +async function connect() { | |
| 50 | + urlTouched.value = true | |
| 51 | + if (!urlValid.value) return | |
| 52 | + connecting.value = true | |
| 53 | + connectError.value = null | |
| 54 | + try { | |
| 55 | + const body: { url: string, path?: string } = { url: remoteUrl.value.trim() } | |
| 56 | + if (catalogPath.value && catalogPath.value !== settings.value?.catalogPath) body.path = catalogPath.value | |
| 57 | + result.value = await request<CatalogResponse>('/api/catalog/clone', { method: 'POST', body, silent: true }) | |
| 58 | + catalogState.data.value = result.value | |
| 59 | + if (settings.value) settings.value = { ...settings.value, catalogPath: result.value.snapshot.root } | |
| 60 | + } catch (e) { | |
| 61 | + connectError.value = (e as Error).message | |
| 62 | + } finally { | |
| 63 | + connecting.value = false | |
| 64 | + } | |
| 65 | +} | |
| 66 | + | |
| 67 | +async function createCatalog() { | |
| 68 | + connecting.value = true | |
| 69 | + connectError.value = null | |
| 70 | + try { | |
| 71 | + if (catalogPath.value && catalogPath.value !== settings.value?.catalogPath) { | |
| 72 | + settings.value = await request<Settings>('/api/settings', { method: 'PUT', body: { catalogPath: catalogPath.value }, silent: true }) | |
| 73 | + } | |
| 74 | + result.value = await request<CatalogResponse>('/api/catalog/init', { method: 'POST', silent: true }) | |
| 75 | + catalogState.data.value = result.value | |
| 76 | + } catch (e) { | |
| 77 | + connectError.value = (e as Error).message | |
| 78 | + } finally { | |
| 79 | + connecting.value = false | |
| 80 | + } | |
| 81 | +} | |
| 82 | + | |
| 83 | +const catalogReady = computed(() => !!result.value?.snapshot.exists) | |
| 84 | + | |
| 85 | +/* ------------------------------------------------ Agents */ | |
| 86 | + | |
| 87 | +const AGENTS = [ | |
| 88 | + { id: 'claude', name: 'Claude Code', where: '~/.claude' }, | |
| 89 | + { id: 'codex', name: 'Codex', where: '~/.codex · ~/.agents' } | |
| 90 | +] as const | |
| 91 | +const agentRows = computed(() => AGENTS.map(a => ({ | |
| 92 | + ...a, | |
| 93 | + found: !!home.value?.agentsDetected.includes(a.id), | |
| 94 | + count: home.value?.items.filter(i => i.agent === a.id && i.type !== 'instructions').length ?? 0 | |
| 95 | +}))) | |
| 96 | + | |
| 97 | +/* ------------------------------------------------ Projects */ | |
| 98 | + | |
| 99 | +interface Candidate extends DiscoveredProject { checked: boolean, added: boolean } | |
| 100 | +const candidates = ref<Candidate[]>([]) | |
| 101 | +const scanning = ref(false) | |
| 102 | +const scanned = ref(false) | |
| 103 | +const addingProjects = ref(false) | |
| 104 | +const addedCount = ref(0) | |
| 105 | +const addErrors = ref<string[]>([]) | |
| 106 | +const scanPicker = ref(false) | |
| 107 | +const addPicker = ref(false) | |
| 108 | +const ROOT_NAMES = new Set(['code', 'dev', 'work', 'projects', 'project', 'src', 'repos', 'repo', 'git', 'workspace', 'workspaces', 'developer', 'sites', 'github']) | |
| 109 | + | |
| 110 | +const sameKey = (p: string) => p.replace(/[\\/]+$/, '').toLowerCase() | |
| 111 | +const known = computed(() => new Set(projects.value.map(p => sameKey(p.path)))) | |
| 112 | +const homeDir = computed(() => home.value?.root ?? '') | |
| 113 | +function tilde(p: string): string { | |
| 114 | + const h = homeDir.value | |
| 115 | + return h && sameKey(p).startsWith(sameKey(h) + (p.includes('\\') ? '\\' : '/')) ? '~' + p.slice(h.length).replace(/\\/g, '/') : p | |
| 116 | +} | |
| 117 | + | |
| 118 | +function merge(found: DiscoveredProject[], checked = true) { | |
| 119 | + const have = new Set(candidates.value.map(c => sameKey(c.path))) | |
| 120 | + for (const f of found) { | |
| 121 | + if (have.has(sameKey(f.path))) continue | |
| 122 | + const added = known.value.has(sameKey(f.path)) | |
| 123 | + candidates.value.push({ ...f, added, checked: checked && !added }) | |
| 124 | + } | |
| 125 | +} | |
| 126 | + | |
| 127 | +async function discover(root: string, depth = 3): Promise<DiscoveredProject[]> { | |
| 128 | + try { | |
| 129 | + return (await request<FsDiscovery>('/api/fs/discover', { query: { root, depth }, silent: true })).entries | |
| 130 | + } catch { | |
| 131 | + return [] | |
| 132 | + } | |
| 133 | +} | |
| 134 | + | |
| 135 | +async function scanDefaults() { | |
| 136 | + scanning.value = true | |
| 137 | + try { | |
| 138 | + const roots = await request<FsListing>('/api/fs/list', { silent: true }) | |
| 139 | + const parents = roots.entries.map(e => e.path) | |
| 140 | + const dirs: string[] = [] | |
| 141 | + await Promise.all(parents.map(async (p) => { | |
| 142 | + try { | |
| 143 | + const l = await request<FsListing>('/api/fs/list', { query: { path: p }, silent: true }) | |
| 144 | + for (const e of l.entries) if (ROOT_NAMES.has(e.name.toLowerCase())) dirs.push(e.path) | |
| 145 | + } catch { /* unreadable root */ } | |
| 146 | + })) | |
| 147 | + const found = (await Promise.all(dirs.slice(0, 8).map(d => discover(d)))).flat() | |
| 148 | + merge(found) | |
| 149 | + } finally { | |
| 150 | + scanning.value = false | |
| 151 | + scanned.value = true | |
| 152 | + } | |
| 153 | +} | |
| 154 | + | |
| 155 | +async function scanFolder(path: string) { | |
| 156 | + scanning.value = true | |
| 157 | + try { merge(await discover(path, 3)) } finally { scanning.value = false } | |
| 158 | +} | |
| 159 | + | |
| 160 | +function addFolder(path: string) { | |
| 161 | + const name = path.replace(/[\\/]+$/, '').split(/[\\/]/).pop() || path | |
| 162 | + merge([{ name, path, hasAgentConfig: false, markers: [] }]) | |
| 163 | + const c = candidates.value.find(x => sameKey(x.path) === sameKey(path)) | |
| 164 | + if (c && !c.added) c.checked = true | |
| 165 | +} | |
| 166 | + | |
| 167 | +const selectedCandidates = computed(() => candidates.value.filter(c => c.checked && !c.added)) | |
| 168 | + | |
| 169 | +async function addSelected(): Promise<boolean> { | |
| 170 | + addingProjects.value = true | |
| 171 | + addErrors.value = [] | |
| 172 | + let n = 0 | |
| 173 | + for (const c of selectedCandidates.value) { | |
| 174 | + try { | |
| 175 | + await addProject(c.path) | |
| 176 | + c.added = true | |
| 177 | + c.checked = false | |
| 178 | + n++ | |
| 179 | + } catch (e) { | |
| 180 | + const err = e as { status?: number, message: string } | |
| 181 | + if (err.status === 409) { c.added = true; c.checked = false } else addErrors.value.push(`${c.name}: ${err.message}`) | |
| 182 | + } | |
| 183 | + } | |
| 184 | + addedCount.value += n | |
| 185 | + addingProjects.value = false | |
| 186 | + return addErrors.value.length === 0 | |
| 187 | +} | |
| 188 | + | |
| 189 | +/* ------------------------------------------------ Navigation */ | |
| 190 | + | |
| 191 | +const SKIP_KEY = 'skilly-onboarding-done' | |
| 192 | +function finish(to = '/projects') { | |
| 193 | + try { localStorage.setItem(SKIP_KEY, '1') } catch { /* private mode */ } | |
| 194 | + return router.push(to) | |
| 195 | +} | |
| 196 | + | |
| 197 | +const canContinue = computed(() => { | |
| 198 | + if (step.value === 0) return false | |
| 199 | + if (step.value === 1) return mode.value === 'restore' ? connected.value || !!existing.value : true | |
| 200 | + return true | |
| 201 | +}) | |
| 202 | +const nextLabel = computed(() => { | |
| 203 | + if (step.value === 1 && mode.value === 'fresh' && !catalogReady.value) return 'Skip for now' | |
| 204 | + if (step.value === 3) return selectedCandidates.value.length ? `Add ${selectedCandidates.value.length} and continue` : 'Skip for now' | |
| 205 | + if (step.value === 4) return 'Open skilly' | |
| 206 | + return 'Continue' | |
| 207 | +}) | |
| 208 | + | |
| 209 | +async function next() { | |
| 210 | + if (step.value === 3 && selectedCandidates.value.length && !(await addSelected())) return | |
| 211 | + if (step.value === 4) return finish() | |
| 212 | + step.value++ | |
| 213 | +} | |
| 214 | +function back() { step.value = Math.max(0, step.value - 1) } | |
| 215 | +function choose(m: 'restore' | 'fresh') { mode.value = m; step.value = 1 } | |
| 216 | + | |
| 217 | +watch(step, (s) => { if (s === 3 && !scanned.value && !scanning.value) scanDefaults() }) | |
| 218 | + | |
| 219 | +const summary = computed(() => [ | |
| 220 | + ['Catalog', catalogReady.value ? `${plural(result.value!.snapshot.items.length, 'item')} · ${plural(result.value!.snapshot.presets.length, 'preset')}` : 'Not created yet'], | |
| 221 | + ['Location', catalogPath.value || '-'], | |
| 222 | + ['Agents', agentRows.value.filter(a => a.found).map(a => a.name).join(', ') || 'None detected'], | |
| 223 | + ['Projects', `${projects.value.length} managed${addedCount.value ? ` (${addedCount.value} added now)` : ''}`] | |
| 224 | +] as const) | |
| 4 | 225 | </script> |
| 5 | 226 | |
| 6 | 227 | <template> |
| 7 | - <div class="flex flex-1 items-center justify-center p-8"> | |
| 8 | - <SkPanel hero class="w-full max-w-[860px]"> | |
| 9 | - <SkEmpty title="Onboarding" text="Not built yet (WP3)." /> | |
| 228 | + <div class="flex flex-1 items-center justify-center p-4 sm:p-8"> | |
| 229 | + <SkPanel hero class="grid w-full max-w-[860px] md:min-h-[520px] md:grid-cols-[200px_minmax(0,1fr)]"> | |
| 230 | + <!-- Steps --> | |
| 231 | + <aside class="hidden flex-col gap-5 border-r border-sk-line2 bg-sk-panel p-[18px] md:flex"> | |
| 232 | + <div class="flex items-center gap-2"><SkLogo /><span class="text-base font-semibold">skilly</span></div> | |
| 233 | + <ol class="m-0 flex list-none flex-col gap-3 p-0"> | |
| 234 | + <li v-for="(s, i) in STEPS" :key="s" class="flex items-center gap-2.5" :aria-current="i === step ? 'step' : undefined"> | |
| 235 | + <span | |
| 236 | + class="flex size-5 shrink-0 items-center justify-center rounded-full border text-[11px]" | |
| 237 | + :class="i < step ? 'border-sk-ink bg-sk-ink text-sk-onink' : i === step ? 'border-sk-ink text-sk-text' : 'border-sk-line text-sk-faint'" | |
| 238 | + >{{ i < step ? '✓' : i + 1 }}</span> | |
| 239 | + <span :class="i === step ? 'font-semibold text-sk-text' : i < step ? 'text-sk-muted' : 'text-sk-faint'">{{ s }}</span> | |
| 240 | + </li> | |
| 241 | + </ol> | |
| 242 | + <span class="flex-1" /> | |
| 243 | + <SkButton variant="ghost" size="sm" label="Skip setup" class="self-start" @click="finish()" /> | |
| 244 | + </aside> | |
| 245 | + | |
| 246 | + <!-- Content --> | |
| 247 | + <div class="flex min-w-0 flex-col p-[26px] sm:p-[32px]"> | |
| 248 | + <div class="flex flex-1 flex-col gap-4"> | |
| 249 | + <!-- 0 Welcome --> | |
| 250 | + <template v-if="step === 0"> | |
| 251 | + <h1 class="m-0 mt-3 font-mono text-[26px] leading-tight font-normal tracking-[-0.01em]">Welcome to skilly</h1> | |
| 252 | + <p class="m-0 max-w-[460px] text-pretty text-sk-muted">Manage the skills, rules, MCP servers, hooks and subagents your coding agents use, across all your projects, from one place.</p> | |
| 253 | + <div class="mt-3 grid gap-3 sm:grid-cols-2"> | |
| 254 | + <button type="button" class="cursor-pointer rounded-[10px] border border-sk-line bg-sk-panel p-4 text-left transition-colors hover:border-sk-faint" @click="choose('restore')"> | |
| 255 | + <span class="block font-semibold">Restore a catalog</span> | |
| 256 | + <span class="mt-1 block text-pretty text-sk-muted">I already have a catalog in a git repository. Set up this machine from it.</span> | |
| 257 | + </button> | |
| 258 | + <button type="button" class="cursor-pointer rounded-[10px] border border-sk-line bg-sk-panel p-4 text-left transition-colors hover:border-sk-faint" @click="choose('fresh')"> | |
| 259 | + <span class="block font-semibold">Start a new catalog</span> | |
| 260 | + <span class="mt-1 block text-pretty text-sk-muted">Create an empty catalog and fill it from what is already on this machine.</span> | |
| 261 | + </button> | |
| 262 | + </div> | |
| 263 | + </template> | |
| 264 | + | |
| 265 | + <!-- 1 Catalog --> | |
| 266 | + <template v-else-if="step === 1"> | |
| 267 | + <SkSectionLabel>Step 1 of 3</SkSectionLabel> | |
| 268 | + <h1 class="m-0 font-mono text-[22px] font-normal tracking-[-0.01em]">{{ mode === 'restore' ? 'Connect your catalog' : 'Create your catalog' }}</h1> | |
| 269 | + <p class="m-0 text-sk-muted">{{ mode === 'restore' ? 'skilly clones the repository and restores your items and presets on this machine.' : 'An empty catalog in a git repository. Add a remote later from the Sync page to share it.' }}</p> | |
| 270 | + | |
| 271 | + <div class="flex flex-col gap-1.5"> | |
| 272 | + <span class="font-medium">Catalog folder</span> | |
| 273 | + <div class="flex items-center gap-2"> | |
| 274 | + <span class="min-w-0 flex-1 truncate rounded-[7px] border border-sk-line bg-sk-bg px-2.5 py-[7px] font-mono text-xs" :title="catalogPath">{{ catalogPath || '-' }}</span> | |
| 275 | + <SkButton label="Change" :disabled="connecting || connected || (mode === 'fresh' && catalogReady)" @click="folderPicker = true" /> | |
| 276 | + </div> | |
| 277 | + </div> | |
| 278 | + | |
| 279 | + <template v-if="mode === 'restore'"> | |
| 280 | + <form class="flex flex-col gap-1.5" @submit.prevent="connect"> | |
| 281 | + <label class="font-medium" for="remote-url">Git remote URL</label> | |
| 282 | + <div class="flex items-start gap-2"> | |
| 283 | + <div class="flex min-w-0 flex-1 flex-col gap-1"> | |
| 284 | + <UInput | |
| 285 | + id="remote-url" | |
| 286 | + v-model="remoteUrl" | |
| 287 | + placeholder="git@github.com:you/agent-catalog.git" | |
| 288 | + :disabled="connecting || connected" | |
| 289 | + :class="(urlError || connectError) && '[&_input]:ring-sk-accent'" | |
| 290 | + :ui="{ base: 'font-mono' }" | |
| 291 | + @blur="urlTouched = true" | |
| 292 | + @update:model-value="connectError = null" | |
| 293 | + /> | |
| 294 | + <p v-if="urlError" class="m-0 text-xs text-sk-accent-text">{{ urlError }}</p> | |
| 295 | + </div> | |
| 296 | + <SkButton type="submit" variant="primary" :label="connected ? 'Connected' : connecting ? 'Connecting…' : 'Connect'" :loading="connecting" :disabled="connected || !remoteUrl.trim()" /> | |
| 297 | + </div> | |
| 298 | + <p class="m-0 text-xs text-pretty text-sk-faint">GitHub, GitLab or self-hosted. The catalog never stores secrets: MCP configs only reference environment variable names.</p> | |
| 299 | + </form> | |
| 300 | + <SkNote v-if="connecting" tone="info">Cloning the repository… this can take a moment.</SkNote> | |
| 301 | + <SkNote v-if="connectError" tone="accent"><span class="font-mono text-xs break-words whitespace-pre-wrap">{{ connectError }}</span></SkNote> | |
| 302 | + <SkNote v-if="connected && result" tone="ok"> | |
| 303 | + Restored {{ plural(result.snapshot.items.length, 'item') }} and {{ plural(result.snapshot.presets.length, 'preset') }} | |
| 304 | + <template #aside><span class="font-mono text-[11px] text-sk-faint">{{ result.git.branch }}</span></template> | |
| 305 | + </SkNote> | |
| 306 | + <SkNote v-else-if="!connected && existing" tone="faint">A catalog already exists at this location ({{ plural(existing.snapshot.items.length, 'item') }}). Change the folder to restore into a new one, or continue to keep it.</SkNote> | |
| 307 | + </template> | |
| 308 | + | |
| 309 | + <template v-else> | |
| 310 | + <SkNote v-if="catalogReady && result" tone="ok">Catalog ready · {{ plural(result.snapshot.items.length, 'item') }}, {{ plural(result.snapshot.presets.length, 'preset') }}</SkNote> | |
| 311 | + <div v-else><SkButton variant="primary" label="Create the catalog now" :loading="connecting" @click="createCatalog" /></div> | |
| 312 | + <SkNote v-if="connectError" tone="accent"><span class="font-mono text-xs break-words whitespace-pre-wrap">{{ connectError }}</span></SkNote> | |
| 313 | + </template> | |
| 314 | + </template> | |
| 315 | + | |
| 316 | + <!-- 2 Agents --> | |
| 317 | + <template v-else-if="step === 2"> | |
| 318 | + <SkSectionLabel>Step 2 of 3</SkSectionLabel> | |
| 319 | + <h1 class="m-0 font-mono text-[22px] font-normal tracking-[-0.01em]">Agents on this machine</h1> | |
| 320 | + <p class="m-0 text-sk-muted">What skilly found in your home folder. Each project chooses the agents it writes for.</p> | |
| 321 | + <SkPanel> | |
| 322 | + <SkRow v-for="a in agentRows" :key="a.id" cols="minmax(0,1fr) auto" class="!py-3"> | |
| 323 | + <div class="min-w-0"> | |
| 324 | + <div class="font-medium">{{ a.name }}</div> | |
| 325 | + <div class="font-mono text-[11px] text-sk-faint">{{ a.where }}</div> | |
| 326 | + </div> | |
| 327 | + <SkStatus :tone="a.found ? 'ok' : 'faint'" :label="a.found ? `Detected · ${a.count} item${a.count === 1 ? '' : 's'}` : 'Not found'" /> | |
| 328 | + </SkRow> | |
| 329 | + </SkPanel> | |
| 330 | + <p v-if="home && !home.agentsDetected.length" class="m-0 text-xs text-sk-faint">Nothing detected yet. You can still manage projects; skilly writes the agent folders when you apply.</p> | |
| 331 | + </template> | |
| 332 | + | |
| 333 | + <!-- 3 Projects --> | |
| 334 | + <template v-else-if="step === 3"> | |
| 335 | + <SkSectionLabel>Step 3 of 3</SkSectionLabel> | |
| 336 | + <h1 class="m-0 font-mono text-[22px] font-normal tracking-[-0.01em]">Add your projects</h1> | |
| 337 | + <p class="m-0 text-sk-muted">Folders with agent files, found in your usual code folders. You can add more later.</p> | |
| 338 | + <SkNote v-if="scanning" tone="info">Looking for projects…</SkNote> | |
| 339 | + <SkPanel v-if="candidates.length"> | |
| 340 | + <SkRow v-for="c in candidates" :key="c.path" cols="18px minmax(0,1fr) auto" :faded="c.added"> | |
| 341 | + <UCheckbox v-model="c.checked" :disabled="c.added" :aria-label="`Add ${c.name}`" /> | |
| 342 | + <div class="min-w-0"> | |
| 343 | + <div class="truncate font-medium">{{ c.name }}</div> | |
| 344 | + <div class="truncate font-mono text-[11px] text-sk-faint" :title="c.path">{{ tilde(c.path) }}</div> | |
| 345 | + </div> | |
| 346 | + <span v-if="c.added" class="text-xs text-sk-faint">Already added</span> | |
| 347 | + <span v-else class="font-mono text-[11px] text-sk-faint">{{ c.markers.filter(m => m.endsWith('.md')).join(' · ') || c.markers.join(' · ') }}</span> | |
| 348 | + </SkRow> | |
| 349 | + </SkPanel> | |
| 350 | + <SkEmpty v-else-if="scanned && !scanning" dashed text="No project with agent files found in the usual folders. Add a folder, or scan one." /> | |
| 351 | + <SkNote v-for="e in addErrors" :key="e" tone="accent">{{ e }}</SkNote> | |
| 352 | + <div class="flex flex-wrap gap-2"> | |
| 353 | + <SkButton label="Add a folder…" @click="addPicker = true" /> | |
| 354 | + <SkButton variant="ghost" label="Scan a folder…" @click="scanPicker = true" /> | |
| 355 | + </div> | |
| 356 | + </template> | |
| 357 | + | |
| 358 | + <!-- 4 Ready --> | |
| 359 | + <template v-else> | |
| 360 | + <h1 class="m-0 mt-3 font-mono text-[26px] leading-tight font-normal tracking-[-0.01em]">You're set up</h1> | |
| 361 | + <p class="m-0 max-w-[480px] text-pretty text-sk-muted">skilly writes the enabled items into each project. The files are committed with the project, so it works without skilly too.</p> | |
| 362 | + <SkPanel> | |
| 363 | + <SkRow v-for="[k, v] in summary" :key="k" cols="110px minmax(0,1fr)"> | |
| 364 | + <span class="text-sk-muted">{{ k }}</span> | |
| 365 | + <span class="truncate text-right" :class="k === 'Location' && 'font-mono text-xs'" :title="v">{{ v }}</span> | |
| 366 | + </SkRow> | |
| 367 | + </SkPanel> | |
| 368 | + </template> | |
| 369 | + </div> | |
| 370 | + | |
| 371 | + <!-- Footer --> | |
| 372 | + <div v-if="step > 0" class="mt-6 flex items-center justify-between gap-3"> | |
| 373 | + <SkButton variant="ghost" label="Back" @click="back" /> | |
| 374 | + <SkButton variant="primary" :label="nextLabel" :loading="addingProjects" :disabled="!canContinue" @click="next" /> | |
| 375 | + </div> | |
| 376 | + <div v-else class="mt-6"><SkButton variant="ghost" label="Skip setup" class="md:hidden" @click="finish()" /></div> | |
| 377 | + </div> | |
| 10 | 378 | </SkPanel> |
| 379 | + | |
| 380 | + <FolderPickerModal v-model:open="folderPicker" title="Choose the catalog folder" :start="catalogPath" @pick="(p: string) => catalogPath = p" /> | |
| 381 | + <FolderPickerModal v-model:open="addPicker" title="Add a project folder" @pick="addFolder" /> | |
| 382 | + <FolderPickerModal v-model:open="scanPicker" title="Scan a folder for projects" @pick="scanFolder" /> | |
| 11 | 383 | </div> |
| 12 | 384 | </template> |
| @@ -1,12 +1,384 @@ | |||
| 1 | <script setup lang="ts"> | 1 | <script setup lang="ts"> |
| 2 | -// PLACEHOLDER (WP3 owns this page). Uses the bare layout (no header): the 860px card of the mockup goes here. | 2 | +import type { CatalogResponse, DiscoveredProject, FsDiscovery, FsListing, Settings } from '@skilly/core' |
| 3 | + | ||
| 4 | +/** | ||
| 5 | + * Onboarding (design/BRIEF.md 2.1 + 4.8), bare layout. Five steps: Welcome, Catalog (restore = git clone, or start | ||
| 6 | + * a new one), Agents (read-only detection summary), Projects (discovered folders), Ready. Skippable at any time. | ||
| 7 | + */ | ||
| 3 | definePageMeta({ layout: 'bare' }) | 8 | definePageMeta({ layout: 'bare' }) |
| 9 | + | ||
| 10 | +const { request } = useApi() | ||
| 11 | +const catalogState = useCatalog() | ||
| 12 | +const { projects, refresh: refreshProjects, add: addProject } = useProjects() | ||
| 13 | +const { home, refresh: refreshHome } = useHomeInventory() | ||
| 14 | +const router = useRouter() | ||
| 15 | + | ||
| 16 | +const plural = (n: number, w: string) => `${n} ${w}${n === 1 ? '' : 's'}` | ||
| 17 | +const STEPS = ['Welcome', 'Catalog', 'Agents', 'Projects', 'Ready'] as const | ||
| 18 | +const step = ref(0) | ||
| 19 | +const mode = ref<'restore' | 'fresh' | null>(null) | ||
| 20 | + | ||
| 21 | +/* ------------------------------------------------ Catalog */ | ||
| 22 | + | ||
| 23 | +const settings = ref<Settings | null>(null) | ||
| 24 | +const catalogPath = ref('') | ||
| 25 | +const remoteUrl = ref('') | ||
| 26 | +const urlTouched = ref(false) | ||
| 27 | +const connecting = ref(false) | ||
| 28 | +const connectError = ref<string | null>(null) | ||
| 29 | +const result = ref<CatalogResponse | null>(null) | ||
| 30 | +const folderPicker = ref(false) | ||
| 31 | + | ||
| 32 | +const URL_RE = /^(?:(?:https?|ssh|git|file):\/\/\S+|[\w.-]+@[\w.-]+:\S+|[A-Za-z]:[\\/]\S*|\/\S+)$/ | ||
| 33 | +const urlValid = computed(() => URL_RE.test(remoteUrl.value.trim())) | ||
| 34 | +const urlError = computed(() => (urlTouched.value && remoteUrl.value.trim() && !urlValid.value | ||
| 35 | + ? 'Use an SSH address (git@github.com:you/catalog.git) or an HTTPS URL.' | ||
| 36 | + : null)) | ||
| 37 | +const connected = computed(() => !!result.value?.git.isRepo && mode.value === 'restore') | ||
| 38 | +const existing = computed(() => (catalogState.data.value?.snapshot.exists ? catalogState.data.value : null)) | ||
| 39 | + | ||
| 40 | +onMounted(async () => { | ||
| 41 | + try { | ||
| 42 | + settings.value = await request<Settings>('/api/settings', { silent: true }) | ||
| 43 | + catalogPath.value = settings.value.catalogPath | ||
| 44 | + } catch { /* the step shows an error when acting */ } | ||
| 45 | + await Promise.all([catalogState.refresh(), refreshProjects(), refreshHome()]) | ||
| 46 | + if (catalogState.exists.value) result.value = catalogState.data.value | ||
| 47 | +}) | ||
| 48 | + | ||
| 49 | +async function connect() { | ||
| 50 | + urlTouched.value = true | ||
| 51 | + if (!urlValid.value) return | ||
| 52 | + connecting.value = true | ||
| 53 | + connectError.value = null | ||
| 54 | + try { | ||
| 55 | + const body: { url: string, path?: string } = { url: remoteUrl.value.trim() } | ||
| 56 | + if (catalogPath.value && catalogPath.value !== settings.value?.catalogPath) body.path = catalogPath.value | ||
| 57 | + result.value = await request<CatalogResponse>('/api/catalog/clone', { method: 'POST', body, silent: true }) | ||
| 58 | + catalogState.data.value = result.value | ||
| 59 | + if (settings.value) settings.value = { ...settings.value, catalogPath: result.value.snapshot.root } | ||
| 60 | + } catch (e) { | ||
| 61 | + connectError.value = (e as Error).message | ||
| 62 | + } finally { | ||
| 63 | + connecting.value = false | ||
| 64 | + } | ||
| 65 | +} | ||
| 66 | + | ||
| 67 | +async function createCatalog() { | ||
| 68 | + connecting.value = true | ||
| 69 | + connectError.value = null | ||
| 70 | + try { | ||
| 71 | + if (catalogPath.value && catalogPath.value !== settings.value?.catalogPath) { | ||
| 72 | + settings.value = await request<Settings>('/api/settings', { method: 'PUT', body: { catalogPath: catalogPath.value }, silent: true }) | ||
| 73 | + } | ||
| 74 | + result.value = await request<CatalogResponse>('/api/catalog/init', { method: 'POST', silent: true }) | ||
| 75 | + catalogState.data.value = result.value | ||
| 76 | + } catch (e) { | ||
| 77 | + connectError.value = (e as Error).message | ||
| 78 | + } finally { | ||
| 79 | + connecting.value = false | ||
| 80 | + } | ||
| 81 | +} | ||
| 82 | + | ||
| 83 | +const catalogReady = computed(() => !!result.value?.snapshot.exists) | ||
| 84 | + | ||
| 85 | +/* ------------------------------------------------ Agents */ | ||
| 86 | + | ||
| 87 | +const AGENTS = [ | ||
| 88 | + { id: 'claude', name: 'Claude Code', where: '~/.claude' }, | ||
| 89 | + { id: 'codex', name: 'Codex', where: '~/.codex · ~/.agents' } | ||
| 90 | +] as const | ||
| 91 | +const agentRows = computed(() => AGENTS.map(a => ({ | ||
| 92 | + ...a, | ||
| 93 | + found: !!home.value?.agentsDetected.includes(a.id), | ||
| 94 | + count: home.value?.items.filter(i => i.agent === a.id && i.type !== 'instructions').length ?? 0 | ||
| 95 | +}))) | ||
| 96 | + | ||
| 97 | +/* ------------------------------------------------ Projects */ | ||
| 98 | + | ||
| 99 | +interface Candidate extends DiscoveredProject { checked: boolean, added: boolean } | ||
| 100 | +const candidates = ref<Candidate[]>([]) | ||
| 101 | +const scanning = ref(false) | ||
| 102 | +const scanned = ref(false) | ||
| 103 | +const addingProjects = ref(false) | ||
| 104 | +const addedCount = ref(0) | ||
| 105 | +const addErrors = ref<string[]>([]) | ||
| 106 | +const scanPicker = ref(false) | ||
| 107 | +const addPicker = ref(false) | ||
| 108 | +const ROOT_NAMES = new Set(['code', 'dev', 'work', 'projects', 'project', 'src', 'repos', 'repo', 'git', 'workspace', 'workspaces', 'developer', 'sites', 'github']) | ||
| 109 | + | ||
| 110 | +const sameKey = (p: string) => p.replace(/[\\/]+$/, '').toLowerCase() | ||
| 111 | +const known = computed(() => new Set(projects.value.map(p => sameKey(p.path)))) | ||
| 112 | +const homeDir = computed(() => home.value?.root ?? '') | ||
| 113 | +function tilde(p: string): string { | ||
| 114 | + const h = homeDir.value | ||
| 115 | + return h && sameKey(p).startsWith(sameKey(h) + (p.includes('\\') ? '\\' : '/')) ? '~' + p.slice(h.length).replace(/\\/g, '/') : p | ||
| 116 | +} | ||
| 117 | + | ||
| 118 | +function merge(found: DiscoveredProject[], checked = true) { | ||
| 119 | + const have = new Set(candidates.value.map(c => sameKey(c.path))) | ||
| 120 | + for (const f of found) { | ||
| 121 | + if (have.has(sameKey(f.path))) continue | ||
| 122 | + const added = known.value.has(sameKey(f.path)) | ||
| 123 | + candidates.value.push({ ...f, added, checked: checked && !added }) | ||
| 124 | + } | ||
| 125 | +} | ||
| 126 | + | ||
| 127 | +async function discover(root: string, depth = 3): Promise<DiscoveredProject[]> { | ||
| 128 | + try { | ||
| 129 | + return (await request<FsDiscovery>('/api/fs/discover', { query: { root, depth }, silent: true })).entries | ||
| 130 | + } catch { | ||
| 131 | + return [] | ||
| 132 | + } | ||
| 133 | +} | ||
| 134 | + | ||
| 135 | +async function scanDefaults() { | ||
| 136 | + scanning.value = true | ||
| 137 | + try { | ||
| 138 | + const roots = await request<FsListing>('/api/fs/list', { silent: true }) | ||
| 139 | + const parents = roots.entries.map(e => e.path) | ||
| 140 | + const dirs: string[] = [] | ||
| 141 | + await Promise.all(parents.map(async (p) => { | ||
| 142 | + try { | ||
| 143 | + const l = await request<FsListing>('/api/fs/list', { query: { path: p }, silent: true }) | ||
| 144 | + for (const e of l.entries) if (ROOT_NAMES.has(e.name.toLowerCase())) dirs.push(e.path) | ||
| 145 | + } catch { /* unreadable root */ } | ||
| 146 | + })) | ||
| 147 | + const found = (await Promise.all(dirs.slice(0, 8).map(d => discover(d)))).flat() | ||
| 148 | + merge(found) | ||
| 149 | + } finally { | ||
| 150 | + scanning.value = false | ||
| 151 | + scanned.value = true | ||
| 152 | + } | ||
| 153 | +} | ||
| 154 | + | ||
| 155 | +async function scanFolder(path: string) { | ||
| 156 | + scanning.value = true | ||
| 157 | + try { merge(await discover(path, 3)) } finally { scanning.value = false } | ||
| 158 | +} | ||
| 159 | + | ||
| 160 | +function addFolder(path: string) { | ||
| 161 | + const name = path.replace(/[\\/]+$/, '').split(/[\\/]/).pop() || path | ||
| 162 | + merge([{ name, path, hasAgentConfig: false, markers: [] }]) | ||
| 163 | + const c = candidates.value.find(x => sameKey(x.path) === sameKey(path)) | ||
| 164 | + if (c && !c.added) c.checked = true | ||
| 165 | +} | ||
| 166 | + | ||
| 167 | +const selectedCandidates = computed(() => candidates.value.filter(c => c.checked && !c.added)) | ||
| 168 | + | ||
| 169 | +async function addSelected(): Promise<boolean> { | ||
| 170 | + addingProjects.value = true | ||
| 171 | + addErrors.value = [] | ||
| 172 | + let n = 0 | ||
| 173 | + for (const c of selectedCandidates.value) { | ||
| 174 | + try { | ||
| 175 | + await addProject(c.path) | ||
| 176 | + c.added = true | ||
| 177 | + c.checked = false | ||
| 178 | + n++ | ||
| 179 | + } catch (e) { | ||
| 180 | + const err = e as { status?: number, message: string } | ||
| 181 | + if (err.status === 409) { c.added = true; c.checked = false } else addErrors.value.push(`${c.name}: ${err.message}`) | ||
| 182 | + } | ||
| 183 | + } | ||
| 184 | + addedCount.value += n | ||
| 185 | + addingProjects.value = false | ||
| 186 | + return addErrors.value.length === 0 | ||
| 187 | +} | ||
| 188 | + | ||
| 189 | +/* ------------------------------------------------ Navigation */ | ||
| 190 | + | ||
| 191 | +const SKIP_KEY = 'skilly-onboarding-done' | ||
| 192 | +function finish(to = '/projects') { | ||
| 193 | + try { localStorage.setItem(SKIP_KEY, '1') } catch { /* private mode */ } | ||
| 194 | + return router.push(to) | ||
| 195 | +} | ||
| 196 | + | ||
| 197 | +const canContinue = computed(() => { | ||
| 198 | + if (step.value === 0) return false | ||
| 199 | + if (step.value === 1) return mode.value === 'restore' ? connected.value || !!existing.value : true | ||
| 200 | + return true | ||
| 201 | +}) | ||
| 202 | +const nextLabel = computed(() => { | ||
| 203 | + if (step.value === 1 && mode.value === 'fresh' && !catalogReady.value) return 'Skip for now' | ||
| 204 | + if (step.value === 3) return selectedCandidates.value.length ? `Add ${selectedCandidates.value.length} and continue` : 'Skip for now' | ||
| 205 | + if (step.value === 4) return 'Open skilly' | ||
| 206 | + return 'Continue' | ||
| 207 | +}) | ||
| 208 | + | ||
| 209 | +async function next() { | ||
| 210 | + if (step.value === 3 && selectedCandidates.value.length && !(await addSelected())) return | ||
| 211 | + if (step.value === 4) return finish() | ||
| 212 | + step.value++ | ||
| 213 | +} | ||
| 214 | +function back() { step.value = Math.max(0, step.value - 1) } | ||
| 215 | +function choose(m: 'restore' | 'fresh') { mode.value = m; step.value = 1 } | ||
| 216 | + | ||
| 217 | +watch(step, (s) => { if (s === 3 && !scanned.value && !scanning.value) scanDefaults() }) | ||
| 218 | + | ||
| 219 | +const summary = computed(() => [ | ||
| 220 | + ['Catalog', catalogReady.value ? `${plural(result.value!.snapshot.items.length, 'item')} · ${plural(result.value!.snapshot.presets.length, 'preset')}` : 'Not created yet'], | ||
| 221 | + ['Location', catalogPath.value || '-'], | ||
| 222 | + ['Agents', agentRows.value.filter(a => a.found).map(a => a.name).join(', ') || 'None detected'], | ||
| 223 | + ['Projects', `${projects.value.length} managed${addedCount.value ? ` (${addedCount.value} added now)` : ''}`] | ||
| 224 | +] as const) | ||
| 4 | </script> | 225 | </script> |
| 5 | 226 | ||
| 6 | <template> | 227 | <template> |
| 7 | - <div class="flex flex-1 items-center justify-center p-8"> | 228 | + <div class="flex flex-1 items-center justify-center p-4 sm:p-8"> |
| 8 | - <SkPanel hero class="w-full max-w-[860px]"> | 229 | + <SkPanel hero class="grid w-full max-w-[860px] md:min-h-[520px] md:grid-cols-[200px_minmax(0,1fr)]"> |
| 9 | - <SkEmpty title="Onboarding" text="Not built yet (WP3)." /> | 230 | + <!-- Steps --> |
| 231 | + <aside class="hidden flex-col gap-5 border-r border-sk-line2 bg-sk-panel p-[18px] md:flex"> | ||
| 232 | + <div class="flex items-center gap-2"><SkLogo /><span class="text-base font-semibold">skilly</span></div> | ||
| 233 | + <ol class="m-0 flex list-none flex-col gap-3 p-0"> | ||
| 234 | + <li v-for="(s, i) in STEPS" :key="s" class="flex items-center gap-2.5" :aria-current="i === step ? 'step' : undefined"> | ||
| 235 | + <span | ||
| 236 | + class="flex size-5 shrink-0 items-center justify-center rounded-full border text-[11px]" | ||
| 237 | + :class="i < step ? 'border-sk-ink bg-sk-ink text-sk-onink' : i === step ? 'border-sk-ink text-sk-text' : 'border-sk-line text-sk-faint'" | ||
| 238 | + >{{ i < step ? '✓' : i + 1 }}</span> | ||
| 239 | + <span :class="i === step ? 'font-semibold text-sk-text' : i < step ? 'text-sk-muted' : 'text-sk-faint'">{{ s }}</span> | ||
| 240 | + </li> | ||
| 241 | + </ol> | ||
| 242 | + <span class="flex-1" /> | ||
| 243 | + <SkButton variant="ghost" size="sm" label="Skip setup" class="self-start" @click="finish()" /> | ||
| 244 | + </aside> | ||
| 245 | + | ||
| 246 | + <!-- Content --> | ||
| 247 | + <div class="flex min-w-0 flex-col p-[26px] sm:p-[32px]"> | ||
| 248 | + <div class="flex flex-1 flex-col gap-4"> | ||
| 249 | + <!-- 0 Welcome --> | ||
| 250 | + <template v-if="step === 0"> | ||
| 251 | + <h1 class="m-0 mt-3 font-mono text-[26px] leading-tight font-normal tracking-[-0.01em]">Welcome to skilly</h1> | ||
| 252 | + <p class="m-0 max-w-[460px] text-pretty text-sk-muted">Manage the skills, rules, MCP servers, hooks and subagents your coding agents use, across all your projects, from one place.</p> | ||
| 253 | + <div class="mt-3 grid gap-3 sm:grid-cols-2"> | ||
| 254 | + <button type="button" class="cursor-pointer rounded-[10px] border border-sk-line bg-sk-panel p-4 text-left transition-colors hover:border-sk-faint" @click="choose('restore')"> | ||
| 255 | + <span class="block font-semibold">Restore a catalog</span> | ||
| 256 | + <span class="mt-1 block text-pretty text-sk-muted">I already have a catalog in a git repository. Set up this machine from it.</span> | ||
| 257 | + </button> | ||
| 258 | + <button type="button" class="cursor-pointer rounded-[10px] border border-sk-line bg-sk-panel p-4 text-left transition-colors hover:border-sk-faint" @click="choose('fresh')"> | ||
| 259 | + <span class="block font-semibold">Start a new catalog</span> | ||
| 260 | + <span class="mt-1 block text-pretty text-sk-muted">Create an empty catalog and fill it from what is already on this machine.</span> | ||
| 261 | + </button> | ||
| 262 | + </div> | ||
| 263 | + </template> | ||
| 264 | + | ||
| 265 | + <!-- 1 Catalog --> | ||
| 266 | + <template v-else-if="step === 1"> | ||
| 267 | + <SkSectionLabel>Step 1 of 3</SkSectionLabel> | ||
| 268 | + <h1 class="m-0 font-mono text-[22px] font-normal tracking-[-0.01em]">{{ mode === 'restore' ? 'Connect your catalog' : 'Create your catalog' }}</h1> | ||
| 269 | + <p class="m-0 text-sk-muted">{{ mode === 'restore' ? 'skilly clones the repository and restores your items and presets on this machine.' : 'An empty catalog in a git repository. Add a remote later from the Sync page to share it.' }}</p> | ||
| 270 | + | ||
| 271 | + <div class="flex flex-col gap-1.5"> | ||
| 272 | + <span class="font-medium">Catalog folder</span> | ||
| 273 | + <div class="flex items-center gap-2"> | ||
| 274 | + <span class="min-w-0 flex-1 truncate rounded-[7px] border border-sk-line bg-sk-bg px-2.5 py-[7px] font-mono text-xs" :title="catalogPath">{{ catalogPath || '-' }}</span> | ||
| 275 | + <SkButton label="Change" :disabled="connecting || connected || (mode === 'fresh' && catalogReady)" @click="folderPicker = true" /> | ||
| 276 | + </div> | ||
| 277 | + </div> | ||
| 278 | + | ||
| 279 | + <template v-if="mode === 'restore'"> | ||
| 280 | + <form class="flex flex-col gap-1.5" @submit.prevent="connect"> | ||
| 281 | + <label class="font-medium" for="remote-url">Git remote URL</label> | ||
| 282 | + <div class="flex items-start gap-2"> | ||
| 283 | + <div class="flex min-w-0 flex-1 flex-col gap-1"> | ||
| 284 | + <UInput | ||
| 285 | + id="remote-url" | ||
| 286 | + v-model="remoteUrl" | ||
| 287 | + placeholder="git@github.com:you/agent-catalog.git" | ||
| 288 | + :disabled="connecting || connected" | ||
| 289 | + :class="(urlError || connectError) && '[&_input]:ring-sk-accent'" | ||
| 290 | + :ui="{ base: 'font-mono' }" | ||
| 291 | + @blur="urlTouched = true" | ||
| 292 | + @update:model-value="connectError = null" | ||
| 293 | + /> | ||
| 294 | + <p v-if="urlError" class="m-0 text-xs text-sk-accent-text">{{ urlError }}</p> | ||
| 295 | + </div> | ||
| 296 | + <SkButton type="submit" variant="primary" :label="connected ? 'Connected' : connecting ? 'Connecting…' : 'Connect'" :loading="connecting" :disabled="connected || !remoteUrl.trim()" /> | ||
| 297 | + </div> | ||
| 298 | + <p class="m-0 text-xs text-pretty text-sk-faint">GitHub, GitLab or self-hosted. The catalog never stores secrets: MCP configs only reference environment variable names.</p> | ||
| 299 | + </form> | ||
| 300 | + <SkNote v-if="connecting" tone="info">Cloning the repository… this can take a moment.</SkNote> | ||
| 301 | + <SkNote v-if="connectError" tone="accent"><span class="font-mono text-xs break-words whitespace-pre-wrap">{{ connectError }}</span></SkNote> | ||
| 302 | + <SkNote v-if="connected && result" tone="ok"> | ||
| 303 | + Restored {{ plural(result.snapshot.items.length, 'item') }} and {{ plural(result.snapshot.presets.length, 'preset') }} | ||
| 304 | + <template #aside><span class="font-mono text-[11px] text-sk-faint">{{ result.git.branch }}</span></template> | ||
| 305 | + </SkNote> | ||
| 306 | + <SkNote v-else-if="!connected && existing" tone="faint">A catalog already exists at this location ({{ plural(existing.snapshot.items.length, 'item') }}). Change the folder to restore into a new one, or continue to keep it.</SkNote> | ||
| 307 | + </template> | ||
| 308 | + | ||
| 309 | + <template v-else> | ||
| 310 | + <SkNote v-if="catalogReady && result" tone="ok">Catalog ready · {{ plural(result.snapshot.items.length, 'item') }}, {{ plural(result.snapshot.presets.length, 'preset') }}</SkNote> | ||
| 311 | + <div v-else><SkButton variant="primary" label="Create the catalog now" :loading="connecting" @click="createCatalog" /></div> | ||
| 312 | + <SkNote v-if="connectError" tone="accent"><span class="font-mono text-xs break-words whitespace-pre-wrap">{{ connectError }}</span></SkNote> | ||
| 313 | + </template> | ||
| 314 | + </template> | ||
| 315 | + | ||
| 316 | + <!-- 2 Agents --> | ||
| 317 | + <template v-else-if="step === 2"> | ||
| 318 | + <SkSectionLabel>Step 2 of 3</SkSectionLabel> | ||
| 319 | + <h1 class="m-0 font-mono text-[22px] font-normal tracking-[-0.01em]">Agents on this machine</h1> | ||
| 320 | + <p class="m-0 text-sk-muted">What skilly found in your home folder. Each project chooses the agents it writes for.</p> | ||
| 321 | + <SkPanel> | ||
| 322 | + <SkRow v-for="a in agentRows" :key="a.id" cols="minmax(0,1fr) auto" class="!py-3"> | ||
| 323 | + <div class="min-w-0"> | ||
| 324 | + <div class="font-medium">{{ a.name }}</div> | ||
| 325 | + <div class="font-mono text-[11px] text-sk-faint">{{ a.where }}</div> | ||
| 326 | + </div> | ||
| 327 | + <SkStatus :tone="a.found ? 'ok' : 'faint'" :label="a.found ? `Detected · ${a.count} item${a.count === 1 ? '' : 's'}` : 'Not found'" /> | ||
| 328 | + </SkRow> | ||
| 329 | + </SkPanel> | ||
| 330 | + <p v-if="home && !home.agentsDetected.length" class="m-0 text-xs text-sk-faint">Nothing detected yet. You can still manage projects; skilly writes the agent folders when you apply.</p> | ||
| 331 | + </template> | ||
| 332 | + | ||
| 333 | + <!-- 3 Projects --> | ||
| 334 | + <template v-else-if="step === 3"> | ||
| 335 | + <SkSectionLabel>Step 3 of 3</SkSectionLabel> | ||
| 336 | + <h1 class="m-0 font-mono text-[22px] font-normal tracking-[-0.01em]">Add your projects</h1> | ||
| 337 | + <p class="m-0 text-sk-muted">Folders with agent files, found in your usual code folders. You can add more later.</p> | ||
| 338 | + <SkNote v-if="scanning" tone="info">Looking for projects…</SkNote> | ||
| 339 | + <SkPanel v-if="candidates.length"> | ||
| 340 | + <SkRow v-for="c in candidates" :key="c.path" cols="18px minmax(0,1fr) auto" :faded="c.added"> | ||
| 341 | + <UCheckbox v-model="c.checked" :disabled="c.added" :aria-label="`Add ${c.name}`" /> | ||
| 342 | + <div class="min-w-0"> | ||
| 343 | + <div class="truncate font-medium">{{ c.name }}</div> | ||
| 344 | + <div class="truncate font-mono text-[11px] text-sk-faint" :title="c.path">{{ tilde(c.path) }}</div> | ||
| 345 | + </div> | ||
| 346 | + <span v-if="c.added" class="text-xs text-sk-faint">Already added</span> | ||
| 347 | + <span v-else class="font-mono text-[11px] text-sk-faint">{{ c.markers.filter(m => m.endsWith('.md')).join(' · ') || c.markers.join(' · ') }}</span> | ||
| 348 | + </SkRow> | ||
| 349 | + </SkPanel> | ||
| 350 | + <SkEmpty v-else-if="scanned && !scanning" dashed text="No project with agent files found in the usual folders. Add a folder, or scan one." /> | ||
| 351 | + <SkNote v-for="e in addErrors" :key="e" tone="accent">{{ e }}</SkNote> | ||
| 352 | + <div class="flex flex-wrap gap-2"> | ||
| 353 | + <SkButton label="Add a folder…" @click="addPicker = true" /> | ||
| 354 | + <SkButton variant="ghost" label="Scan a folder…" @click="scanPicker = true" /> | ||
| 355 | + </div> | ||
| 356 | + </template> | ||
| 357 | + | ||
| 358 | + <!-- 4 Ready --> | ||
| 359 | + <template v-else> | ||
| 360 | + <h1 class="m-0 mt-3 font-mono text-[26px] leading-tight font-normal tracking-[-0.01em]">You're set up</h1> | ||
| 361 | + <p class="m-0 max-w-[480px] text-pretty text-sk-muted">skilly writes the enabled items into each project. The files are committed with the project, so it works without skilly too.</p> | ||
| 362 | + <SkPanel> | ||
| 363 | + <SkRow v-for="[k, v] in summary" :key="k" cols="110px minmax(0,1fr)"> | ||
| 364 | + <span class="text-sk-muted">{{ k }}</span> | ||
| 365 | + <span class="truncate text-right" :class="k === 'Location' && 'font-mono text-xs'" :title="v">{{ v }}</span> | ||
| 366 | + </SkRow> | ||
| 367 | + </SkPanel> | ||
| 368 | + </template> | ||
| 369 | + </div> | ||
| 370 | + | ||
| 371 | + <!-- Footer --> | ||
| 372 | + <div v-if="step > 0" class="mt-6 flex items-center justify-between gap-3"> | ||
| 373 | + <SkButton variant="ghost" label="Back" @click="back" /> | ||
| 374 | + <SkButton variant="primary" :label="nextLabel" :loading="addingProjects" :disabled="!canContinue" @click="next" /> | ||
| 375 | + </div> | ||
| 376 | + <div v-else class="mt-6"><SkButton variant="ghost" label="Skip setup" class="md:hidden" @click="finish()" /></div> | ||
| 377 | + </div> | ||
| 10 | </SkPanel> | 378 | </SkPanel> |
| 379 | + | ||
| 380 | + <FolderPickerModal v-model:open="folderPicker" title="Choose the catalog folder" :start="catalogPath" @pick="(p: string) => catalogPath = p" /> | ||
| 381 | + <FolderPickerModal v-model:open="addPicker" title="Add a project folder" @pick="addFolder" /> | ||
| 382 | + <FolderPickerModal v-model:open="scanPicker" title="Scan a folder for projects" @pick="scanFolder" /> | ||
| 11 | </div> | 383 | </div> |
| 12 | </template> | 384 | </template> |
modified
packages/ui/app/pages/projects/[id].vue +1 -1 | @@ -4,5 +4,5 @@ const id = computed(() => String(route.params.id)) | ||
| 4 | 4 | </script> |
| 5 | 5 | |
| 6 | 6 | <template> |
| 7 | - <ScopeView :key="id" kind="project" :project-id="id" /> | |
| 7 | + <ScopePage :key="id" kind="project" :project-id="id" /> | |
| 8 | 8 | </template> |
| @@ -4,5 +4,5 @@ const id = computed(() => String(route.params.id)) | |||
| 4 | </script> | 4 | </script> |
| 5 | 5 | ||
| 6 | <template> | 6 | <template> |
| 7 | - <ScopeView :key="id" kind="project" :project-id="id" /> | 7 | + <ScopePage :key="id" kind="project" :project-id="id" /> |
| 8 | </template> | 8 | </template> |
modified
packages/ui/app/pages/projects/index.vue +9 -145 | @@ -1,150 +1,14 @@ | ||
| 1 | 1 | <script setup lang="ts"> |
| 2 | -import type { ItemType, ScopeInventory, ScopeState } from '@skilly/core' | |
| 3 | - | |
| 4 | -const { projects, loaded, error: projectsError, refresh: refreshProjects } = useProjects() | |
| 5 | -const { home, loading: homeLoading, error: homeError, refresh: refreshHome } = useHomeInventory() | |
| 6 | -const { request } = useApi() | |
| 7 | -const addOpen = useState('skilly-add-open', () => false) | |
| 8 | - | |
| 9 | -interface Sync { label: string, color: 'success' | 'warning' | 'neutral' | 'error', icon: string } | |
| 10 | -const syncs = ref<Record<string, Sync | null>>({}) | |
| 11 | - | |
| 12 | -function toSync(s: ScopeState): Sync { | |
| 13 | - if (!s.config) return { label: 'not managed', color: 'neutral', icon: 'i-lucide-circle-dashed' } | |
| 14 | - const pending = (s.plan?.actions ?? []).filter(a => a.kind !== 'unchanged') | |
| 15 | - const conflicts = pending.filter(a => a.kind === 'conflict').length | |
| 16 | - if (conflicts) return { label: `${conflicts} conflict${conflicts > 1 ? 's' : ''}`, color: 'warning', icon: 'i-lucide-triangle-alert' } | |
| 17 | - if (pending.length) return { label: `${pending.length} change${pending.length > 1 ? 's' : ''}`, color: 'warning', icon: 'i-lucide-refresh-cw' } | |
| 18 | - return { label: 'in sync', color: 'success', icon: 'i-lucide-check' } | |
| 19 | -} | |
| 20 | - | |
| 21 | -// Sync state needs a scan of each scope: fetch lazily, three at a time, and tolerate failures (the card then shows counts only). | |
| 22 | -async function loadSyncs() { | |
| 23 | - const ids = ['home', ...projects.value.filter(p => p.exists).map(p => p.id)] | |
| 24 | - const queue = [...ids] | |
| 25 | - const worker = async () => { | |
| 26 | - for (let id = queue.shift(); id; id = queue.shift()) { | |
| 27 | - try { | |
| 28 | - syncs.value[id] = toSync(await request<ScopeState>(`/api/scopes/${encodeURIComponent(id)}/state`, { silent: true })) | |
| 29 | - } catch { | |
| 30 | - syncs.value[id] = null | |
| 31 | - } | |
| 32 | - } | |
| 33 | - } | |
| 34 | - await Promise.all([worker(), worker(), worker()]) | |
| 35 | -} | |
| 36 | - | |
| 37 | -onMounted(async () => { | |
| 38 | - if (!home.value) refreshHome() | |
| 39 | - // Projects may have changed on disk (e.g. a folder was deleted) since the layout loaded them. | |
| 40 | - if (loaded.value) await refreshProjects() | |
| 41 | - else await new Promise<void>((res) => { const stop = watch(loaded, (v) => { if (v) { stop(); res() } }) }) | |
| 42 | - loadSyncs() | |
| 43 | -}) | |
| 44 | - | |
| 45 | -function countItems(inv: ScopeInventory): Record<ItemType, number> { | |
| 46 | - const c = Object.fromEntries(TYPE_ORDER.map(t => [t, 0])) as Record<ItemType, number> | |
| 47 | - for (const i of inv.items) c[i.type]++ | |
| 48 | - return c | |
| 49 | -} | |
| 50 | - | |
| 51 | -const homeCounts = computed(() => (home.value ? countItems(home.value) : null)) | |
| 2 | +const addOpen = useAddProjectModal() | |
| 52 | 3 | </script> |
| 53 | 4 | |
| 54 | 5 | <template> |
| 55 | - <UDashboardPanel id="projects"> | |
| 56 | - <template #header> | |
| 57 | - <UDashboardNavbar title="Projects"> | |
| 58 | - <template #right> | |
| 59 | - <UButton icon="i-lucide-folder-plus" label="Add project" @click="addOpen = true" /> | |
| 60 | - </template> | |
| 61 | - </UDashboardNavbar> | |
| 62 | - </template> | |
| 63 | - | |
| 64 | - <template #body> | |
| 65 | - <div class="mx-auto w-full max-w-5xl space-y-6"> | |
| 66 | - <p class="text-sm text-muted"> | |
| 67 | - Everything your coding agents can see: skills, rules, MCP servers, hooks, subagents and instruction files. | |
| 68 | - </p> | |
| 69 | - | |
| 70 | - <div class="grid gap-3 md:grid-cols-2"> | |
| 71 | - <!-- Home --> | |
| 72 | - <NuxtLink to="/home" class="block rounded-xl border border-default p-3 transition-colors hover:bg-elevated/50 focus-visible:outline-2 focus-visible:outline-primary"> | |
| 73 | - <div class="flex items-center gap-2"> | |
| 74 | - <UIcon name="i-lucide-house" class="size-4 shrink-0 text-primary" /> | |
| 75 | - <h2 class="font-semibold">Home</h2> | |
| 76 | - <span class="truncate text-xs text-muted">user-level configuration</span> | |
| 77 | - <UBadge v-if="syncs.home" :label="syncs.home.label" :color="syncs.home.color" :icon="syncs.home.icon" variant="subtle" size="sm" class="ml-auto shrink-0" /> | |
| 78 | - <USkeleton v-else-if="syncs.home === undefined" class="ml-auto h-5 w-16" /> | |
| 79 | - </div> | |
| 80 | - <div v-if="homeLoading && !home" class="mt-2 space-y-2"><USkeleton class="h-4 w-full" /></div> | |
| 81 | - <UAlert v-else-if="homeError" color="error" variant="subtle" icon="i-lucide-circle-alert" :title="homeError" class="mt-2" /> | |
| 82 | - <div v-else-if="home && homeCounts" class="mt-2 space-y-2"> | |
| 83 | - <p class="truncate font-mono text-xs text-muted" :title="home.root">{{ home.root }}</p> | |
| 84 | - <div class="flex flex-wrap items-center gap-x-3 gap-y-1.5"> | |
| 85 | - <span class="flex gap-1"> | |
| 86 | - <AgentBadge v-for="a in home.agentsDetected" :key="a" :agent="a" compact /> | |
| 87 | - <span v-if="!home.agentsDetected.length" class="text-xs text-muted">No agent</span> | |
| 88 | - </span> | |
| 89 | - <CountChips :counts="homeCounts" compact /> | |
| 90 | - </div> | |
| 91 | - </div> | |
| 92 | - </NuxtLink> | |
| 93 | - | |
| 94 | - <!-- Projects --> | |
| 95 | - <template v-if="loaded"> | |
| 96 | - <NuxtLink | |
| 97 | - v-for="p in projects" | |
| 98 | - :key="p.id" | |
| 99 | - :to="`/projects/${encodeURIComponent(p.id)}`" | |
| 100 | - class="block rounded-xl border border-default p-3 transition-colors hover:bg-elevated/50 focus-visible:outline-2 focus-visible:outline-primary" | |
| 101 | - > | |
| 102 | - <div class="flex items-center gap-2"> | |
| 103 | - <UIcon name="i-lucide-folder" class="size-4 shrink-0 text-primary" /> | |
| 104 | - <h2 class="truncate font-semibold">{{ p.name }}</h2> | |
| 105 | - <UBadge v-if="!p.exists" label="Folder missing" color="warning" variant="subtle" icon="i-lucide-triangle-alert" size="sm" class="ml-auto shrink-0" /> | |
| 106 | - <UBadge v-else-if="syncs[p.id]" :label="syncs[p.id]!.label" :color="syncs[p.id]!.color" :icon="syncs[p.id]!.icon" variant="subtle" size="sm" class="ml-auto shrink-0" /> | |
| 107 | - <USkeleton v-else-if="syncs[p.id] === undefined" class="ml-auto h-5 w-16" /> | |
| 108 | - </div> | |
| 109 | - <div class="mt-2 space-y-2"> | |
| 110 | - <p class="truncate font-mono text-xs text-muted" :title="p.path">{{ p.path }}</p> | |
| 111 | - <div class="flex flex-wrap items-center gap-x-3 gap-y-1.5"> | |
| 112 | - <span class="flex gap-1"> | |
| 113 | - <AgentBadge v-for="a in p.agentsDetected" :key="a" :agent="a" compact /> | |
| 114 | - <span v-if="!p.agentsDetected.length" class="text-xs text-muted">No agent</span> | |
| 115 | - </span> | |
| 116 | - <CountChips :counts="p.counts" compact /> | |
| 117 | - </div> | |
| 118 | - </div> | |
| 119 | - </NuxtLink> | |
| 120 | - </template> | |
| 121 | - <template v-else> | |
| 122 | - <USkeleton class="h-24 w-full" /> | |
| 123 | - </template> | |
| 124 | - </div> | |
| 125 | - | |
| 126 | - <UAlert | |
| 127 | - v-if="projectsError" | |
| 128 | - color="error" | |
| 129 | - variant="subtle" | |
| 130 | - icon="i-lucide-circle-alert" | |
| 131 | - title="Could not load projects" | |
| 132 | - :description="projectsError" | |
| 133 | - :actions="[{ label: 'Retry', color: 'neutral', variant: 'outline', onClick: refreshProjects }]" | |
| 134 | - /> | |
| 135 | - | |
| 136 | - <div | |
| 137 | - v-else-if="loaded && !projects.length" | |
| 138 | - class="flex flex-col items-center gap-3 rounded-xl border border-dashed border-default py-14 text-center" | |
| 139 | - > | |
| 140 | - <UIcon name="i-lucide-folder-plus" class="size-10 text-dimmed" /> | |
| 141 | - <h3 class="font-semibold">Add your first project</h3> | |
| 142 | - <p class="max-w-md text-sm text-muted"> | |
| 143 | - Pick a folder to see which skills, rules, MCP servers and instructions Claude Code and Codex use there, then manage them from your catalog. | |
| 144 | - </p> | |
| 145 | - <UButton icon="i-lucide-folder-plus" label="Add project" @click="addOpen = true" /> | |
| 146 | - </div> | |
| 147 | - </div> | |
| 148 | - </template> | |
| 149 | - </UDashboardPanel> | |
| 6 | + <SkPage> | |
| 7 | + <SkPageHeader title="Projects" subtitle="Folders managed by skilly. Enabled items are written into each project."> | |
| 8 | + <template #actions> | |
| 9 | + <SkButton variant="primary" label="Add project" @click="addOpen = true" /> | |
| 10 | + </template> | |
| 11 | + </SkPageHeader> | |
| 12 | + <ProjectsTable /> | |
| 13 | + </SkPage> | |
| 150 | 14 | </template> |
| @@ -1,150 +1,14 @@ | |||
| 1 | <script setup lang="ts"> | 1 | <script setup lang="ts"> |
| 2 | -import type { ItemType, ScopeInventory, ScopeState } from '@skilly/core' | 2 | +const addOpen = useAddProjectModal() |
| 3 | - | ||
| 4 | -const { projects, loaded, error: projectsError, refresh: refreshProjects } = useProjects() | ||
| 5 | -const { home, loading: homeLoading, error: homeError, refresh: refreshHome } = useHomeInventory() | ||
| 6 | -const { request } = useApi() | ||
| 7 | -const addOpen = useState('skilly-add-open', () => false) | ||
| 8 | - | ||
| 9 | -interface Sync { label: string, color: 'success' | 'warning' | 'neutral' | 'error', icon: string } | ||
| 10 | -const syncs = ref<Record<string, Sync | null>>({}) | ||
| 11 | - | ||
| 12 | -function toSync(s: ScopeState): Sync { | ||
| 13 | - if (!s.config) return { label: 'not managed', color: 'neutral', icon: 'i-lucide-circle-dashed' } | ||
| 14 | - const pending = (s.plan?.actions ?? []).filter(a => a.kind !== 'unchanged') | ||
| 15 | - const conflicts = pending.filter(a => a.kind === 'conflict').length | ||
| 16 | - if (conflicts) return { label: `${conflicts} conflict${conflicts > 1 ? 's' : ''}`, color: 'warning', icon: 'i-lucide-triangle-alert' } | ||
| 17 | - if (pending.length) return { label: `${pending.length} change${pending.length > 1 ? 's' : ''}`, color: 'warning', icon: 'i-lucide-refresh-cw' } | ||
| 18 | - return { label: 'in sync', color: 'success', icon: 'i-lucide-check' } | ||
| 19 | -} | ||
| 20 | - | ||
| 21 | -// Sync state needs a scan of each scope: fetch lazily, three at a time, and tolerate failures (the card then shows counts only). | ||
| 22 | -async function loadSyncs() { | ||
| 23 | - const ids = ['home', ...projects.value.filter(p => p.exists).map(p => p.id)] | ||
| 24 | - const queue = [...ids] | ||
| 25 | - const worker = async () => { | ||
| 26 | - for (let id = queue.shift(); id; id = queue.shift()) { | ||
| 27 | - try { | ||
| 28 | - syncs.value[id] = toSync(await request<ScopeState>(`/api/scopes/${encodeURIComponent(id)}/state`, { silent: true })) | ||
| 29 | - } catch { | ||
| 30 | - syncs.value[id] = null | ||
| 31 | - } | ||
| 32 | - } | ||
| 33 | - } | ||
| 34 | - await Promise.all([worker(), worker(), worker()]) | ||
| 35 | -} | ||
| 36 | - | ||
| 37 | -onMounted(async () => { | ||
| 38 | - if (!home.value) refreshHome() | ||
| 39 | - // Projects may have changed on disk (e.g. a folder was deleted) since the layout loaded them. | ||
| 40 | - if (loaded.value) await refreshProjects() | ||
| 41 | - else await new Promise<void>((res) => { const stop = watch(loaded, (v) => { if (v) { stop(); res() } }) }) | ||
| 42 | - loadSyncs() | ||
| 43 | -}) | ||
| 44 | - | ||
| 45 | -function countItems(inv: ScopeInventory): Record<ItemType, number> { | ||
| 46 | - const c = Object.fromEntries(TYPE_ORDER.map(t => [t, 0])) as Record<ItemType, number> | ||
| 47 | - for (const i of inv.items) c[i.type]++ | ||
| 48 | - return c | ||
| 49 | -} | ||
| 50 | - | ||
| 51 | -const homeCounts = computed(() => (home.value ? countItems(home.value) : null)) | ||
| 52 | </script> | 3 | </script> |
| 53 | 4 | ||
| 54 | <template> | 5 | <template> |
| 55 | - <UDashboardPanel id="projects"> | 6 | + <SkPage> |
| 56 | - <template #header> | 7 | + <SkPageHeader title="Projects" subtitle="Folders managed by skilly. Enabled items are written into each project."> |
| 57 | - <UDashboardNavbar title="Projects"> | 8 | + <template #actions> |
| 58 | - <template #right> | 9 | + <SkButton variant="primary" label="Add project" @click="addOpen = true" /> |
| 59 | - <UButton icon="i-lucide-folder-plus" label="Add project" @click="addOpen = true" /> | 10 | + </template> |
| 60 | - </template> | 11 | + </SkPageHeader> |
| 61 | - </UDashboardNavbar> | 12 | + <ProjectsTable /> |
| 62 | - </template> | 13 | + </SkPage> |
| 63 | - | ||
| 64 | - <template #body> | ||
| 65 | - <div class="mx-auto w-full max-w-5xl space-y-6"> | ||
| 66 | - <p class="text-sm text-muted"> | ||
| 67 | - Everything your coding agents can see: skills, rules, MCP servers, hooks, subagents and instruction files. | ||
| 68 | - </p> | ||
| 69 | - | ||
| 70 | - <div class="grid gap-3 md:grid-cols-2"> | ||
| 71 | - <!-- Home --> | ||
| 72 | - <NuxtLink to="/home" class="block rounded-xl border border-default p-3 transition-colors hover:bg-elevated/50 focus-visible:outline-2 focus-visible:outline-primary"> | ||
| 73 | - <div class="flex items-center gap-2"> | ||
| 74 | - <UIcon name="i-lucide-house" class="size-4 shrink-0 text-primary" /> | ||
| 75 | - <h2 class="font-semibold">Home</h2> | ||
| 76 | - <span class="truncate text-xs text-muted">user-level configuration</span> | ||
| 77 | - <UBadge v-if="syncs.home" :label="syncs.home.label" :color="syncs.home.color" :icon="syncs.home.icon" variant="subtle" size="sm" class="ml-auto shrink-0" /> | ||
| 78 | - <USkeleton v-else-if="syncs.home === undefined" class="ml-auto h-5 w-16" /> | ||
| 79 | - </div> | ||
| 80 | - <div v-if="homeLoading && !home" class="mt-2 space-y-2"><USkeleton class="h-4 w-full" /></div> | ||
| 81 | - <UAlert v-else-if="homeError" color="error" variant="subtle" icon="i-lucide-circle-alert" :title="homeError" class="mt-2" /> | ||
| 82 | - <div v-else-if="home && homeCounts" class="mt-2 space-y-2"> | ||
| 83 | - <p class="truncate font-mono text-xs text-muted" :title="home.root">{{ home.root }}</p> | ||
| 84 | - <div class="flex flex-wrap items-center gap-x-3 gap-y-1.5"> | ||
| 85 | - <span class="flex gap-1"> | ||
| 86 | - <AgentBadge v-for="a in home.agentsDetected" :key="a" :agent="a" compact /> | ||
| 87 | - <span v-if="!home.agentsDetected.length" class="text-xs text-muted">No agent</span> | ||
| 88 | - </span> | ||
| 89 | - <CountChips :counts="homeCounts" compact /> | ||
| 90 | - </div> | ||
| 91 | - </div> | ||
| 92 | - </NuxtLink> | ||
| 93 | - | ||
| 94 | - <!-- Projects --> | ||
| 95 | - <template v-if="loaded"> | ||
| 96 | - <NuxtLink | ||
| 97 | - v-for="p in projects" | ||
| 98 | - :key="p.id" | ||
| 99 | - :to="`/projects/${encodeURIComponent(p.id)}`" | ||
| 100 | - class="block rounded-xl border border-default p-3 transition-colors hover:bg-elevated/50 focus-visible:outline-2 focus-visible:outline-primary" | ||
| 101 | - > | ||
| 102 | - <div class="flex items-center gap-2"> | ||
| 103 | - <UIcon name="i-lucide-folder" class="size-4 shrink-0 text-primary" /> | ||
| 104 | - <h2 class="truncate font-semibold">{{ p.name }}</h2> | ||
| 105 | - <UBadge v-if="!p.exists" label="Folder missing" color="warning" variant="subtle" icon="i-lucide-triangle-alert" size="sm" class="ml-auto shrink-0" /> | ||
| 106 | - <UBadge v-else-if="syncs[p.id]" :label="syncs[p.id]!.label" :color="syncs[p.id]!.color" :icon="syncs[p.id]!.icon" variant="subtle" size="sm" class="ml-auto shrink-0" /> | ||
| 107 | - <USkeleton v-else-if="syncs[p.id] === undefined" class="ml-auto h-5 w-16" /> | ||
| 108 | - </div> | ||
| 109 | - <div class="mt-2 space-y-2"> | ||
| 110 | - <p class="truncate font-mono text-xs text-muted" :title="p.path">{{ p.path }}</p> | ||
| 111 | - <div class="flex flex-wrap items-center gap-x-3 gap-y-1.5"> | ||
| 112 | - <span class="flex gap-1"> | ||
| 113 | - <AgentBadge v-for="a in p.agentsDetected" :key="a" :agent="a" compact /> | ||
| 114 | - <span v-if="!p.agentsDetected.length" class="text-xs text-muted">No agent</span> | ||
| 115 | - </span> | ||
| 116 | - <CountChips :counts="p.counts" compact /> | ||
| 117 | - </div> | ||
| 118 | - </div> | ||
| 119 | - </NuxtLink> | ||
| 120 | - </template> | ||
| 121 | - <template v-else> | ||
| 122 | - <USkeleton class="h-24 w-full" /> | ||
| 123 | - </template> | ||
| 124 | - </div> | ||
| 125 | - | ||
| 126 | - <UAlert | ||
| 127 | - v-if="projectsError" | ||
| 128 | - color="error" | ||
| 129 | - variant="subtle" | ||
| 130 | - icon="i-lucide-circle-alert" | ||
| 131 | - title="Could not load projects" | ||
| 132 | - :description="projectsError" | ||
| 133 | - :actions="[{ label: 'Retry', color: 'neutral', variant: 'outline', onClick: refreshProjects }]" | ||
| 134 | - /> | ||
| 135 | - | ||
| 136 | - <div | ||
| 137 | - v-else-if="loaded && !projects.length" | ||
| 138 | - class="flex flex-col items-center gap-3 rounded-xl border border-dashed border-default py-14 text-center" | ||
| 139 | - > | ||
| 140 | - <UIcon name="i-lucide-folder-plus" class="size-10 text-dimmed" /> | ||
| 141 | - <h3 class="font-semibold">Add your first project</h3> | ||
| 142 | - <p class="max-w-md text-sm text-muted"> | ||
| 143 | - Pick a folder to see which skills, rules, MCP servers and instructions Claude Code and Codex use there, then manage them from your catalog. | ||
| 144 | - </p> | ||
| 145 | - <UButton icon="i-lucide-folder-plus" label="Add project" @click="addOpen = true" /> | ||
| 146 | - </div> | ||
| 147 | - </div> | ||
| 148 | - </template> | ||
| 149 | - </UDashboardPanel> | ||
| 150 | </template> | 14 | </template> |
modified
packages/ui/app/pages/settings.vue +79 -42 | @@ -1,17 +1,22 @@ | ||
| 1 | 1 | <script setup lang="ts"> |
| 2 | 2 | import type { Settings } from '@skilly/core' |
| 3 | 3 | |
| 4 | +/** Settings (brief 3.1): catalog location + automatic sync, appearance, run the setup again, about. */ | |
| 4 | 5 | const { request } = useApi() |
| 5 | 6 | const { refresh: refreshCatalog, data: catalog } = useCatalog() |
| 7 | +const { projects } = useProjects() | |
| 6 | 8 | const toast = useToast() |
| 9 | +const colorMode = useColorMode() | |
| 7 | 10 | |
| 8 | 11 | const loaded = ref<Settings | null>(null) |
| 9 | 12 | const form = reactive<Settings>({ catalogPath: '', autoSync: false }) |
| 10 | 13 | const error = ref<string | null>(null) |
| 11 | 14 | const saving = ref(false) |
| 12 | 15 | const pickerOpen = ref(false) |
| 16 | +const version = ref<string | null>(null) | |
| 13 | 17 | |
| 14 | -const dirty = computed(() => !!loaded.value && (form.catalogPath !== loaded.value.catalogPath || form.autoSync !== loaded.value.autoSync)) | |
| 18 | +const dirty = computed(() => !!loaded.value && (form.catalogPath.trim() !== loaded.value.catalogPath || form.autoSync !== loaded.value.autoSync)) | |
| 19 | +const pathChanged = computed(() => !!loaded.value && form.catalogPath.trim() !== loaded.value.catalogPath) | |
| 15 | 20 | |
| 16 | 21 | function apply(s: Settings) { |
| 17 | 22 | loaded.value = s |
| @@ -25,60 +30,92 @@ onMounted(async () => { | ||
| 25 | 30 | } catch (e) { |
| 26 | 31 | error.value = (e as Error).message |
| 27 | 32 | } |
| 33 | + if (!catalog.value) refreshCatalog() | |
| 34 | + try { | |
| 35 | + version.value = (await request<{ version: string }>('/api/health', { silent: true })).version | |
| 36 | + } catch { /* the version line just stays hidden */ } | |
| 28 | 37 | }) |
| 29 | 38 | |
| 30 | 39 | async function save() { |
| 31 | 40 | saving.value = true |
| 32 | 41 | try { |
| 33 | - apply(await request<Settings>('/api/settings', { method: 'PUT', body: { catalogPath: form.catalogPath.trim(), autoSync: form.autoSync } })) | |
| 42 | + apply(await request<Settings>('/api/settings', { method: 'PUT', body: { catalogPath: form.catalogPath.trim(), autoSync: form.autoSync }, silent: true })) | |
| 34 | 43 | await refreshCatalog() |
| 35 | 44 | toast.add({ title: 'Settings saved', icon: 'i-lucide-check', color: 'success' }) |
| 36 | - } catch { | |
| 37 | - // toast shown by useApi | |
| 45 | + } catch (e) { | |
| 46 | + toast.add({ title: 'Settings not saved', description: (e as Error).message, color: 'error', icon: 'i-lucide-circle-alert' }) | |
| 38 | 47 | } finally { |
| 39 | 48 | saving.value = false |
| 40 | 49 | } |
| 41 | 50 | } |
| 51 | + | |
| 52 | +const appearance = computed({ | |
| 53 | + get: () => colorMode.preference as 'light' | 'dark' | 'system', | |
| 54 | + set: (v) => { colorMode.preference = v } | |
| 55 | +}) | |
| 56 | +const APPEARANCE = [ | |
| 57 | + { value: 'light', label: 'Light' }, | |
| 58 | + { value: 'dark', label: 'Dark' }, | |
| 59 | + { value: 'system', label: 'System' } | |
| 60 | +] as const | |
| 42 | 61 | </script> |
| 43 | 62 | |
| 44 | 63 | <template> |
| 45 | - <UDashboardPanel id="settings"> | |
| 46 | - <template #header> | |
| 47 | - <UDashboardNavbar title="Settings" icon="i-lucide-settings" /> | |
| 48 | - </template> | |
| 49 | - <template #body> | |
| 50 | - <div class="mx-auto w-full max-w-2xl space-y-6"> | |
| 51 | - <UAlert v-if="error" color="error" variant="subtle" icon="i-lucide-circle-alert" title="Could not load settings" :description="error" /> | |
| 52 | - <USkeleton v-else-if="!loaded" class="h-40 w-full" /> | |
| 53 | - <template v-else> | |
| 54 | - <UCard> | |
| 55 | - <template #header> | |
| 56 | - <h2 class="font-semibold">Catalog</h2> | |
| 57 | - </template> | |
| 58 | - <div class="space-y-4"> | |
| 59 | - <UFormField label="Catalog folder" description="A folder (usually a git repository) holding your skills, rules, MCP servers, hooks, agents and presets. Point it to an existing clone to reuse it."> | |
| 60 | - <div class="flex gap-2"> | |
| 61 | - <UInput v-model="form.catalogPath" class="flex-1" :ui="{ base: 'font-mono' }" /> | |
| 62 | - <UButton label="Browse" icon="i-lucide-folder-search" color="neutral" variant="outline" @click="pickerOpen = true" /> | |
| 63 | - </div> | |
| 64 | - </UFormField> | |
| 65 | - <p v-if="catalog && form.catalogPath === loaded.catalogPath" class="text-xs" :class="catalog.snapshot.exists ? 'text-success' : 'text-warning'"> | |
| 66 | - {{ catalog.snapshot.exists ? 'Catalog found at this location.' : 'No catalog at this location yet. Create it from the Catalog page.' }} | |
| 67 | - </p> | |
| 68 | - <p v-else-if="dirty" class="text-xs text-muted">Saving re-opens the catalog at the new location.</p> | |
| 69 | - | |
| 70 | - <USwitch v-model="form.autoSync" label="Automatic sync" description="Commit, pull and push the catalog automatically after changes (needs a git remote)." /> | |
| 71 | - </div> | |
| 72 | - <template #footer> | |
| 73 | - <div class="flex justify-end gap-2"> | |
| 74 | - <UButton label="Reset" color="neutral" variant="ghost" :disabled="!dirty" @click="apply(loaded!)" /> | |
| 75 | - <UButton label="Save" icon="i-lucide-save" :loading="saving" :disabled="!dirty || !form.catalogPath.trim()" @click="save" /> | |
| 76 | - </div> | |
| 77 | - </template> | |
| 78 | - </UCard> | |
| 79 | - </template> | |
| 80 | - </div> | |
| 81 | - <FolderPickerModal v-model:open="pickerOpen" title="Choose the catalog folder" :start="form.catalogPath" @pick="(p) => form.catalogPath = p" /> | |
| 64 | + <SkPage> | |
| 65 | + <SkPageHeader title="Settings" subtitle="Where the catalog lives and how it stays in sync." /> | |
| 66 | + | |
| 67 | + <SkNote v-if="error" tone="accent">Could not load the settings: {{ error }}</SkNote> | |
| 68 | + <SkPanel v-else-if="!loaded" padded> | |
| 69 | + <div class="flex flex-col gap-3"><USkeleton class="h-5 w-1/4" /><USkeleton class="h-9 w-full" /><USkeleton class="h-5 w-1/2" /></div> | |
| 70 | + </SkPanel> | |
| 71 | + | |
| 72 | + <template v-else> | |
| 73 | + <SkPanel padded class="flex flex-col gap-5"> | |
| 74 | + <SkStepHeader title="Catalog" hint="A folder, usually a git repository, holding your skills, rules, MCP servers, hooks, subagents and presets." /> | |
| 75 | + | |
| 76 | + <div class="flex flex-col gap-1.5"> | |
| 77 | + <label class="font-mono text-[11px] tracking-[.06em] text-sk-faint uppercase" for="catalog-path">Catalog folder</label> | |
| 78 | + <div class="flex gap-2"> | |
| 79 | + <UInput id="catalog-path" v-model="form.catalogPath" class="min-w-0 flex-1" :ui="{ base: 'font-mono' }" /> | |
| 80 | + <SkButton icon="i-lucide-folder-search" label="Browse" @click="pickerOpen = true" /> | |
| 81 | + </div> | |
| 82 | + <p v-if="pathChanged" class="m-0 flex items-center gap-1.5 text-xs text-sk-muted"> | |
| 83 | + Saving re-opens the catalog at the new location. Nothing is moved or deleted in the old folder. | |
| 84 | + </p> | |
| 85 | + <p v-else-if="catalog" class="m-0 flex items-center gap-1.5 text-xs text-sk-muted"> | |
| 86 | + <SkStatusDot :tone="catalog.snapshot.exists ? 'ok' : 'faint'" /> | |
| 87 | + {{ catalog.snapshot.exists ? 'Catalog found at this location.' : 'No catalog at this location yet. Create it from the Catalog page, or restore one from a remote in the setup.' }} | |
| 88 | + </p> | |
| 89 | + </div> | |
| 90 | + | |
| 91 | + <SkToggleRow v-model="form.autoSync" label="Automatic sync" hint="Commit, pull and push the catalog a few seconds after each change. Without a git remote, changes are only committed." /> | |
| 92 | + | |
| 93 | + <div class="flex items-center justify-end gap-2 border-t border-sk-line2 pt-4"> | |
| 94 | + <SkButton variant="ghost" label="Reset" :disabled="!dirty || saving" @click="apply(loaded!)" /> | |
| 95 | + <SkButton variant="primary" label="Save" :loading="saving" :disabled="!dirty || !form.catalogPath.trim()" @click="save" /> | |
| 96 | + </div> | |
| 97 | + </SkPanel> | |
| 98 | + | |
| 99 | + <SkPanel padded class="flex flex-col gap-3.5"> | |
| 100 | + <SkStepHeader title="Appearance" hint="Applies immediately; stored in this browser." /> | |
| 101 | + <div><SkSeg v-model="appearance" size="md" aria-label="Appearance" :options="[...APPEARANCE]" /></div> | |
| 102 | + </SkPanel> | |
| 103 | + | |
| 104 | + <SkPanel padded class="flex flex-col gap-3.5"> | |
| 105 | + <SkStepHeader title="Setup" hint="Walk through connecting a catalog and adding projects again. Existing data is kept." /> | |
| 106 | + <div><SkButton label="Run setup again" to="/onboarding" /></div> | |
| 107 | + </SkPanel> | |
| 108 | + | |
| 109 | + <SkPanel padded class="flex flex-col gap-3.5"> | |
| 110 | + <SkStepHeader title="About" /> | |
| 111 | + <SkMetaGrid label-width="130px"> | |
| 112 | + <SkMeta label="Version" mono>{{ version ?? '-' }}</SkMeta> | |
| 113 | + <SkMeta label="Projects" mono>{{ projects.length }}</SkMeta> | |
| 114 | + </SkMetaGrid> | |
| 115 | + <p class="m-0 text-xs text-sk-faint">skilly runs locally. The catalog only leaves this machine through the git remote you set.</p> | |
| 116 | + </SkPanel> | |
| 82 | 117 | </template> |
| 83 | - </UDashboardPanel> | |
| 118 | + | |
| 119 | + <FolderPickerModal v-model:open="pickerOpen" title="Choose the catalog folder" :start="form.catalogPath" @pick="(p: string) => form.catalogPath = p" /> | |
| 120 | + </SkPage> | |
| 84 | 121 | </template> |
| @@ -1,17 +1,22 @@ | |||
| 1 | <script setup lang="ts"> | 1 | <script setup lang="ts"> |
| 2 | import type { Settings } from '@skilly/core' | 2 | import type { Settings } from '@skilly/core' |
| 3 | 3 | ||
| 4 | +/** Settings (brief 3.1): catalog location + automatic sync, appearance, run the setup again, about. */ | ||
| 4 | const { request } = useApi() | 5 | const { request } = useApi() |
| 5 | const { refresh: refreshCatalog, data: catalog } = useCatalog() | 6 | const { refresh: refreshCatalog, data: catalog } = useCatalog() |
| 7 | +const { projects } = useProjects() | ||
| 6 | const toast = useToast() | 8 | const toast = useToast() |
| 9 | +const colorMode = useColorMode() | ||
| 7 | 10 | ||
| 8 | const loaded = ref<Settings | null>(null) | 11 | const loaded = ref<Settings | null>(null) |
| 9 | const form = reactive<Settings>({ catalogPath: '', autoSync: false }) | 12 | const form = reactive<Settings>({ catalogPath: '', autoSync: false }) |
| 10 | const error = ref<string | null>(null) | 13 | const error = ref<string | null>(null) |
| 11 | const saving = ref(false) | 14 | const saving = ref(false) |
| 12 | const pickerOpen = ref(false) | 15 | const pickerOpen = ref(false) |
| 16 | +const version = ref<string | null>(null) | ||
| 13 | 17 | ||
| 14 | -const dirty = computed(() => !!loaded.value && (form.catalogPath !== loaded.value.catalogPath || form.autoSync !== loaded.value.autoSync)) | 18 | +const dirty = computed(() => !!loaded.value && (form.catalogPath.trim() !== loaded.value.catalogPath || form.autoSync !== loaded.value.autoSync)) |
| 19 | +const pathChanged = computed(() => !!loaded.value && form.catalogPath.trim() !== loaded.value.catalogPath) | ||
| 15 | 20 | ||
| 16 | function apply(s: Settings) { | 21 | function apply(s: Settings) { |
| 17 | loaded.value = s | 22 | loaded.value = s |
| @@ -25,60 +30,92 @@ onMounted(async () => { | |||
| 25 | } catch (e) { | 30 | } catch (e) { |
| 26 | error.value = (e as Error).message | 31 | error.value = (e as Error).message |
| 27 | } | 32 | } |
| 33 | + if (!catalog.value) refreshCatalog() | ||
| 34 | + try { | ||
| 35 | + version.value = (await request<{ version: string }>('/api/health', { silent: true })).version | ||
| 36 | + } catch { /* the version line just stays hidden */ } | ||
| 28 | }) | 37 | }) |
| 29 | 38 | ||
| 30 | async function save() { | 39 | async function save() { |
| 31 | saving.value = true | 40 | saving.value = true |
| 32 | try { | 41 | try { |
| 33 | - apply(await request<Settings>('/api/settings', { method: 'PUT', body: { catalogPath: form.catalogPath.trim(), autoSync: form.autoSync } })) | 42 | + apply(await request<Settings>('/api/settings', { method: 'PUT', body: { catalogPath: form.catalogPath.trim(), autoSync: form.autoSync }, silent: true })) |
| 34 | await refreshCatalog() | 43 | await refreshCatalog() |
| 35 | toast.add({ title: 'Settings saved', icon: 'i-lucide-check', color: 'success' }) | 44 | toast.add({ title: 'Settings saved', icon: 'i-lucide-check', color: 'success' }) |
| 36 | - } catch { | 45 | + } catch (e) { |
| 37 | - // toast shown by useApi | 46 | + toast.add({ title: 'Settings not saved', description: (e as Error).message, color: 'error', icon: 'i-lucide-circle-alert' }) |
| 38 | } finally { | 47 | } finally { |
| 39 | saving.value = false | 48 | saving.value = false |
| 40 | } | 49 | } |
| 41 | } | 50 | } |
| 51 | + | ||
| 52 | +const appearance = computed({ | ||
| 53 | + get: () => colorMode.preference as 'light' | 'dark' | 'system', | ||
| 54 | + set: (v) => { colorMode.preference = v } | ||
| 55 | +}) | ||
| 56 | +const APPEARANCE = [ | ||
| 57 | + { value: 'light', label: 'Light' }, | ||
| 58 | + { value: 'dark', label: 'Dark' }, | ||
| 59 | + { value: 'system', label: 'System' } | ||
| 60 | +] as const | ||
| 42 | </script> | 61 | </script> |
| 43 | 62 | ||
| 44 | <template> | 63 | <template> |
| 45 | - <UDashboardPanel id="settings"> | 64 | + <SkPage> |
| 46 | - <template #header> | 65 | + <SkPageHeader title="Settings" subtitle="Where the catalog lives and how it stays in sync." /> |
| 47 | - <UDashboardNavbar title="Settings" icon="i-lucide-settings" /> | 66 | + |
| 48 | - </template> | 67 | + <SkNote v-if="error" tone="accent">Could not load the settings: {{ error }}</SkNote> |
| 49 | - <template #body> | 68 | + <SkPanel v-else-if="!loaded" padded> |
| 50 | - <div class="mx-auto w-full max-w-2xl space-y-6"> | 69 | + <div class="flex flex-col gap-3"><USkeleton class="h-5 w-1/4" /><USkeleton class="h-9 w-full" /><USkeleton class="h-5 w-1/2" /></div> |
| 51 | - <UAlert v-if="error" color="error" variant="subtle" icon="i-lucide-circle-alert" title="Could not load settings" :description="error" /> | 70 | + </SkPanel> |
| 52 | - <USkeleton v-else-if="!loaded" class="h-40 w-full" /> | 71 | + |
| 53 | - <template v-else> | 72 | + <template v-else> |
| 54 | - <UCard> | 73 | + <SkPanel padded class="flex flex-col gap-5"> |
| 55 | - <template #header> | 74 | + <SkStepHeader title="Catalog" hint="A folder, usually a git repository, holding your skills, rules, MCP servers, hooks, subagents and presets." /> |
| 56 | - <h2 class="font-semibold">Catalog</h2> | 75 | + |
| 57 | - </template> | 76 | + <div class="flex flex-col gap-1.5"> |
| 58 | - <div class="space-y-4"> | 77 | + <label class="font-mono text-[11px] tracking-[.06em] text-sk-faint uppercase" for="catalog-path">Catalog folder</label> |
| 59 | - <UFormField label="Catalog folder" description="A folder (usually a git repository) holding your skills, rules, MCP servers, hooks, agents and presets. Point it to an existing clone to reuse it."> | 78 | + <div class="flex gap-2"> |
| 60 | - <div class="flex gap-2"> | 79 | + <UInput id="catalog-path" v-model="form.catalogPath" class="min-w-0 flex-1" :ui="{ base: 'font-mono' }" /> |
| 61 | - <UInput v-model="form.catalogPath" class="flex-1" :ui="{ base: 'font-mono' }" /> | 80 | + <SkButton icon="i-lucide-folder-search" label="Browse" @click="pickerOpen = true" /> |
| 62 | - <UButton label="Browse" icon="i-lucide-folder-search" color="neutral" variant="outline" @click="pickerOpen = true" /> | 81 | + </div> |
| 63 | - </div> | 82 | + <p v-if="pathChanged" class="m-0 flex items-center gap-1.5 text-xs text-sk-muted"> |
| 64 | - </UFormField> | 83 | + Saving re-opens the catalog at the new location. Nothing is moved or deleted in the old folder. |
| 65 | - <p v-if="catalog && form.catalogPath === loaded.catalogPath" class="text-xs" :class="catalog.snapshot.exists ? 'text-success' : 'text-warning'"> | 84 | + </p> |
| 66 | - {{ catalog.snapshot.exists ? 'Catalog found at this location.' : 'No catalog at this location yet. Create it from the Catalog page.' }} | 85 | + <p v-else-if="catalog" class="m-0 flex items-center gap-1.5 text-xs text-sk-muted"> |
| 67 | - </p> | 86 | + <SkStatusDot :tone="catalog.snapshot.exists ? 'ok' : 'faint'" /> |
| 68 | - <p v-else-if="dirty" class="text-xs text-muted">Saving re-opens the catalog at the new location.</p> | 87 | + {{ catalog.snapshot.exists ? 'Catalog found at this location.' : 'No catalog at this location yet. Create it from the Catalog page, or restore one from a remote in the setup.' }} |
| 69 | - | 88 | + </p> |
| 70 | - <USwitch v-model="form.autoSync" label="Automatic sync" description="Commit, pull and push the catalog automatically after changes (needs a git remote)." /> | 89 | + </div> |
| 71 | - </div> | 90 | + |
| 72 | - <template #footer> | 91 | + <SkToggleRow v-model="form.autoSync" label="Automatic sync" hint="Commit, pull and push the catalog a few seconds after each change. Without a git remote, changes are only committed." /> |
| 73 | - <div class="flex justify-end gap-2"> | 92 | + |
| 74 | - <UButton label="Reset" color="neutral" variant="ghost" :disabled="!dirty" @click="apply(loaded!)" /> | 93 | + <div class="flex items-center justify-end gap-2 border-t border-sk-line2 pt-4"> |
| 75 | - <UButton label="Save" icon="i-lucide-save" :loading="saving" :disabled="!dirty || !form.catalogPath.trim()" @click="save" /> | 94 | + <SkButton variant="ghost" label="Reset" :disabled="!dirty || saving" @click="apply(loaded!)" /> |
| 76 | - </div> | 95 | + <SkButton variant="primary" label="Save" :loading="saving" :disabled="!dirty || !form.catalogPath.trim()" @click="save" /> |
| 77 | - </template> | 96 | + </div> |
| 78 | - </UCard> | 97 | + </SkPanel> |
| 79 | - </template> | 98 | + |
| 80 | - </div> | 99 | + <SkPanel padded class="flex flex-col gap-3.5"> |
| 81 | - <FolderPickerModal v-model:open="pickerOpen" title="Choose the catalog folder" :start="form.catalogPath" @pick="(p) => form.catalogPath = p" /> | 100 | + <SkStepHeader title="Appearance" hint="Applies immediately; stored in this browser." /> |
| 101 | + <div><SkSeg v-model="appearance" size="md" aria-label="Appearance" :options="[...APPEARANCE]" /></div> | ||
| 102 | + </SkPanel> | ||
| 103 | + | ||
| 104 | + <SkPanel padded class="flex flex-col gap-3.5"> | ||
| 105 | + <SkStepHeader title="Setup" hint="Walk through connecting a catalog and adding projects again. Existing data is kept." /> | ||
| 106 | + <div><SkButton label="Run setup again" to="/onboarding" /></div> | ||
| 107 | + </SkPanel> | ||
| 108 | + | ||
| 109 | + <SkPanel padded class="flex flex-col gap-3.5"> | ||
| 110 | + <SkStepHeader title="About" /> | ||
| 111 | + <SkMetaGrid label-width="130px"> | ||
| 112 | + <SkMeta label="Version" mono>{{ version ?? '-' }}</SkMeta> | ||
| 113 | + <SkMeta label="Projects" mono>{{ projects.length }}</SkMeta> | ||
| 114 | + </SkMetaGrid> | ||
| 115 | + <p class="m-0 text-xs text-sk-faint">skilly runs locally. The catalog only leaves this machine through the git remote you set.</p> | ||
| 116 | + </SkPanel> | ||
| 82 | </template> | 117 | </template> |
| 83 | - </UDashboardPanel> | 118 | + |
| 119 | + <FolderPickerModal v-model:open="pickerOpen" title="Choose the catalog folder" :start="form.catalogPath" @pick="(p: string) => form.catalogPath = p" /> | ||
| 120 | + </SkPage> | ||
| 84 | </template> | 121 | </template> |
modified
packages/ui/app/pages/sync.vue +244 -10 | @@ -1,20 +1,254 @@ | ||
| 1 | 1 | <script setup lang="ts"> |
| 2 | -// PLACEHOLDER (WP3 owns this page). Until the real Sync page exists, the old git dialog is the way to manage the | |
| 3 | -// catalog repository; it is opened from here so the header pill always leads somewhere useful. | |
| 4 | -const git = useCatalogGit() | |
| 5 | -const { git: status } = useCatalog() | |
| 2 | +import type { Settings } from '@skilly/core' | |
| 6 | 3 | |
| 7 | -onMounted(() => { git.open.value = true }) | |
| 4 | +/** | |
| 5 | + * Sync (brief 3.1): the catalog git repository in one page. Status, remote, commit, pull / push / sync, conflicts | |
| 6 | + * resolved item by item, automatic sync. The header pill (`gitPillState`) leads here. | |
| 7 | + */ | |
| 8 | +const { request } = useApi() | |
| 9 | +const { git, exists, data, refresh, init } = useCatalog() | |
| 10 | +const { busy, error, run } = useCatalogGit() | |
| 11 | +const toast = useToast() | |
| 12 | + | |
| 13 | +const settings = ref<Settings | null>(null) | |
| 14 | +const autoSyncSaving = ref(false) | |
| 15 | +const remoteInput = ref('') | |
| 16 | +const message = ref('') | |
| 17 | +const initialising = ref(false) | |
| 18 | +const resolving = ref<string | null>(null) | |
| 19 | + | |
| 20 | +async function load() { | |
| 21 | + await refresh() | |
| 22 | + try { | |
| 23 | + settings.value = await request<Settings>('/api/settings', { silent: true }) | |
| 24 | + } catch { /* the page works without it; the switch stays disabled */ } | |
| 25 | +} | |
| 26 | +onMounted(() => { | |
| 27 | + error.value = null | |
| 28 | + load() | |
| 29 | +}) | |
| 30 | +watch(() => git.value?.remote, (r) => { remoteInput.value = r ?? '' }, { immediate: true }) | |
| 31 | + | |
| 32 | +const dirty = computed(() => git.value?.dirty ?? []) | |
| 33 | +const conflicts = computed(() => git.value?.conflicts ?? []) | |
| 34 | +const groups = computed(() => groupConflicts(conflicts.value)) | |
| 35 | +const hasRemote = computed(() => !!git.value?.remote) | |
| 36 | +const working = computed(() => !!busy.value || !!resolving.value) | |
| 37 | + | |
| 38 | +/** Big number of the status tile: what needs attention first. */ | |
| 39 | +const tile = computed(() => { | |
| 40 | + const g = git.value | |
| 41 | + if (!g) return { value: '-', caption: '', variant: 'blue' as const } | |
| 42 | + if (g.conflicts.length) { | |
| 43 | + const n = groups.value.length | |
| 44 | + return { value: g.conflicts.length, caption: `conflicting file${g.conflicts.length === 1 ? '' : 's'} in ${n} item${n === 1 ? '' : 's'}`, variant: 'clay' as const } | |
| 45 | + } | |
| 46 | + if (g.behind || g.ahead) { | |
| 47 | + const parts = [g.ahead ? `${g.ahead} to push` : '', g.behind ? `${g.behind} to pull` : ''].filter(Boolean) | |
| 48 | + return { value: g.ahead + g.behind, caption: parts.join(' · '), variant: 'blue' as const } | |
| 49 | + } | |
| 50 | + if (g.dirty.length) return { value: g.dirty.length, caption: `uncommitted change${g.dirty.length === 1 ? '' : 's'}`, variant: 'blue' as const } | |
| 51 | + return { value: 0, caption: g.remote ? 'up to date' : 'nothing to commit', variant: 'blue' as const } | |
| 52 | +}) | |
| 53 | + | |
| 54 | +const subtitle = computed(() => { | |
| 55 | + const g = git.value | |
| 56 | + if (!g?.isRepo) return 'Catalog repository' | |
| 57 | + return ['Catalog repository', g.remote ?? 'no remote', g.branch].filter(Boolean).join(' · ') | |
| 58 | +}) | |
| 59 | + | |
| 60 | +const BUSY_LABELS: Record<string, string> = { | |
| 61 | + sync: 'Committing, pulling and pushing the catalog…', | |
| 62 | + pull: 'Pulling from the remote…', | |
| 63 | + push: 'Pushing to the remote…', | |
| 64 | + commit: 'Committing…', | |
| 65 | + remote: 'Updating the remote…', | |
| 66 | + resolve: 'Resolving…' | |
| 67 | +} | |
| 68 | +const busyLabel = computed(() => BUSY_LABELS[busy.value ?? 'sync']) | |
| 69 | + | |
| 70 | +async function setRemote() { | |
| 71 | + await run('remote', { url: remoteInput.value.trim() }, 'Remote updated') | |
| 72 | +} | |
| 73 | + | |
| 74 | +async function commit() { | |
| 75 | + if (await run('commit', { message: message.value.trim() }, 'Committed')) message.value = '' | |
| 76 | +} | |
| 77 | + | |
| 78 | +async function resolveGroup(key: string, side: 'ours' | 'theirs') { | |
| 79 | + const group = groups.value.find(g => g.key === key) | |
| 80 | + if (!group) return | |
| 81 | + resolving.value = key | |
| 82 | + try { | |
| 83 | + // One request per path: the last one commits the merge once no conflict remains. | |
| 84 | + for (const path of [...group.paths]) { | |
| 85 | + if (!(await run('resolve', { path, side }))) return | |
| 86 | + } | |
| 87 | + toast.add({ title: `${group.name} resolved`, description: side === 'ours' ? 'Kept your version.' : 'Used the remote version.', icon: 'i-lucide-check', color: 'success' }) | |
| 88 | + } finally { | |
| 89 | + resolving.value = null | |
| 90 | + } | |
| 91 | +} | |
| 92 | + | |
| 93 | +async function initRepo() { | |
| 94 | + initialising.value = true | |
| 95 | + try { | |
| 96 | + await init() | |
| 97 | + } catch (e) { | |
| 98 | + error.value = (e as Error).message | |
| 99 | + } finally { | |
| 100 | + initialising.value = false | |
| 101 | + } | |
| 102 | +} | |
| 103 | + | |
| 104 | +async function setAutoSync(v: boolean) { | |
| 105 | + if (!settings.value) return | |
| 106 | + const previous = settings.value | |
| 107 | + settings.value = { ...previous, autoSync: v } | |
| 108 | + autoSyncSaving.value = true | |
| 109 | + try { | |
| 110 | + settings.value = await request<Settings>('/api/settings', { method: 'PUT', body: { autoSync: v }, silent: true }) | |
| 111 | + } catch (e) { | |
| 112 | + settings.value = previous | |
| 113 | + error.value = (e as Error).message | |
| 114 | + } finally { | |
| 115 | + autoSyncSaving.value = false | |
| 116 | + } | |
| 117 | +} | |
| 118 | + | |
| 119 | +const noRemoteHint = 'Set a remote first' | |
| 8 | 120 | </script> |
| 9 | 121 | |
| 10 | 122 | <template> |
| 11 | 123 | <SkPage> |
| 12 | - <SkPageHeader title="Sync" :subtitle="status?.remote ? `Catalog repository · ${status.remote}${status.branch ? ' · ' + status.branch : ''}` : 'Catalog repository'" /> | |
| 13 | - <SkPanel padded class="flex flex-col items-start gap-3"> | |
| 14 | - <SkEmpty text="Not designed yet. The catalog git dialog is still the way to commit, pull and push." class="self-stretch" /> | |
| 15 | - <div class="flex w-full justify-center"> | |
| 16 | - <SkButton variant="primary" label="Open git dialog" @click="git.open.value = true" /> | |
| 124 | + <SkPageHeader title="Sync" :subtitle="subtitle"> | |
| 125 | + <template #actions> | |
| 126 | + <SkButton variant="ghost" icon="i-lucide-refresh-cw" label="Refresh" :disabled="working" @click="load" /> | |
| 127 | + </template> | |
| 128 | + </SkPageHeader> | |
| 129 | + | |
| 130 | + <SkNote v-if="error" tone="accent"> | |
| 131 | + <div class="flex items-start gap-3"> | |
| 132 | + <div class="min-w-0 flex-1"> | |
| 133 | + <p class="m-0 font-semibold">Git error</p> | |
| 134 | + <p class="m-0 mt-0.5 font-mono text-xs break-words whitespace-pre-wrap">{{ error }}</p> | |
| 135 | + </div> | |
| 136 | + <SkButton size="xs" variant="ghost" label="Dismiss" @click="error = null" /> | |
| 17 | 137 | </div> |
| 138 | + </SkNote> | |
| 139 | + | |
| 140 | + <SkNote v-if="busy" tone="info">{{ busyLabel }} <span class="text-sk-muted">Large catalogs can take a moment.</span></SkNote> | |
| 141 | + | |
| 142 | + <SkPanel v-if="!data" padded> | |
| 143 | + <div class="flex flex-col gap-3"><USkeleton class="h-6 w-1/3" /><USkeleton class="h-40 w-full" /></div> | |
| 144 | + </SkPanel> | |
| 145 | + | |
| 146 | + <SkPanel v-else-if="!exists" padded> | |
| 147 | + <SkEmpty title="No catalog yet" text="The catalog folder has not been created, so there is nothing to sync."> | |
| 148 | + <template #actions> | |
| 149 | + <SkButton variant="primary" label="Open the catalog" to="/catalog" /> | |
| 150 | + <SkButton label="Settings" to="/settings" /> | |
| 151 | + </template> | |
| 152 | + </SkEmpty> | |
| 153 | + </SkPanel> | |
| 154 | + | |
| 155 | + <SkPanel v-else-if="!git?.isRepo" padded> | |
| 156 | + <SkEmpty title="Not a git repository" text="The catalog folder is not versioned yet. Initialize git to commit it and share it through a remote."> | |
| 157 | + <template #actions> | |
| 158 | + <SkButton variant="primary" label="Initialize git" :loading="initialising" @click="initRepo" /> | |
| 159 | + </template> | |
| 160 | + </SkEmpty> | |
| 18 | 161 | </SkPanel> |
| 162 | + | |
| 163 | + <template v-else-if="git"> | |
| 164 | + <!-- 1 Status --> | |
| 165 | + <SkPanel padded class="flex flex-col gap-4"> | |
| 166 | + <SkStepHeader step="1" title="Status" :hint="git.conflicts.length ? 'The last pull stopped on conflicts.' : hasRemote ? 'Local catalog compared to the remote.' : 'This catalog has no remote yet.'" /> | |
| 167 | + <div class="grid items-stretch gap-[22px] md:grid-cols-[280px_minmax(0,1fr)]"> | |
| 168 | + <SkDitherTile :variant="tile.variant" :height="188" :value="tile.value" :caption="tile.caption" /> | |
| 169 | + <div class="flex min-w-0 flex-col justify-center gap-3.5"> | |
| 170 | + <SkMetaGrid label-width="130px"> | |
| 171 | + <SkMeta label="Branch" mono>{{ git.branch ?? '-' }}</SkMeta> | |
| 172 | + <SkMeta label="Remote"> | |
| 173 | + <form class="flex flex-wrap items-center gap-2" @submit.prevent="setRemote"> | |
| 174 | + <UInput | |
| 175 | + v-model="remoteInput" | |
| 176 | + placeholder="git@github.com:you/agent-catalog.git" | |
| 177 | + aria-label="Remote URL" | |
| 178 | + class="min-w-0 flex-1 basis-60" | |
| 179 | + :ui="{ base: 'font-mono' }" | |
| 180 | + /> | |
| 181 | + <SkButton type="submit" label="Set remote" :loading="busy === 'remote'" :disabled="working || !remoteInput.trim() || remoteInput.trim() === git.remote" /> | |
| 182 | + </form> | |
| 183 | + <p class="m-0 mt-1 text-xs text-sk-faint">GitHub, GitLab or self-hosted. The catalog never stores secrets.</p> | |
| 184 | + </SkMeta> | |
| 185 | + <SkMeta label="Last commit"> | |
| 186 | + <span v-if="git.lastCommit" class="flex min-w-0 flex-wrap items-baseline gap-x-2"> | |
| 187 | + <span class="font-mono text-xs text-sk-faint">{{ git.lastCommit.hash.slice(0, 7) }}</span> | |
| 188 | + <span class="min-w-0 truncate" :title="git.lastCommit.message">{{ git.lastCommit.message }}</span> | |
| 189 | + <span class="text-xs text-sk-faint" :title="formatDate(git.lastCommit.date)">{{ timeAgo(git.lastCommit.date) }}</span> | |
| 190 | + </span> | |
| 191 | + <span v-else class="text-sk-faint">No commit yet</span> | |
| 192 | + </SkMeta> | |
| 193 | + <SkMeta label="Automatic sync"> | |
| 194 | + <SkToggleRow | |
| 195 | + :model-value="settings?.autoSync ?? false" | |
| 196 | + :disabled="!settings || autoSyncSaving" | |
| 197 | + label="Commit, pull and push after changes" | |
| 198 | + :hint="hasRemote ? 'Runs a few seconds after each catalog change.' : 'Without a remote, changes are only committed locally.'" | |
| 199 | + @update:model-value="setAutoSync" | |
| 200 | + /> | |
| 201 | + </SkMeta> | |
| 202 | + </SkMetaGrid> | |
| 203 | + </div> | |
| 204 | + </div> | |
| 205 | + </SkPanel> | |
| 206 | + | |
| 207 | + <!-- 2 Conflicts --> | |
| 208 | + <SkPanel v-if="groups.length" padded class="flex flex-col gap-3"> | |
| 209 | + <SkStepHeader step="2" title="Conflicts" hint="Changed on both sides. Choose a version for each item; nothing else is blocked." /> | |
| 210 | + <SkTray> | |
| 211 | + <SkRowCard v-for="g in groups" :key="g.key" cols="minmax(0,1fr) auto"> | |
| 212 | + <div class="min-w-0"> | |
| 213 | + <div class="flex items-baseline gap-2"> | |
| 214 | + <span class="truncate font-medium">{{ g.name }}</span> | |
| 215 | + <span v-if="g.type" class="font-mono text-[11px] text-sk-faint">{{ g.type }}</span> | |
| 216 | + </div> | |
| 217 | + <ul class="m-0 mt-0.5 list-none p-0"> | |
| 218 | + <li v-for="p in g.paths" :key="p" class="truncate font-mono text-[11px] text-sk-faint" :title="p">{{ p }}</li> | |
| 219 | + </ul> | |
| 220 | + </div> | |
| 221 | + <div class="flex items-center gap-2"> | |
| 222 | + <SkButton size="sm" label="Keep mine" :loading="resolving === g.key" :disabled="working" @click="resolveGroup(g.key, 'ours')" /> | |
| 223 | + <SkButton size="sm" label="Use remote" :disabled="working" @click="resolveGroup(g.key, 'theirs')" /> | |
| 224 | + </div> | |
| 225 | + </SkRowCard> | |
| 226 | + </SkTray> | |
| 227 | + <p class="m-0 text-xs text-sk-muted">Keep mine keeps the version on this machine. Use remote takes the version from the remote. Once every conflict is resolved, skilly commits the merge.</p> | |
| 228 | + </SkPanel> | |
| 229 | + | |
| 230 | + <!-- Changes --> | |
| 231 | + <SkPanel padded class="flex flex-col gap-3.5"> | |
| 232 | + <SkStepHeader :step="groups.length ? '3' : '2'" title="Changes" :hint="dirty.length ? `${dirty.length} uncommitted file${dirty.length === 1 ? '' : 's'}.` : 'Working tree clean.'" /> | |
| 233 | + | |
| 234 | + <SkTray v-if="dirty.length" class="max-h-64 overflow-auto"> | |
| 235 | + <SkRowCard v-for="d in dirty" :key="d" class="truncate font-mono text-xs" :title="d">{{ d }}</SkRowCard> | |
| 236 | + </SkTray> | |
| 237 | + <SkEmpty v-else text="Nothing to commit." class="py-5" /> | |
| 238 | + | |
| 239 | + <form class="flex flex-wrap items-center gap-2" @submit.prevent="commit"> | |
| 240 | + <UInput v-model="message" placeholder="Commit message" aria-label="Commit message" class="min-w-0 flex-1 basis-64" :disabled="!dirty.length || working" /> | |
| 241 | + <SkButton type="submit" variant="primary" label="Commit" :loading="busy === 'commit'" :disabled="!dirty.length || !message.trim() || working || !!conflicts.length" /> | |
| 242 | + </form> | |
| 243 | + | |
| 244 | + <div class="flex flex-wrap items-center gap-2 border-t border-sk-line2 pt-3.5"> | |
| 245 | + <SkButton icon="i-lucide-arrow-down" label="Pull" :loading="busy === 'pull'" :disabled="!hasRemote || working" :title="hasRemote ? undefined : noRemoteHint" @click="run('pull', undefined, 'Pulled')" /> | |
| 246 | + <SkButton icon="i-lucide-arrow-up" label="Push" :loading="busy === 'push'" :disabled="!hasRemote || working || !!conflicts.length" :title="hasRemote ? undefined : noRemoteHint" @click="run('push', undefined, 'Pushed')" /> | |
| 247 | + <span class="flex-1" /> | |
| 248 | + <span v-if="!hasRemote" class="text-xs text-sk-faint">Set a remote to pull and push.</span> | |
| 249 | + <SkButton variant="primary" icon="i-lucide-refresh-cw" label="Pull & push" :loading="busy === 'sync'" :disabled="!hasRemote || working || !!conflicts.length" :title="hasRemote ? 'Commits everything, pulls, then pushes' : noRemoteHint" @click="run('sync', undefined, 'Catalog synced')" /> | |
| 250 | + </div> | |
| 251 | + </SkPanel> | |
| 252 | + </template> | |
| 19 | 253 | </SkPage> |
| 20 | 254 | </template> |
| @@ -1,20 +1,254 @@ | |||
| 1 | <script setup lang="ts"> | 1 | <script setup lang="ts"> |
| 2 | -// PLACEHOLDER (WP3 owns this page). Until the real Sync page exists, the old git dialog is the way to manage the | 2 | +import type { Settings } from '@skilly/core' |
| 3 | -// catalog repository; it is opened from here so the header pill always leads somewhere useful. | ||
| 4 | -const git = useCatalogGit() | ||
| 5 | -const { git: status } = useCatalog() | ||
| 6 | 3 | ||
| 7 | -onMounted(() => { git.open.value = true }) | 4 | +/** |
| 5 | + * Sync (brief 3.1): the catalog git repository in one page. Status, remote, commit, pull / push / sync, conflicts | ||
| 6 | + * resolved item by item, automatic sync. The header pill (`gitPillState`) leads here. | ||
| 7 | + */ | ||
| 8 | +const { request } = useApi() | ||
| 9 | +const { git, exists, data, refresh, init } = useCatalog() | ||
| 10 | +const { busy, error, run } = useCatalogGit() | ||
| 11 | +const toast = useToast() | ||
| 12 | + | ||
| 13 | +const settings = ref<Settings | null>(null) | ||
| 14 | +const autoSyncSaving = ref(false) | ||
| 15 | +const remoteInput = ref('') | ||
| 16 | +const message = ref('') | ||
| 17 | +const initialising = ref(false) | ||
| 18 | +const resolving = ref<string | null>(null) | ||
| 19 | + | ||
| 20 | +async function load() { | ||
| 21 | + await refresh() | ||
| 22 | + try { | ||
| 23 | + settings.value = await request<Settings>('/api/settings', { silent: true }) | ||
| 24 | + } catch { /* the page works without it; the switch stays disabled */ } | ||
| 25 | +} | ||
| 26 | +onMounted(() => { | ||
| 27 | + error.value = null | ||
| 28 | + load() | ||
| 29 | +}) | ||
| 30 | +watch(() => git.value?.remote, (r) => { remoteInput.value = r ?? '' }, { immediate: true }) | ||
| 31 | + | ||
| 32 | +const dirty = computed(() => git.value?.dirty ?? []) | ||
| 33 | +const conflicts = computed(() => git.value?.conflicts ?? []) | ||
| 34 | +const groups = computed(() => groupConflicts(conflicts.value)) | ||
| 35 | +const hasRemote = computed(() => !!git.value?.remote) | ||
| 36 | +const working = computed(() => !!busy.value || !!resolving.value) | ||
| 37 | + | ||
| 38 | +/** Big number of the status tile: what needs attention first. */ | ||
| 39 | +const tile = computed(() => { | ||
| 40 | + const g = git.value | ||
| 41 | + if (!g) return { value: '-', caption: '', variant: 'blue' as const } | ||
| 42 | + if (g.conflicts.length) { | ||
| 43 | + const n = groups.value.length | ||
| 44 | + return { value: g.conflicts.length, caption: `conflicting file${g.conflicts.length === 1 ? '' : 's'} in ${n} item${n === 1 ? '' : 's'}`, variant: 'clay' as const } | ||
| 45 | + } | ||
| 46 | + if (g.behind || g.ahead) { | ||
| 47 | + const parts = [g.ahead ? `${g.ahead} to push` : '', g.behind ? `${g.behind} to pull` : ''].filter(Boolean) | ||
| 48 | + return { value: g.ahead + g.behind, caption: parts.join(' · '), variant: 'blue' as const } | ||
| 49 | + } | ||
| 50 | + if (g.dirty.length) return { value: g.dirty.length, caption: `uncommitted change${g.dirty.length === 1 ? '' : 's'}`, variant: 'blue' as const } | ||
| 51 | + return { value: 0, caption: g.remote ? 'up to date' : 'nothing to commit', variant: 'blue' as const } | ||
| 52 | +}) | ||
| 53 | + | ||
| 54 | +const subtitle = computed(() => { | ||
| 55 | + const g = git.value | ||
| 56 | + if (!g?.isRepo) return 'Catalog repository' | ||
| 57 | + return ['Catalog repository', g.remote ?? 'no remote', g.branch].filter(Boolean).join(' · ') | ||
| 58 | +}) | ||
| 59 | + | ||
| 60 | +const BUSY_LABELS: Record<string, string> = { | ||
| 61 | + sync: 'Committing, pulling and pushing the catalog…', | ||
| 62 | + pull: 'Pulling from the remote…', | ||
| 63 | + push: 'Pushing to the remote…', | ||
| 64 | + commit: 'Committing…', | ||
| 65 | + remote: 'Updating the remote…', | ||
| 66 | + resolve: 'Resolving…' | ||
| 67 | +} | ||
| 68 | +const busyLabel = computed(() => BUSY_LABELS[busy.value ?? 'sync']) | ||
| 69 | + | ||
| 70 | +async function setRemote() { | ||
| 71 | + await run('remote', { url: remoteInput.value.trim() }, 'Remote updated') | ||
| 72 | +} | ||
| 73 | + | ||
| 74 | +async function commit() { | ||
| 75 | + if (await run('commit', { message: message.value.trim() }, 'Committed')) message.value = '' | ||
| 76 | +} | ||
| 77 | + | ||
| 78 | +async function resolveGroup(key: string, side: 'ours' | 'theirs') { | ||
| 79 | + const group = groups.value.find(g => g.key === key) | ||
| 80 | + if (!group) return | ||
| 81 | + resolving.value = key | ||
| 82 | + try { | ||
| 83 | + // One request per path: the last one commits the merge once no conflict remains. | ||
| 84 | + for (const path of [...group.paths]) { | ||
| 85 | + if (!(await run('resolve', { path, side }))) return | ||
| 86 | + } | ||
| 87 | + toast.add({ title: `${group.name} resolved`, description: side === 'ours' ? 'Kept your version.' : 'Used the remote version.', icon: 'i-lucide-check', color: 'success' }) | ||
| 88 | + } finally { | ||
| 89 | + resolving.value = null | ||
| 90 | + } | ||
| 91 | +} | ||
| 92 | + | ||
| 93 | +async function initRepo() { | ||
| 94 | + initialising.value = true | ||
| 95 | + try { | ||
| 96 | + await init() | ||
| 97 | + } catch (e) { | ||
| 98 | + error.value = (e as Error).message | ||
| 99 | + } finally { | ||
| 100 | + initialising.value = false | ||
| 101 | + } | ||
| 102 | +} | ||
| 103 | + | ||
| 104 | +async function setAutoSync(v: boolean) { | ||
| 105 | + if (!settings.value) return | ||
| 106 | + const previous = settings.value | ||
| 107 | + settings.value = { ...previous, autoSync: v } | ||
| 108 | + autoSyncSaving.value = true | ||
| 109 | + try { | ||
| 110 | + settings.value = await request<Settings>('/api/settings', { method: 'PUT', body: { autoSync: v }, silent: true }) | ||
| 111 | + } catch (e) { | ||
| 112 | + settings.value = previous | ||
| 113 | + error.value = (e as Error).message | ||
| 114 | + } finally { | ||
| 115 | + autoSyncSaving.value = false | ||
| 116 | + } | ||
| 117 | +} | ||
| 118 | + | ||
| 119 | +const noRemoteHint = 'Set a remote first' | ||
| 8 | </script> | 120 | </script> |
| 9 | 121 | ||
| 10 | <template> | 122 | <template> |
| 11 | <SkPage> | 123 | <SkPage> |
| 12 | - <SkPageHeader title="Sync" :subtitle="status?.remote ? `Catalog repository · ${status.remote}${status.branch ? ' · ' + status.branch : ''}` : 'Catalog repository'" /> | 124 | + <SkPageHeader title="Sync" :subtitle="subtitle"> |
| 13 | - <SkPanel padded class="flex flex-col items-start gap-3"> | 125 | + <template #actions> |
| 14 | - <SkEmpty text="Not designed yet. The catalog git dialog is still the way to commit, pull and push." class="self-stretch" /> | 126 | + <SkButton variant="ghost" icon="i-lucide-refresh-cw" label="Refresh" :disabled="working" @click="load" /> |
| 15 | - <div class="flex w-full justify-center"> | 127 | + </template> |
| 16 | - <SkButton variant="primary" label="Open git dialog" @click="git.open.value = true" /> | 128 | + </SkPageHeader> |
| 129 | + | ||
| 130 | + <SkNote v-if="error" tone="accent"> | ||
| 131 | + <div class="flex items-start gap-3"> | ||
| 132 | + <div class="min-w-0 flex-1"> | ||
| 133 | + <p class="m-0 font-semibold">Git error</p> | ||
| 134 | + <p class="m-0 mt-0.5 font-mono text-xs break-words whitespace-pre-wrap">{{ error }}</p> | ||
| 135 | + </div> | ||
| 136 | + <SkButton size="xs" variant="ghost" label="Dismiss" @click="error = null" /> | ||
| 17 | </div> | 137 | </div> |
| 138 | + </SkNote> | ||
| 139 | + | ||
| 140 | + <SkNote v-if="busy" tone="info">{{ busyLabel }} <span class="text-sk-muted">Large catalogs can take a moment.</span></SkNote> | ||
| 141 | + | ||
| 142 | + <SkPanel v-if="!data" padded> | ||
| 143 | + <div class="flex flex-col gap-3"><USkeleton class="h-6 w-1/3" /><USkeleton class="h-40 w-full" /></div> | ||
| 144 | + </SkPanel> | ||
| 145 | + | ||
| 146 | + <SkPanel v-else-if="!exists" padded> | ||
| 147 | + <SkEmpty title="No catalog yet" text="The catalog folder has not been created, so there is nothing to sync."> | ||
| 148 | + <template #actions> | ||
| 149 | + <SkButton variant="primary" label="Open the catalog" to="/catalog" /> | ||
| 150 | + <SkButton label="Settings" to="/settings" /> | ||
| 151 | + </template> | ||
| 152 | + </SkEmpty> | ||
| 153 | + </SkPanel> | ||
| 154 | + | ||
| 155 | + <SkPanel v-else-if="!git?.isRepo" padded> | ||
| 156 | + <SkEmpty title="Not a git repository" text="The catalog folder is not versioned yet. Initialize git to commit it and share it through a remote."> | ||
| 157 | + <template #actions> | ||
| 158 | + <SkButton variant="primary" label="Initialize git" :loading="initialising" @click="initRepo" /> | ||
| 159 | + </template> | ||
| 160 | + </SkEmpty> | ||
| 18 | </SkPanel> | 161 | </SkPanel> |
| 162 | + | ||
| 163 | + <template v-else-if="git"> | ||
| 164 | + <!-- 1 Status --> | ||
| 165 | + <SkPanel padded class="flex flex-col gap-4"> | ||
| 166 | + <SkStepHeader step="1" title="Status" :hint="git.conflicts.length ? 'The last pull stopped on conflicts.' : hasRemote ? 'Local catalog compared to the remote.' : 'This catalog has no remote yet.'" /> | ||
| 167 | + <div class="grid items-stretch gap-[22px] md:grid-cols-[280px_minmax(0,1fr)]"> | ||
| 168 | + <SkDitherTile :variant="tile.variant" :height="188" :value="tile.value" :caption="tile.caption" /> | ||
| 169 | + <div class="flex min-w-0 flex-col justify-center gap-3.5"> | ||
| 170 | + <SkMetaGrid label-width="130px"> | ||
| 171 | + <SkMeta label="Branch" mono>{{ git.branch ?? '-' }}</SkMeta> | ||
| 172 | + <SkMeta label="Remote"> | ||
| 173 | + <form class="flex flex-wrap items-center gap-2" @submit.prevent="setRemote"> | ||
| 174 | + <UInput | ||
| 175 | + v-model="remoteInput" | ||
| 176 | + placeholder="git@github.com:you/agent-catalog.git" | ||
| 177 | + aria-label="Remote URL" | ||
| 178 | + class="min-w-0 flex-1 basis-60" | ||
| 179 | + :ui="{ base: 'font-mono' }" | ||
| 180 | + /> | ||
| 181 | + <SkButton type="submit" label="Set remote" :loading="busy === 'remote'" :disabled="working || !remoteInput.trim() || remoteInput.trim() === git.remote" /> | ||
| 182 | + </form> | ||
| 183 | + <p class="m-0 mt-1 text-xs text-sk-faint">GitHub, GitLab or self-hosted. The catalog never stores secrets.</p> | ||
| 184 | + </SkMeta> | ||
| 185 | + <SkMeta label="Last commit"> | ||
| 186 | + <span v-if="git.lastCommit" class="flex min-w-0 flex-wrap items-baseline gap-x-2"> | ||
| 187 | + <span class="font-mono text-xs text-sk-faint">{{ git.lastCommit.hash.slice(0, 7) }}</span> | ||
| 188 | + <span class="min-w-0 truncate" :title="git.lastCommit.message">{{ git.lastCommit.message }}</span> | ||
| 189 | + <span class="text-xs text-sk-faint" :title="formatDate(git.lastCommit.date)">{{ timeAgo(git.lastCommit.date) }}</span> | ||
| 190 | + </span> | ||
| 191 | + <span v-else class="text-sk-faint">No commit yet</span> | ||
| 192 | + </SkMeta> | ||
| 193 | + <SkMeta label="Automatic sync"> | ||
| 194 | + <SkToggleRow | ||
| 195 | + :model-value="settings?.autoSync ?? false" | ||
| 196 | + :disabled="!settings || autoSyncSaving" | ||
| 197 | + label="Commit, pull and push after changes" | ||
| 198 | + :hint="hasRemote ? 'Runs a few seconds after each catalog change.' : 'Without a remote, changes are only committed locally.'" | ||
| 199 | + @update:model-value="setAutoSync" | ||
| 200 | + /> | ||
| 201 | + </SkMeta> | ||
| 202 | + </SkMetaGrid> | ||
| 203 | + </div> | ||
| 204 | + </div> | ||
| 205 | + </SkPanel> | ||
| 206 | + | ||
| 207 | + <!-- 2 Conflicts --> | ||
| 208 | + <SkPanel v-if="groups.length" padded class="flex flex-col gap-3"> | ||
| 209 | + <SkStepHeader step="2" title="Conflicts" hint="Changed on both sides. Choose a version for each item; nothing else is blocked." /> | ||
| 210 | + <SkTray> | ||
| 211 | + <SkRowCard v-for="g in groups" :key="g.key" cols="minmax(0,1fr) auto"> | ||
| 212 | + <div class="min-w-0"> | ||
| 213 | + <div class="flex items-baseline gap-2"> | ||
| 214 | + <span class="truncate font-medium">{{ g.name }}</span> | ||
| 215 | + <span v-if="g.type" class="font-mono text-[11px] text-sk-faint">{{ g.type }}</span> | ||
| 216 | + </div> | ||
| 217 | + <ul class="m-0 mt-0.5 list-none p-0"> | ||
| 218 | + <li v-for="p in g.paths" :key="p" class="truncate font-mono text-[11px] text-sk-faint" :title="p">{{ p }}</li> | ||
| 219 | + </ul> | ||
| 220 | + </div> | ||
| 221 | + <div class="flex items-center gap-2"> | ||
| 222 | + <SkButton size="sm" label="Keep mine" :loading="resolving === g.key" :disabled="working" @click="resolveGroup(g.key, 'ours')" /> | ||
| 223 | + <SkButton size="sm" label="Use remote" :disabled="working" @click="resolveGroup(g.key, 'theirs')" /> | ||
| 224 | + </div> | ||
| 225 | + </SkRowCard> | ||
| 226 | + </SkTray> | ||
| 227 | + <p class="m-0 text-xs text-sk-muted">Keep mine keeps the version on this machine. Use remote takes the version from the remote. Once every conflict is resolved, skilly commits the merge.</p> | ||
| 228 | + </SkPanel> | ||
| 229 | + | ||
| 230 | + <!-- Changes --> | ||
| 231 | + <SkPanel padded class="flex flex-col gap-3.5"> | ||
| 232 | + <SkStepHeader :step="groups.length ? '3' : '2'" title="Changes" :hint="dirty.length ? `${dirty.length} uncommitted file${dirty.length === 1 ? '' : 's'}.` : 'Working tree clean.'" /> | ||
| 233 | + | ||
| 234 | + <SkTray v-if="dirty.length" class="max-h-64 overflow-auto"> | ||
| 235 | + <SkRowCard v-for="d in dirty" :key="d" class="truncate font-mono text-xs" :title="d">{{ d }}</SkRowCard> | ||
| 236 | + </SkTray> | ||
| 237 | + <SkEmpty v-else text="Nothing to commit." class="py-5" /> | ||
| 238 | + | ||
| 239 | + <form class="flex flex-wrap items-center gap-2" @submit.prevent="commit"> | ||
| 240 | + <UInput v-model="message" placeholder="Commit message" aria-label="Commit message" class="min-w-0 flex-1 basis-64" :disabled="!dirty.length || working" /> | ||
| 241 | + <SkButton type="submit" variant="primary" label="Commit" :loading="busy === 'commit'" :disabled="!dirty.length || !message.trim() || working || !!conflicts.length" /> | ||
| 242 | + </form> | ||
| 243 | + | ||
| 244 | + <div class="flex flex-wrap items-center gap-2 border-t border-sk-line2 pt-3.5"> | ||
| 245 | + <SkButton icon="i-lucide-arrow-down" label="Pull" :loading="busy === 'pull'" :disabled="!hasRemote || working" :title="hasRemote ? undefined : noRemoteHint" @click="run('pull', undefined, 'Pulled')" /> | ||
| 246 | + <SkButton icon="i-lucide-arrow-up" label="Push" :loading="busy === 'push'" :disabled="!hasRemote || working || !!conflicts.length" :title="hasRemote ? undefined : noRemoteHint" @click="run('push', undefined, 'Pushed')" /> | ||
| 247 | + <span class="flex-1" /> | ||
| 248 | + <span v-if="!hasRemote" class="text-xs text-sk-faint">Set a remote to pull and push.</span> | ||
| 249 | + <SkButton variant="primary" icon="i-lucide-refresh-cw" label="Pull & push" :loading="busy === 'sync'" :disabled="!hasRemote || working || !!conflicts.length" :title="hasRemote ? 'Commits everything, pulls, then pushes' : noRemoteHint" @click="run('sync', undefined, 'Catalog synced')" /> | ||
| 250 | + </div> | ||
| 251 | + </SkPanel> | ||
| 252 | + </template> | ||
| 19 | </SkPage> | 253 | </SkPage> |
| 20 | </template> | 254 | </template> |
modified
packages/ui/app/utils/catalog.ts +92 -11 | @@ -1,13 +1,17 @@ | ||
| 1 | 1 | import type { CatalogItemType, HookTemplate, ItemStatus, McpEnvValue, McpTemplate } from '@skilly/core' |
| 2 | +import { splitFrontmatter } from './markdown' | |
| 2 | 3 | |
| 3 | 4 | export const CAT_TYPES: CatalogItemType[] = ['skill', 'rule', 'agent', 'mcp', 'hook'] |
| 4 | 5 | |
| 5 | -export const CAT_META: Record<CatalogItemType, { label: string, singular: string, icon: string, markdown: boolean }> = { | |
| 6 | - skill: { label: 'Skills', singular: 'Skill', icon: 'i-lucide-sparkles', markdown: true }, | |
| 7 | - rule: { label: 'Rules', singular: 'Rule', icon: 'i-lucide-scroll-text', markdown: true }, | |
| 8 | - agent: { label: 'Agents', singular: 'Agent', icon: 'i-lucide-bot', markdown: true }, | |
| 9 | - mcp: { label: 'MCP', singular: 'MCP server', icon: 'i-lucide-plug', markdown: false }, | |
| 10 | - hook: { label: 'Hooks', singular: 'Hook', icon: 'i-lucide-webhook', markdown: false } | |
| 6 | +/** Display order in the UI (as in the design: skills, rules, MCP servers, hooks, subagents). */ | |
| 7 | +export const CAT_ORDER: CatalogItemType[] = ['skill', 'rule', 'mcp', 'hook', 'agent'] | |
| 8 | + | |
| 9 | +export const CAT_META: Record<CatalogItemType, { label: string, singular: string, newLabel: string, icon: string, markdown: boolean }> = { | |
| 10 | + skill: { label: 'Skills', singular: 'Skill', newLabel: 'New skill', icon: 'i-lucide-sparkles', markdown: true }, | |
| 11 | + rule: { label: 'Rules', singular: 'Rule', newLabel: 'New rule', icon: 'i-lucide-scroll-text', markdown: true }, | |
| 12 | + agent: { label: 'Subagents', singular: 'Subagent', newLabel: 'New subagent', icon: 'i-lucide-bot', markdown: true }, | |
| 13 | + mcp: { label: 'MCP servers', singular: 'MCP server', newLabel: 'New MCP server', icon: 'i-lucide-plug', markdown: false }, | |
| 14 | + hook: { label: 'Hooks', singular: 'Hook', newLabel: 'New hook', icon: 'i-lucide-webhook', markdown: false } | |
| 11 | 15 | } |
| 12 | 16 | |
| 13 | 17 | export const NAME_RE = /^[a-z0-9][a-z0-9._-]*$/ |
| @@ -99,17 +103,94 @@ export function setFm(content: string, key: string, value: string): string { | ||
| 99 | 103 | if (idx === -1) lines.push(line) |
| 100 | 104 | else { |
| 101 | 105 | let end = idx + 1 |
| 102 | - while (end < lines.length && /^\s+\S/.test(lines[end]!)) end++ | |
| 106 | + while (end < lines.length && /^(\s+\S|-\s)/.test(lines[end]!)) end++ | |
| 103 | 107 | lines.splice(idx, end - idx, line) |
| 104 | 108 | } |
| 105 | 109 | return m[1] + lines.join('\n') + m[3] + content.slice(m[0].length) |
| 106 | 110 | } |
| 107 | 111 | |
| 112 | +/** Heading shown in a new skill's body while the name is still empty. */ | |
| 113 | +export const PLACEHOLDER_TITLE = 'New skill' | |
| 114 | + | |
| 115 | +/** Template for a new Markdown item. A skill's heading carries the real name (or a placeholder until one is typed). */ | |
| 108 | 116 | export function newItemContent(type: CatalogItemType, name: string): string { |
| 109 | - const head = `---\nname: ${name}\ndescription: \n---\n\n` | |
| 110 | - if (type === 'skill') return head.replace('description: \n', 'description: ""\n') + `# ${name}\n\nDescribe when and how the agent should use this skill.\n` | |
| 111 | - if (type === 'rule') return head.replace('description: \n', 'description: ""\n') + '- \n' | |
| 112 | - return head.replace('description: \n', 'description: ""\n') + 'You are a specialized subagent. Describe your role here.\n' | |
| 117 | + const head = `---\nname: ${fmValue(name)}\ndescription: ""\n---\n\n` | |
| 118 | + if (type === 'skill') return head + `# ${name || PLACEHOLDER_TITLE}\n\nDescribe when and how the agent should use this skill.\n` | |
| 119 | + if (type === 'rule') return head + '- \n' | |
| 120 | + return head + 'You are a specialized subagent. Describe your role here.\n' | |
| 121 | +} | |
| 122 | + | |
| 123 | +/** Replaces the first `# from` heading line with `# to` (keeps a new skill's title in sync with its name). */ | |
| 124 | +export function retitle(content: string, from: string, to: string): string { | |
| 125 | + if (from === to) return content | |
| 126 | + const lines = content.split('\n') | |
| 127 | + const i = lines.findIndex(l => l.trimEnd() === `# ${from}`) | |
| 128 | + if (i === -1) return content | |
| 129 | + lines[i] = `# ${to}` | |
| 130 | + return lines.join('\n') | |
| 131 | +} | |
| 132 | + | |
| 133 | +/* ------------------------------------------------ frontmatter lists (rule `paths`) */ | |
| 134 | + | |
| 135 | +const FM_RE = /^(---\r?\n)([\s\S]*?)(\r?\n---[ \t]*(?:\r?\n|$))/ | |
| 136 | +const unquote = (v: string) => v.trim().replace(/^(['"])(.*)\1$/, '$2') | |
| 137 | + | |
| 138 | +function splitFlow(inner: string): string[] { | |
| 139 | + const out: string[] = [] | |
| 140 | + let cur = '' | |
| 141 | + let quote = '' | |
| 142 | + let depth = 0 | |
| 143 | + for (const ch of inner) { | |
| 144 | + if (quote) { | |
| 145 | + if (ch === quote) quote = '' | |
| 146 | + else cur += ch | |
| 147 | + } else if (ch === '"' || ch === '\'') quote = ch | |
| 148 | + else if (ch === '{') { depth++; cur += ch } | |
| 149 | + else if (ch === '}') { depth = Math.max(0, depth - 1); cur += ch } | |
| 150 | + else if (ch === ',' && depth === 0) { out.push(cur); cur = '' } | |
| 151 | + else cur += ch | |
| 152 | + } | |
| 153 | + out.push(cur) | |
| 154 | + return out.map(x => x.trim()).filter(Boolean) | |
| 155 | +} | |
| 156 | + | |
| 157 | +/** Reads a frontmatter list: block (`- a`), flow (`[a, b]`) or a single scalar. */ | |
| 158 | +export function getFmList(content: string, key: string): string[] { | |
| 159 | + const m = FM_RE.exec(content) | |
| 160 | + if (!m) return [] | |
| 161 | + const lines = m[2]!.split(/\r?\n/) | |
| 162 | + const idx = lines.findIndex(l => l.startsWith(`${key}:`)) | |
| 163 | + if (idx === -1) return [] | |
| 164 | + const inline = lines[idx]!.slice(key.length + 1).trim() | |
| 165 | + if (inline.startsWith('[') && inline.endsWith(']')) return splitFlow(inline.slice(1, -1)).map(unquote) | |
| 166 | + if (inline) return [unquote(inline)] | |
| 167 | + const out: string[] = [] | |
| 168 | + for (let k = idx + 1; k < lines.length; k++) { | |
| 169 | + const item = /^\s*-\s+(.*)$/.exec(lines[k]!) | |
| 170 | + if (!item) { | |
| 171 | + if (/^\s+\S/.test(lines[k]!)) continue | |
| 172 | + break | |
| 173 | + } | |
| 174 | + out.push(unquote(item[1]!)) | |
| 175 | + } | |
| 176 | + return out.filter(Boolean) | |
| 177 | +} | |
| 178 | + | |
| 179 | +/** Sets (or removes, when empty) a top-level frontmatter list, written as a block list of quoted strings. */ | |
| 180 | +export function setFmList(content: string, key: string, values: string[]): string { | |
| 181 | + const vals = values.map(v => v.trim()).filter(Boolean) | |
| 182 | + const block = vals.length ? [`${key}:`, ...vals.map(v => ` - ${JSON.stringify(v)}`)] : [] | |
| 183 | + const m = FM_RE.exec(content) | |
| 184 | + if (!m) return block.length ? `---\n${block.join('\n')}\n---\n\n${content}` : content | |
| 185 | + const lines = m[2]!.split(/\r?\n/) | |
| 186 | + const idx = lines.findIndex(l => l.startsWith(`${key}:`)) | |
| 187 | + if (idx === -1) lines.push(...block) | |
| 188 | + else { | |
| 189 | + let end = idx + 1 | |
| 190 | + while (end < lines.length && /^(\s+\S|-\s)/.test(lines[end]!)) end++ | |
| 191 | + lines.splice(idx, end - idx, ...block) | |
| 192 | + } | |
| 193 | + return m[1] + lines.join('\n') + m[3] + content.slice(m[0].length) | |
| 113 | 194 | } |
| 114 | 195 | |
| 115 | 196 | export const HOOK_EVENTS = [ |
| @@ -1,13 +1,17 @@ | |||
| 1 | import type { CatalogItemType, HookTemplate, ItemStatus, McpEnvValue, McpTemplate } from '@skilly/core' | 1 | import type { CatalogItemType, HookTemplate, ItemStatus, McpEnvValue, McpTemplate } from '@skilly/core' |
| 2 | +import { splitFrontmatter } from './markdown' | ||
| 2 | 3 | ||
| 3 | export const CAT_TYPES: CatalogItemType[] = ['skill', 'rule', 'agent', 'mcp', 'hook'] | 4 | export const CAT_TYPES: CatalogItemType[] = ['skill', 'rule', 'agent', 'mcp', 'hook'] |
| 4 | 5 | ||
| 5 | -export const CAT_META: Record<CatalogItemType, { label: string, singular: string, icon: string, markdown: boolean }> = { | 6 | +/** Display order in the UI (as in the design: skills, rules, MCP servers, hooks, subagents). */ |
| 6 | - skill: { label: 'Skills', singular: 'Skill', icon: 'i-lucide-sparkles', markdown: true }, | 7 | +export const CAT_ORDER: CatalogItemType[] = ['skill', 'rule', 'mcp', 'hook', 'agent'] |
| 7 | - rule: { label: 'Rules', singular: 'Rule', icon: 'i-lucide-scroll-text', markdown: true }, | 8 | + |
| 8 | - agent: { label: 'Agents', singular: 'Agent', icon: 'i-lucide-bot', markdown: true }, | 9 | +export const CAT_META: Record<CatalogItemType, { label: string, singular: string, newLabel: string, icon: string, markdown: boolean }> = { |
| 9 | - mcp: { label: 'MCP', singular: 'MCP server', icon: 'i-lucide-plug', markdown: false }, | 10 | + skill: { label: 'Skills', singular: 'Skill', newLabel: 'New skill', icon: 'i-lucide-sparkles', markdown: true }, |
| 10 | - hook: { label: 'Hooks', singular: 'Hook', icon: 'i-lucide-webhook', markdown: false } | 11 | + rule: { label: 'Rules', singular: 'Rule', newLabel: 'New rule', icon: 'i-lucide-scroll-text', markdown: true }, |
| 12 | + agent: { label: 'Subagents', singular: 'Subagent', newLabel: 'New subagent', icon: 'i-lucide-bot', markdown: true }, | ||
| 13 | + mcp: { label: 'MCP servers', singular: 'MCP server', newLabel: 'New MCP server', icon: 'i-lucide-plug', markdown: false }, | ||
| 14 | + hook: { label: 'Hooks', singular: 'Hook', newLabel: 'New hook', icon: 'i-lucide-webhook', markdown: false } | ||
| 11 | } | 15 | } |
| 12 | 16 | ||
| 13 | export const NAME_RE = /^[a-z0-9][a-z0-9._-]*$/ | 17 | export const NAME_RE = /^[a-z0-9][a-z0-9._-]*$/ |
| @@ -99,17 +103,94 @@ export function setFm(content: string, key: string, value: string): string { | |||
| 99 | if (idx === -1) lines.push(line) | 103 | if (idx === -1) lines.push(line) |
| 100 | else { | 104 | else { |
| 101 | let end = idx + 1 | 105 | let end = idx + 1 |
| 102 | - while (end < lines.length && /^\s+\S/.test(lines[end]!)) end++ | 106 | + while (end < lines.length && /^(\s+\S|-\s)/.test(lines[end]!)) end++ |
| 103 | lines.splice(idx, end - idx, line) | 107 | lines.splice(idx, end - idx, line) |
| 104 | } | 108 | } |
| 105 | return m[1] + lines.join('\n') + m[3] + content.slice(m[0].length) | 109 | return m[1] + lines.join('\n') + m[3] + content.slice(m[0].length) |
| 106 | } | 110 | } |
| 107 | 111 | ||
| 112 | +/** Heading shown in a new skill's body while the name is still empty. */ | ||
| 113 | +export const PLACEHOLDER_TITLE = 'New skill' | ||
| 114 | + | ||
| 115 | +/** Template for a new Markdown item. A skill's heading carries the real name (or a placeholder until one is typed). */ | ||
| 108 | export function newItemContent(type: CatalogItemType, name: string): string { | 116 | export function newItemContent(type: CatalogItemType, name: string): string { |
| 109 | - const head = `---\nname: ${name}\ndescription: \n---\n\n` | 117 | + const head = `---\nname: ${fmValue(name)}\ndescription: ""\n---\n\n` |
| 110 | - if (type === 'skill') return head.replace('description: \n', 'description: ""\n') + `# ${name}\n\nDescribe when and how the agent should use this skill.\n` | 118 | + if (type === 'skill') return head + `# ${name || PLACEHOLDER_TITLE}\n\nDescribe when and how the agent should use this skill.\n` |
| 111 | - if (type === 'rule') return head.replace('description: \n', 'description: ""\n') + '- \n' | 119 | + if (type === 'rule') return head + '- \n' |
| 112 | - return head.replace('description: \n', 'description: ""\n') + 'You are a specialized subagent. Describe your role here.\n' | 120 | + return head + 'You are a specialized subagent. Describe your role here.\n' |
| 121 | +} | ||
| 122 | + | ||
| 123 | +/** Replaces the first `# from` heading line with `# to` (keeps a new skill's title in sync with its name). */ | ||
| 124 | +export function retitle(content: string, from: string, to: string): string { | ||
| 125 | + if (from === to) return content | ||
| 126 | + const lines = content.split('\n') | ||
| 127 | + const i = lines.findIndex(l => l.trimEnd() === `# ${from}`) | ||
| 128 | + if (i === -1) return content | ||
| 129 | + lines[i] = `# ${to}` | ||
| 130 | + return lines.join('\n') | ||
| 131 | +} | ||
| 132 | + | ||
| 133 | +/* ------------------------------------------------ frontmatter lists (rule `paths`) */ | ||
| 134 | + | ||
| 135 | +const FM_RE = /^(---\r?\n)([\s\S]*?)(\r?\n---[ \t]*(?:\r?\n|$))/ | ||
| 136 | +const unquote = (v: string) => v.trim().replace(/^(['"])(.*)\1$/, '$2') | ||
| 137 | + | ||
| 138 | +function splitFlow(inner: string): string[] { | ||
| 139 | + const out: string[] = [] | ||
| 140 | + let cur = '' | ||
| 141 | + let quote = '' | ||
| 142 | + let depth = 0 | ||
| 143 | + for (const ch of inner) { | ||
| 144 | + if (quote) { | ||
| 145 | + if (ch === quote) quote = '' | ||
| 146 | + else cur += ch | ||
| 147 | + } else if (ch === '"' || ch === '\'') quote = ch | ||
| 148 | + else if (ch === '{') { depth++; cur += ch } | ||
| 149 | + else if (ch === '}') { depth = Math.max(0, depth - 1); cur += ch } | ||
| 150 | + else if (ch === ',' && depth === 0) { out.push(cur); cur = '' } | ||
| 151 | + else cur += ch | ||
| 152 | + } | ||
| 153 | + out.push(cur) | ||
| 154 | + return out.map(x => x.trim()).filter(Boolean) | ||
| 155 | +} | ||
| 156 | + | ||
| 157 | +/** Reads a frontmatter list: block (`- a`), flow (`[a, b]`) or a single scalar. */ | ||
| 158 | +export function getFmList(content: string, key: string): string[] { | ||
| 159 | + const m = FM_RE.exec(content) | ||
| 160 | + if (!m) return [] | ||
| 161 | + const lines = m[2]!.split(/\r?\n/) | ||
| 162 | + const idx = lines.findIndex(l => l.startsWith(`${key}:`)) | ||
| 163 | + if (idx === -1) return [] | ||
| 164 | + const inline = lines[idx]!.slice(key.length + 1).trim() | ||
| 165 | + if (inline.startsWith('[') && inline.endsWith(']')) return splitFlow(inline.slice(1, -1)).map(unquote) | ||
| 166 | + if (inline) return [unquote(inline)] | ||
| 167 | + const out: string[] = [] | ||
| 168 | + for (let k = idx + 1; k < lines.length; k++) { | ||
| 169 | + const item = /^\s*-\s+(.*)$/.exec(lines[k]!) | ||
| 170 | + if (!item) { | ||
| 171 | + if (/^\s+\S/.test(lines[k]!)) continue | ||
| 172 | + break | ||
| 173 | + } | ||
| 174 | + out.push(unquote(item[1]!)) | ||
| 175 | + } | ||
| 176 | + return out.filter(Boolean) | ||
| 177 | +} | ||
| 178 | + | ||
| 179 | +/** Sets (or removes, when empty) a top-level frontmatter list, written as a block list of quoted strings. */ | ||
| 180 | +export function setFmList(content: string, key: string, values: string[]): string { | ||
| 181 | + const vals = values.map(v => v.trim()).filter(Boolean) | ||
| 182 | + const block = vals.length ? [`${key}:`, ...vals.map(v => ` - ${JSON.stringify(v)}`)] : [] | ||
| 183 | + const m = FM_RE.exec(content) | ||
| 184 | + if (!m) return block.length ? `---\n${block.join('\n')}\n---\n\n${content}` : content | ||
| 185 | + const lines = m[2]!.split(/\r?\n/) | ||
| 186 | + const idx = lines.findIndex(l => l.startsWith(`${key}:`)) | ||
| 187 | + if (idx === -1) lines.push(...block) | ||
| 188 | + else { | ||
| 189 | + let end = idx + 1 | ||
| 190 | + while (end < lines.length && /^(\s+\S|-\s)/.test(lines[end]!)) end++ | ||
| 191 | + lines.splice(idx, end - idx, ...block) | ||
| 192 | + } | ||
| 193 | + return m[1] + lines.join('\n') + m[3] + content.slice(m[0].length) | ||
| 113 | } | 194 | } |
| 114 | 195 | ||
| 115 | export const HOOK_EVENTS = [ | 196 | export const HOOK_EVENTS = [ |
added
packages/ui/app/utils/gitConflicts.ts +40 -0 | new file mode 100644 | ||
| @@ -0,0 +1,40 @@ | ||
| 1 | +/** Catalog folder -> item type label, for naming conflicted paths in item terms (brief 4.10). */ | |
| 2 | +const FOLDER_TYPES: Record<string, string> = { | |
| 3 | + skills: 'Skill', | |
| 4 | + rules: 'Rule', | |
| 5 | + agents: 'Subagent', | |
| 6 | + mcp: 'MCP', | |
| 7 | + hooks: 'Hook', | |
| 8 | + presets: 'Preset' | |
| 9 | +} | |
| 10 | + | |
| 11 | +export interface ConflictGroup { | |
| 12 | + /** Stable key: `skills/nuxt`, or the path itself for files outside an item. */ | |
| 13 | + key: string | |
| 14 | + /** Item name ("nuxt"), or the path for files outside an item. */ | |
| 15 | + name: string | |
| 16 | + /** Type label ("Skill"), when the path belongs to an item. */ | |
| 17 | + type?: string | |
| 18 | + paths: string[] | |
| 19 | +} | |
| 20 | + | |
| 21 | +/** Groups conflicted catalog paths by item: every file of `skills/<name>/` belongs to the skill `<name>`. */ | |
| 22 | +export function groupConflicts(paths: string[]): ConflictGroup[] { | |
| 23 | + const groups = new Map<string, ConflictGroup>() | |
| 24 | + for (const p of paths) { | |
| 25 | + const parts = p.replace(/\\/g, '/').split('/') | |
| 26 | + const type = parts.length > 1 ? FOLDER_TYPES[parts[0]!] : undefined | |
| 27 | + let key = p | |
| 28 | + let name = p | |
| 29 | + if (type && parts[1]) { | |
| 30 | + // Folder items (skills) are a directory; the others are a single file named `<name>.<ext>`. | |
| 31 | + const isFolder = parts[0] === 'skills' && parts.length > 2 | |
| 32 | + name = isFolder ? parts[1] : parts[1].replace(/\.[^.]+$/, '') | |
| 33 | + key = `${parts[0]}/${name}` | |
| 34 | + } | |
| 35 | + const g = groups.get(key) ?? { key, name, type, paths: [] } | |
| 36 | + g.paths.push(p) | |
| 37 | + groups.set(key, g) | |
| 38 | + } | |
| 39 | + return [...groups.values()] | |
| 40 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,40 @@ | |||
| 1 | +/** Catalog folder -> item type label, for naming conflicted paths in item terms (brief 4.10). */ | ||
| 2 | +const FOLDER_TYPES: Record<string, string> = { | ||
| 3 | + skills: 'Skill', | ||
| 4 | + rules: 'Rule', | ||
| 5 | + agents: 'Subagent', | ||
| 6 | + mcp: 'MCP', | ||
| 7 | + hooks: 'Hook', | ||
| 8 | + presets: 'Preset' | ||
| 9 | +} | ||
| 10 | + | ||
| 11 | +export interface ConflictGroup { | ||
| 12 | + /** Stable key: `skills/nuxt`, or the path itself for files outside an item. */ | ||
| 13 | + key: string | ||
| 14 | + /** Item name ("nuxt"), or the path for files outside an item. */ | ||
| 15 | + name: string | ||
| 16 | + /** Type label ("Skill"), when the path belongs to an item. */ | ||
| 17 | + type?: string | ||
| 18 | + paths: string[] | ||
| 19 | +} | ||
| 20 | + | ||
| 21 | +/** Groups conflicted catalog paths by item: every file of `skills/<name>/` belongs to the skill `<name>`. */ | ||
| 22 | +export function groupConflicts(paths: string[]): ConflictGroup[] { | ||
| 23 | + const groups = new Map<string, ConflictGroup>() | ||
| 24 | + for (const p of paths) { | ||
| 25 | + const parts = p.replace(/\\/g, '/').split('/') | ||
| 26 | + const type = parts.length > 1 ? FOLDER_TYPES[parts[0]!] : undefined | ||
| 27 | + let key = p | ||
| 28 | + let name = p | ||
| 29 | + if (type && parts[1]) { | ||
| 30 | + // Folder items (skills) are a directory; the others are a single file named `<name>.<ext>`. | ||
| 31 | + const isFolder = parts[0] === 'skills' && parts.length > 2 | ||
| 32 | + name = isFolder ? parts[1] : parts[1].replace(/\.[^.]+$/, '') | ||
| 33 | + key = `${parts[0]}/${name}` | ||
| 34 | + } | ||
| 35 | + const g = groups.get(key) ?? { key, name, type, paths: [] } | ||
| 36 | + g.paths.push(p) | ||
| 37 | + groups.set(key, g) | ||
| 38 | + } | ||
| 39 | + return [...groups.values()] | ||
| 40 | +} | ||
added
packages/ui/app/utils/inventoryView.ts +114 -0 | new file mode 100644 | ||
| @@ -0,0 +1,114 @@ | ||
| 1 | +import type { AgentId, CatalogItemType, HookDetails, InstructionsDetails, InventoryItem, ItemStatus } from '@skilly/core' | |
| 2 | +import type { Tone } from './status' | |
| 3 | + | |
| 4 | +/** | |
| 5 | + * Presentation helpers for inventory items (Home inventory, project Files tab, item drawer). | |
| 6 | + * Pure functions: no Nuxt context needed, unit-tested in packages/ui/test. | |
| 7 | + */ | |
| 8 | + | |
| 9 | +/** Row title: hooks lead with their event, instruction files with their file name. */ | |
| 10 | +export function itemDisplayName(i: InventoryItem): string { | |
| 11 | + if (i.type === 'hook') { | |
| 12 | + const d = i.details as HookDetails | |
| 13 | + return d.matcher ? `${d.event} · ${d.matcher}` : d.event | |
| 14 | + } | |
| 15 | + if (i.type === 'instructions') return (i.details as InstructionsDetails).fileName | |
| 16 | + return i.name | |
| 17 | +} | |
| 18 | + | |
| 19 | +const fwd = (p: string) => p.replace(/\\/g, '/') | |
| 20 | + | |
| 21 | +/** `p` relative to `root` (forward slashes), or the absolute path (forward slashes) when it lives elsewhere. */ | |
| 22 | +export function pathInRoot(root: string, p: string): string { | |
| 23 | + const r = fwd(root).replace(/\/+$/, '') | |
| 24 | + const q = fwd(p) | |
| 25 | + if (r && q.length > r.length && q.slice(0, r.length).toLowerCase() === r.toLowerCase() && q[r.length] === '/') return q.slice(r.length + 1) | |
| 26 | + return q | |
| 27 | +} | |
| 28 | + | |
| 29 | +/** Mono line under a row title: path (with the config key for entries of a shared file). `prefix` is `~/` for Home. */ | |
| 30 | +export function itemLocation(i: InventoryItem, root: string, prefix = ''): string { | |
| 31 | + const rel = pathInRoot(root, i.source.path) | |
| 32 | + const shown = rel === fwd(i.source.path) ? rel : `${prefix}${rel}` | |
| 33 | + if (i.source.kind === 'config-entry') return i.source.configKey ? `${shown} · ${i.source.configKey}` : shown | |
| 34 | + return i.type === 'skill' ? `${shown}/SKILL.md` : shown | |
| 35 | +} | |
| 36 | + | |
| 37 | +/** Directory a Home item lives in, for the "Loaded everywhere" breakdown: `~/.claude`, `~/.codex`, `~/.agents`. */ | |
| 38 | +export function homeDirOf(i: InventoryItem, root: string): string { | |
| 39 | + const rel = pathInRoot(root, i.source.path) | |
| 40 | + if (rel === fwd(i.source.path)) return i.agent === 'codex' ? '~/.codex' : '~/.claude' | |
| 41 | + const first = rel.split('/')[0] ?? rel | |
| 42 | + return `~/${first === '.claude.json' ? '.claude' : first}` | |
| 43 | +} | |
| 44 | + | |
| 45 | +/** Status shown in Home rows ("In catalog" / "Local"). Managed Home items use the same words. */ | |
| 46 | +export function homeStateMeta(state: ItemStatus['state'] | undefined): { tone: Tone, label: string } { | |
| 47 | + switch (state) { | |
| 48 | + case 'in-sync': return { tone: 'ok', label: 'In catalog' } | |
| 49 | + case 'catalog-updated': return { tone: 'info', label: 'Update available' } | |
| 50 | + case 'modified': return { tone: 'accent', label: 'Drift' } | |
| 51 | + case 'differs-from-catalog': return { tone: 'accent', label: 'Differs from catalog' } | |
| 52 | + default: return { tone: 'faint', label: 'Local' } | |
| 53 | + } | |
| 54 | +} | |
| 55 | + | |
| 56 | +/** Types that "Remove from global" supports. MCP servers and hooks of Home are read-only in v1. */ | |
| 57 | +export const MOVABLE_TYPES: ReadonlySet<string> = new Set(['skill', 'rule', 'agent']) | |
| 58 | + | |
| 59 | +/** True when the item can be moved out of Home (skills, rules, subagents not provided by a plugin nor managed by skilly). */ | |
| 60 | +export function canMoveOutOfHome(i: InventoryItem, status?: ItemStatus): boolean { | |
| 61 | + return MOVABLE_TYPES.has(i.type) && !i.source.plugin && !status?.managed | |
| 62 | +} | |
| 63 | + | |
| 64 | +/** True when the item can be copied to the catalog (not instructions, not yet identical to the catalog version). */ | |
| 65 | +export function canAddToCatalog(i: InventoryItem, status?: ItemStatus): boolean { | |
| 66 | + return i.type !== 'instructions' && !i.source.plugin && (status?.state === 'not-in-catalog' || status?.state === 'differs-from-catalog') | |
| 67 | +} | |
| 68 | + | |
| 69 | +export interface ProjectSpecific { | |
| 70 | + /** Names of the projects using the item. */ | |
| 71 | + projects: string[] | |
| 72 | + /** Hint under the suggestion tile. */ | |
| 73 | + hint: string | |
| 74 | +} | |
| 75 | + | |
| 76 | +/** | |
| 77 | + * Brief 4.5: a Home item looks project-specific when its catalog counterpart is enabled in at least one project | |
| 78 | + * but not in all of them. Computed client side from `CatalogResponse.usage`; never moves anything by itself. | |
| 79 | + */ | |
| 80 | +export function projectSpecific( | |
| 81 | + item: InventoryItem, | |
| 82 | + status: ItemStatus | undefined, | |
| 83 | + usage: Record<string, string[]>, | |
| 84 | + projects: { id: string, name: string, exists?: boolean }[] | |
| 85 | +): ProjectSpecific | null { | |
| 86 | + if (!MOVABLE_TYPES.has(item.type) || item.source.plugin) return null | |
| 87 | + const ref = status?.catalogRef ?? { type: item.type as CatalogItemType, name: item.name } | |
| 88 | + const users = usage[`${ref.type}/${ref.name}`] ?? [] | |
| 89 | + const live = projects.filter(p => p.exists !== false) | |
| 90 | + const using = live.filter(p => users.includes(p.id)) | |
| 91 | + if (!using.length || using.length >= live.length) return null | |
| 92 | + const names = using.map(p => p.name) | |
| 93 | + return { projects: names, hint: names.length === 1 ? `used in ${names[0]}` : `used in ${names.length} projects` } | |
| 94 | +} | |
| 95 | + | |
| 96 | +/** Agents that show up in a list of items, in the canonical order. */ | |
| 97 | +export function agentsOf(items: { agent: AgentId }[]): AgentId[] { | |
| 98 | + const set = new Set(items.map(i => i.agent)) | |
| 99 | + return (['claude', 'codex'] as AgentId[]).filter(a => set.has(a)) | |
| 100 | +} | |
| 101 | + | |
| 102 | +/** Splits an instruction file into plain and skilly-managed segments (the AGENTS.md block between skilly:begin and skilly:end). */ | |
| 103 | +export function splitManagedBlock(text: string): { text: string, managed: boolean }[] { | |
| 104 | + const lines = text.split('\n') | |
| 105 | + const begin = lines.findIndex(l => l.startsWith('<!-- skilly:begin')) | |
| 106 | + if (begin === -1) return [{ text, managed: false }] | |
| 107 | + let end = lines.findIndex((l, idx) => idx > begin && l.startsWith('<!-- skilly:end')) | |
| 108 | + if (end === -1) end = lines.length - 1 | |
| 109 | + const out: { text: string, managed: boolean }[] = [] | |
| 110 | + if (begin > 0) out.push({ text: lines.slice(0, begin).join('\n') + '\n', managed: false }) | |
| 111 | + out.push({ text: lines.slice(begin, end + 1).join('\n') + (end < lines.length - 1 ? '\n' : ''), managed: true }) | |
| 112 | + if (end < lines.length - 1) out.push({ text: lines.slice(end + 1).join('\n'), managed: false }) | |
| 113 | + return out | |
| 114 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,114 @@ | |||
| 1 | +import type { AgentId, CatalogItemType, HookDetails, InstructionsDetails, InventoryItem, ItemStatus } from '@skilly/core' | ||
| 2 | +import type { Tone } from './status' | ||
| 3 | + | ||
| 4 | +/** | ||
| 5 | + * Presentation helpers for inventory items (Home inventory, project Files tab, item drawer). | ||
| 6 | + * Pure functions: no Nuxt context needed, unit-tested in packages/ui/test. | ||
| 7 | + */ | ||
| 8 | + | ||
| 9 | +/** Row title: hooks lead with their event, instruction files with their file name. */ | ||
| 10 | +export function itemDisplayName(i: InventoryItem): string { | ||
| 11 | + if (i.type === 'hook') { | ||
| 12 | + const d = i.details as HookDetails | ||
| 13 | + return d.matcher ? `${d.event} · ${d.matcher}` : d.event | ||
| 14 | + } | ||
| 15 | + if (i.type === 'instructions') return (i.details as InstructionsDetails).fileName | ||
| 16 | + return i.name | ||
| 17 | +} | ||
| 18 | + | ||
| 19 | +const fwd = (p: string) => p.replace(/\\/g, '/') | ||
| 20 | + | ||
| 21 | +/** `p` relative to `root` (forward slashes), or the absolute path (forward slashes) when it lives elsewhere. */ | ||
| 22 | +export function pathInRoot(root: string, p: string): string { | ||
| 23 | + const r = fwd(root).replace(/\/+$/, '') | ||
| 24 | + const q = fwd(p) | ||
| 25 | + if (r && q.length > r.length && q.slice(0, r.length).toLowerCase() === r.toLowerCase() && q[r.length] === '/') return q.slice(r.length + 1) | ||
| 26 | + return q | ||
| 27 | +} | ||
| 28 | + | ||
| 29 | +/** Mono line under a row title: path (with the config key for entries of a shared file). `prefix` is `~/` for Home. */ | ||
| 30 | +export function itemLocation(i: InventoryItem, root: string, prefix = ''): string { | ||
| 31 | + const rel = pathInRoot(root, i.source.path) | ||
| 32 | + const shown = rel === fwd(i.source.path) ? rel : `${prefix}${rel}` | ||
| 33 | + if (i.source.kind === 'config-entry') return i.source.configKey ? `${shown} · ${i.source.configKey}` : shown | ||
| 34 | + return i.type === 'skill' ? `${shown}/SKILL.md` : shown | ||
| 35 | +} | ||
| 36 | + | ||
| 37 | +/** Directory a Home item lives in, for the "Loaded everywhere" breakdown: `~/.claude`, `~/.codex`, `~/.agents`. */ | ||
| 38 | +export function homeDirOf(i: InventoryItem, root: string): string { | ||
| 39 | + const rel = pathInRoot(root, i.source.path) | ||
| 40 | + if (rel === fwd(i.source.path)) return i.agent === 'codex' ? '~/.codex' : '~/.claude' | ||
| 41 | + const first = rel.split('/')[0] ?? rel | ||
| 42 | + return `~/${first === '.claude.json' ? '.claude' : first}` | ||
| 43 | +} | ||
| 44 | + | ||
| 45 | +/** Status shown in Home rows ("In catalog" / "Local"). Managed Home items use the same words. */ | ||
| 46 | +export function homeStateMeta(state: ItemStatus['state'] | undefined): { tone: Tone, label: string } { | ||
| 47 | + switch (state) { | ||
| 48 | + case 'in-sync': return { tone: 'ok', label: 'In catalog' } | ||
| 49 | + case 'catalog-updated': return { tone: 'info', label: 'Update available' } | ||
| 50 | + case 'modified': return { tone: 'accent', label: 'Drift' } | ||
| 51 | + case 'differs-from-catalog': return { tone: 'accent', label: 'Differs from catalog' } | ||
| 52 | + default: return { tone: 'faint', label: 'Local' } | ||
| 53 | + } | ||
| 54 | +} | ||
| 55 | + | ||
| 56 | +/** Types that "Remove from global" supports. MCP servers and hooks of Home are read-only in v1. */ | ||
| 57 | +export const MOVABLE_TYPES: ReadonlySet<string> = new Set(['skill', 'rule', 'agent']) | ||
| 58 | + | ||
| 59 | +/** True when the item can be moved out of Home (skills, rules, subagents not provided by a plugin nor managed by skilly). */ | ||
| 60 | +export function canMoveOutOfHome(i: InventoryItem, status?: ItemStatus): boolean { | ||
| 61 | + return MOVABLE_TYPES.has(i.type) && !i.source.plugin && !status?.managed | ||
| 62 | +} | ||
| 63 | + | ||
| 64 | +/** True when the item can be copied to the catalog (not instructions, not yet identical to the catalog version). */ | ||
| 65 | +export function canAddToCatalog(i: InventoryItem, status?: ItemStatus): boolean { | ||
| 66 | + return i.type !== 'instructions' && !i.source.plugin && (status?.state === 'not-in-catalog' || status?.state === 'differs-from-catalog') | ||
| 67 | +} | ||
| 68 | + | ||
| 69 | +export interface ProjectSpecific { | ||
| 70 | + /** Names of the projects using the item. */ | ||
| 71 | + projects: string[] | ||
| 72 | + /** Hint under the suggestion tile. */ | ||
| 73 | + hint: string | ||
| 74 | +} | ||
| 75 | + | ||
| 76 | +/** | ||
| 77 | + * Brief 4.5: a Home item looks project-specific when its catalog counterpart is enabled in at least one project | ||
| 78 | + * but not in all of them. Computed client side from `CatalogResponse.usage`; never moves anything by itself. | ||
| 79 | + */ | ||
| 80 | +export function projectSpecific( | ||
| 81 | + item: InventoryItem, | ||
| 82 | + status: ItemStatus | undefined, | ||
| 83 | + usage: Record<string, string[]>, | ||
| 84 | + projects: { id: string, name: string, exists?: boolean }[] | ||
| 85 | +): ProjectSpecific | null { | ||
| 86 | + if (!MOVABLE_TYPES.has(item.type) || item.source.plugin) return null | ||
| 87 | + const ref = status?.catalogRef ?? { type: item.type as CatalogItemType, name: item.name } | ||
| 88 | + const users = usage[`${ref.type}/${ref.name}`] ?? [] | ||
| 89 | + const live = projects.filter(p => p.exists !== false) | ||
| 90 | + const using = live.filter(p => users.includes(p.id)) | ||
| 91 | + if (!using.length || using.length >= live.length) return null | ||
| 92 | + const names = using.map(p => p.name) | ||
| 93 | + return { projects: names, hint: names.length === 1 ? `used in ${names[0]}` : `used in ${names.length} projects` } | ||
| 94 | +} | ||
| 95 | + | ||
| 96 | +/** Agents that show up in a list of items, in the canonical order. */ | ||
| 97 | +export function agentsOf(items: { agent: AgentId }[]): AgentId[] { | ||
| 98 | + const set = new Set(items.map(i => i.agent)) | ||
| 99 | + return (['claude', 'codex'] as AgentId[]).filter(a => set.has(a)) | ||
| 100 | +} | ||
| 101 | + | ||
| 102 | +/** Splits an instruction file into plain and skilly-managed segments (the AGENTS.md block between skilly:begin and skilly:end). */ | ||
| 103 | +export function splitManagedBlock(text: string): { text: string, managed: boolean }[] { | ||
| 104 | + const lines = text.split('\n') | ||
| 105 | + const begin = lines.findIndex(l => l.startsWith('<!-- skilly:begin')) | ||
| 106 | + if (begin === -1) return [{ text, managed: false }] | ||
| 107 | + let end = lines.findIndex((l, idx) => idx > begin && l.startsWith('<!-- skilly:end')) | ||
| 108 | + if (end === -1) end = lines.length - 1 | ||
| 109 | + const out: { text: string, managed: boolean }[] = [] | ||
| 110 | + if (begin > 0) out.push({ text: lines.slice(0, begin).join('\n') + '\n', managed: false }) | ||
| 111 | + out.push({ text: lines.slice(begin, end + 1).join('\n') + (end < lines.length - 1 ? '\n' : ''), managed: true }) | ||
| 112 | + if (end < lines.length - 1) out.push({ text: lines.slice(end + 1).join('\n'), managed: false }) | ||
| 113 | + return out | ||
| 114 | +} | ||
added
packages/ui/app/utils/planView.ts +57 -0 | new file mode 100644 | ||
| @@ -0,0 +1,57 @@ | ||
| 1 | +import type { PlanAction, ScopeState } from '@skilly/core' | |
| 2 | + | |
| 3 | +/** Plan helpers shared by the Projects table, the pending bar and the review drawer. */ | |
| 4 | + | |
| 5 | +export const isPending = (a: PlanAction) => a.kind !== 'unchanged' | |
| 6 | + | |
| 7 | +export interface PlanSummary { | |
| 8 | + tone: 'ok' | 'info' | 'accent' | |
| 9 | + label: string | |
| 10 | +} | |
| 11 | + | |
| 12 | +const plural = (n: number, w: string) => `${n} ${w}${n === 1 ? '' : 's'}` | |
| 13 | + | |
| 14 | +/** "1 drift · 1 update" / "2 updates" / "Up to date": distinct items, not per-agent actions. */ | |
| 15 | +export function summarizePlan(plan: ScopeState['plan']): PlanSummary { | |
| 16 | + const pending = (plan?.actions ?? []).filter(isPending) | |
| 17 | + const distinct = (pred: (a: PlanAction) => boolean) => new Set(pending.filter(pred).map(a => `${a.type}/${a.name}`)).size | |
| 18 | + const drift = distinct(a => a.kind === 'conflict' && a.conflict === 'modified-locally') | |
| 19 | + const conflicts = distinct(a => a.kind === 'conflict' && a.conflict !== 'modified-locally') | |
| 20 | + const updates = distinct(a => a.kind === 'update') | |
| 21 | + const others = distinct(a => a.kind === 'add' || a.kind === 'remove') | |
| 22 | + const parts: string[] = [] | |
| 23 | + if (drift) parts.push(`${drift} drift`) | |
| 24 | + if (conflicts) parts.push(plural(conflicts, 'conflict')) | |
| 25 | + if (updates) parts.push(plural(updates, 'update')) | |
| 26 | + if (others) parts.push(plural(others, 'change')) | |
| 27 | + if (!parts.length) return { tone: 'ok', label: 'Up to date' } | |
| 28 | + return { tone: drift || conflicts ? 'accent' : 'info', label: parts.join(' · ') } | |
| 29 | +} | |
| 30 | + | |
| 31 | +/** Number of distinct files a set of actions touches (toast copy: "Applied 4 changes · 2 files written"). */ | |
| 32 | +export function filesTouched(actions: PlanAction[]): number { | |
| 33 | + const files = new Set<string>() | |
| 34 | + for (const a of actions) { | |
| 35 | + a.files.forEach(f => files.add(f)) | |
| 36 | + a.configKeys.forEach(c => files.add(c.file)) | |
| 37 | + } | |
| 38 | + return files.size | |
| 39 | +} | |
| 40 | + | |
| 41 | +/** The item's own file when it has one (SKILL.md, rule or subagent markdown), null for shared configuration entries. */ | |
| 42 | +export function comparableFile(a: PlanAction): string | null { | |
| 43 | + const norm = (f: string) => f.replace(/\\/g, '/') | |
| 44 | + if (a.type === 'skill') return a.files.map(norm).find(f => f.endsWith(`/${a.name}/SKILL.md`)) ?? null | |
| 45 | + if (a.type === 'rule' || a.type === 'agent') return a.files.map(norm).find(f => f.endsWith(`/${a.name}.md`)) ?? null | |
| 46 | + return null | |
| 47 | +} | |
| 48 | + | |
| 49 | +/** Where a scope writes, for the hint "Written to .claude/ and .agents/". */ | |
| 50 | +export function writtenToHint(targets: string[], home: boolean): string { | |
| 51 | + const dirs: string[] = [] | |
| 52 | + if (targets.includes('claude')) dirs.push(home ? '~/.claude/' : '.claude/') | |
| 53 | + if (targets.includes('codex')) dirs.push(...(home ? ['~/.agents/', '~/.codex/'] : ['.agents/', '.codex/'])) | |
| 54 | + if (!dirs.length) return 'No target agent: nothing is written' | |
| 55 | + const list = dirs.length > 1 ? `${dirs.slice(0, -1).join(', ')} and ${dirs[dirs.length - 1]}` : dirs[0] | |
| 56 | + return home ? `Written to ${list}` : `Written to ${list} · committed with the project` | |
| 57 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,57 @@ | |||
| 1 | +import type { PlanAction, ScopeState } from '@skilly/core' | ||
| 2 | + | ||
| 3 | +/** Plan helpers shared by the Projects table, the pending bar and the review drawer. */ | ||
| 4 | + | ||
| 5 | +export const isPending = (a: PlanAction) => a.kind !== 'unchanged' | ||
| 6 | + | ||
| 7 | +export interface PlanSummary { | ||
| 8 | + tone: 'ok' | 'info' | 'accent' | ||
| 9 | + label: string | ||
| 10 | +} | ||
| 11 | + | ||
| 12 | +const plural = (n: number, w: string) => `${n} ${w}${n === 1 ? '' : 's'}` | ||
| 13 | + | ||
| 14 | +/** "1 drift · 1 update" / "2 updates" / "Up to date": distinct items, not per-agent actions. */ | ||
| 15 | +export function summarizePlan(plan: ScopeState['plan']): PlanSummary { | ||
| 16 | + const pending = (plan?.actions ?? []).filter(isPending) | ||
| 17 | + const distinct = (pred: (a: PlanAction) => boolean) => new Set(pending.filter(pred).map(a => `${a.type}/${a.name}`)).size | ||
| 18 | + const drift = distinct(a => a.kind === 'conflict' && a.conflict === 'modified-locally') | ||
| 19 | + const conflicts = distinct(a => a.kind === 'conflict' && a.conflict !== 'modified-locally') | ||
| 20 | + const updates = distinct(a => a.kind === 'update') | ||
| 21 | + const others = distinct(a => a.kind === 'add' || a.kind === 'remove') | ||
| 22 | + const parts: string[] = [] | ||
| 23 | + if (drift) parts.push(`${drift} drift`) | ||
| 24 | + if (conflicts) parts.push(plural(conflicts, 'conflict')) | ||
| 25 | + if (updates) parts.push(plural(updates, 'update')) | ||
| 26 | + if (others) parts.push(plural(others, 'change')) | ||
| 27 | + if (!parts.length) return { tone: 'ok', label: 'Up to date' } | ||
| 28 | + return { tone: drift || conflicts ? 'accent' : 'info', label: parts.join(' · ') } | ||
| 29 | +} | ||
| 30 | + | ||
| 31 | +/** Number of distinct files a set of actions touches (toast copy: "Applied 4 changes · 2 files written"). */ | ||
| 32 | +export function filesTouched(actions: PlanAction[]): number { | ||
| 33 | + const files = new Set<string>() | ||
| 34 | + for (const a of actions) { | ||
| 35 | + a.files.forEach(f => files.add(f)) | ||
| 36 | + a.configKeys.forEach(c => files.add(c.file)) | ||
| 37 | + } | ||
| 38 | + return files.size | ||
| 39 | +} | ||
| 40 | + | ||
| 41 | +/** The item's own file when it has one (SKILL.md, rule or subagent markdown), null for shared configuration entries. */ | ||
| 42 | +export function comparableFile(a: PlanAction): string | null { | ||
| 43 | + const norm = (f: string) => f.replace(/\\/g, '/') | ||
| 44 | + if (a.type === 'skill') return a.files.map(norm).find(f => f.endsWith(`/${a.name}/SKILL.md`)) ?? null | ||
| 45 | + if (a.type === 'rule' || a.type === 'agent') return a.files.map(norm).find(f => f.endsWith(`/${a.name}.md`)) ?? null | ||
| 46 | + return null | ||
| 47 | +} | ||
| 48 | + | ||
| 49 | +/** Where a scope writes, for the hint "Written to .claude/ and .agents/". */ | ||
| 50 | +export function writtenToHint(targets: string[], home: boolean): string { | ||
| 51 | + const dirs: string[] = [] | ||
| 52 | + if (targets.includes('claude')) dirs.push(home ? '~/.claude/' : '.claude/') | ||
| 53 | + if (targets.includes('codex')) dirs.push(...(home ? ['~/.agents/', '~/.codex/'] : ['.agents/', '.codex/'])) | ||
| 54 | + if (!dirs.length) return 'No target agent: nothing is written' | ||
| 55 | + const list = dirs.length > 1 ? `${dirs.slice(0, -1).join(', ')} and ${dirs[dirs.length - 1]}` : dirs[0] | ||
| 56 | + return home ? `Written to ${list}` : `Written to ${list} · committed with the project` | ||
| 57 | +} | ||
added
packages/ui/app/utils/relativeTime.ts +15 -0 | new file mode 100644 | ||
| @@ -0,0 +1,15 @@ | ||
| 1 | +/** "just now", "5 min ago", "2 h ago", "3 days ago", then a plain date. Empty string for an invalid date. */ | |
| 2 | +export function timeAgo(iso: string | undefined, now = Date.now()): string { | |
| 3 | + if (!iso) return '' | |
| 4 | + const t = new Date(iso).getTime() | |
| 5 | + if (Number.isNaN(t)) return '' | |
| 6 | + const s = Math.max(0, Math.round((now - t) / 1000)) | |
| 7 | + if (s < 45) return 'just now' | |
| 8 | + const m = Math.round(s / 60) | |
| 9 | + if (m < 60) return `${m} min ago` | |
| 10 | + const h = Math.round(m / 60) | |
| 11 | + if (h < 24) return `${h} h ago` | |
| 12 | + const d = Math.round(h / 24) | |
| 13 | + if (d < 14) return `${d} day${d === 1 ? '' : 's'} ago` | |
| 14 | + return new Date(t).toLocaleDateString() | |
| 15 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,15 @@ | |||
| 1 | +/** "just now", "5 min ago", "2 h ago", "3 days ago", then a plain date. Empty string for an invalid date. */ | ||
| 2 | +export function timeAgo(iso: string | undefined, now = Date.now()): string { | ||
| 3 | + if (!iso) return '' | ||
| 4 | + const t = new Date(iso).getTime() | ||
| 5 | + if (Number.isNaN(t)) return '' | ||
| 6 | + const s = Math.max(0, Math.round((now - t) / 1000)) | ||
| 7 | + if (s < 45) return 'just now' | ||
| 8 | + const m = Math.round(s / 60) | ||
| 9 | + if (m < 60) return `${m} min ago` | ||
| 10 | + const h = Math.round(m / 60) | ||
| 11 | + if (h < 24) return `${h} h ago` | ||
| 12 | + const d = Math.round(h / 24) | ||
| 13 | + if (d < 14) return `${d} day${d === 1 ? '' : 's'} ago` | ||
| 14 | + return new Date(t).toLocaleDateString() | ||
| 15 | +} | ||
added
packages/ui/app/utils/scopeRows.ts +229 -0 | new file mode 100644 | ||
| @@ -0,0 +1,229 @@ | ||
| 1 | +import type { | |
| 2 | + AgentId, CatalogItem, CatalogItemType, ConflictKind, ItemStatus, PlanAction, Preset, ScopeConfig, ScopeInventory, SyncPlan | |
| 3 | +} from '@skilly/core' | |
| 4 | +import { ITEM_STATE_META, ITEM_STATE_SHORT, type Tone } from './status' | |
| 5 | +import { resolveItems } from './resolve' | |
| 6 | + | |
| 7 | +/** | |
| 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. | |
| 11 | + */ | |
| 12 | + | |
| 13 | +export type RowAction = 'resolve' | 'review' | 'import' | |
| 14 | + | |
| 15 | +export interface AgentRowStatus { | |
| 16 | + agent: AgentId | |
| 17 | + tone: Tone | |
| 18 | + label: string | |
| 19 | + /** Short form for per-agent mini statuses. */ | |
| 20 | + short: string | |
| 21 | + action?: RowAction | |
| 22 | + /** Conflict action of the plan, when there is one. */ | |
| 23 | + conflict?: PlanAction | |
| 24 | +} | |
| 25 | + | |
| 26 | +export type RowKind = 'entry' | 'removal' | 'local' | |
| 27 | + | |
| 28 | +export interface ScopeRow { | |
| 29 | + key: string | |
| 30 | + kind: RowKind | |
| 31 | + type: CatalogItemType | |
| 32 | + name: string | |
| 33 | + /** Presets that provide the item. */ | |
| 34 | + presets: string[] | |
| 35 | + /** Added one by one. */ | |
| 36 | + added: boolean | |
| 37 | + /** Matched by an exclusion (exact name or glob). */ | |
| 38 | + excluded: boolean | |
| 39 | + /** Exclusion is an exact name (can be toggled) rather than a glob pattern. */ | |
| 40 | + excludedExact: boolean | |
| 41 | + excludePattern?: string | |
| 42 | + inCatalog: boolean | |
| 43 | + origin: string | |
| 44 | + statuses: AgentRowStatus[] | |
| 45 | + conflicts: PlanAction[] | |
| 46 | + /** True when the switch toggles a preset exclusion; false when it removes an `add`. */ | |
| 47 | + viaPreset: boolean | |
| 48 | + onDemand: { on: boolean, exact: boolean, pattern?: string } | |
| 49 | +} | |
| 50 | + | |
| 51 | +export const ROW_TYPE_ORDER: CatalogItemType[] = ['skill', 'rule', 'mcp', 'hook', 'agent'] | |
| 52 | + | |
| 53 | +export function globMatches(pattern: string, name: string): boolean { | |
| 54 | + if (!pattern.includes('*')) return pattern === name | |
| 55 | + const esc = (s: string) => s.replace(/[.+?^${}()|[\]\\]/g, c => '\\' + c) | |
| 56 | + return new RegExp('^' + pattern.split('*').map(esc).join('.*') + '$').test(name) | |
| 57 | +} | |
| 58 | + | |
| 59 | +/** How `name` is matched by a list: exactly, or by a glob pattern. */ | |
| 60 | +export function matchIn(list: string[] | undefined, name: string): { exact: boolean, pattern?: string } | null { | |
| 61 | + const l = list ?? [] | |
| 62 | + if (l.includes(name)) return { exact: true } | |
| 63 | + const g = l.find(p => p.includes('*') && globMatches(p, name)) | |
| 64 | + return g ? { exact: false, pattern: g } : null | |
| 65 | +} | |
| 66 | + | |
| 67 | +/** Label of a staged conflict resolution, shown next to the row. */ | |
| 68 | +export const RESOLUTION_LABEL = { | |
| 69 | + 'keep-local': 'keep local', | |
| 70 | + 'take-catalog': 'take catalog', | |
| 71 | + 'push-to-catalog': 'push to catalog' | |
| 72 | +} as const | |
| 73 | + | |
| 74 | +const CONFLICT_STATUS: Record<ConflictKind, { label: string, short: string }> = { | |
| 75 | + 'modified-locally': { label: 'Drift', short: 'Drift' }, | |
| 76 | + 'exists-unmanaged': { label: 'Exists, not managed', short: 'Exists' }, | |
| 77 | + 'missing-in-catalog': { label: 'Not in catalog', short: 'Missing' } | |
| 78 | +} | |
| 79 | + | |
| 80 | +export interface HomeNames { skill: Set<string>, mcp: Set<string> } | |
| 81 | + | |
| 82 | +/** Plan actions that write the "hide global" entries are not items of the project: they have no row. */ | |
| 83 | +export function isHideAction(a: PlanAction, config: ScopeConfig, entryKeys: Set<string>, homeNames?: HomeNames): boolean { | |
| 84 | + if (a.type !== 'skill' && a.type !== 'mcp') return false | |
| 85 | + if (entryKeys.has(`${a.type}/${a.name}`)) return false | |
| 86 | + if (!homeNames || !homeNames[a.type].has(a.name)) return false | |
| 87 | + const patterns = a.type === 'skill' ? config.hideGlobal.skills : config.hideGlobal.mcp | |
| 88 | + return a.kind === 'remove' || !!matchIn(patterns, a.name) | |
| 89 | +} | |
| 90 | + | |
| 91 | +function statusFromAction(a: PlanAction | undefined, inv: ItemStatus | undefined, excluded: boolean, agent: AgentId): AgentRowStatus { | |
| 92 | + if (a) { | |
| 93 | + switch (a.kind) { | |
| 94 | + case 'conflict': { | |
| 95 | + const c = CONFLICT_STATUS[a.conflict ?? 'modified-locally'] | |
| 96 | + return { agent, tone: 'accent', label: c.label, short: c.short, action: 'resolve', conflict: a } | |
| 97 | + } | |
| 98 | + case 'add': return { agent, tone: 'info', label: 'Will add', short: 'Add', action: 'review' } | |
| 99 | + case 'update': | |
| 100 | + return a.catalogUpdated | |
| 101 | + ? { agent, tone: 'info', label: 'Update available', short: 'Update', action: 'review' } | |
| 102 | + : { agent, tone: 'info', label: 'Will update', short: 'Update', action: 'review' } | |
| 103 | + case 'remove': return { agent, tone: 'accent', label: 'Will remove', short: 'Remove', action: 'review' } | |
| 104 | + case 'unchanged': return { agent, tone: 'ok', label: 'Up to date', short: 'Up to date' } | |
| 105 | + } | |
| 106 | + } | |
| 107 | + if (excluded) return { agent, tone: 'faint', label: 'Not installed', short: 'Off' } | |
| 108 | + if (inv) { | |
| 109 | + const meta = ITEM_STATE_META[inv.state] | |
| 110 | + return { agent, tone: meta.tone, label: meta.label, short: ITEM_STATE_SHORT[inv.state] } | |
| 111 | + } | |
| 112 | + return { agent, tone: 'faint', label: 'Not written yet', short: 'Pending' } | |
| 113 | +} | |
| 114 | + | |
| 115 | +export interface BuildRowsInput { | |
| 116 | + config: ScopeConfig | |
| 117 | + presets: Preset[] | |
| 118 | + catalogItems: CatalogItem[] | |
| 119 | + plan: SyncPlan | null | |
| 120 | + inventory: ScopeInventory | null | |
| 121 | + statuses: Record<string, ItemStatus> | |
| 122 | + /** Names of the Home skills / MCP servers, to tell "hide global" actions from item actions. */ | |
| 123 | + homeNames?: HomeNames | |
| 124 | + /** Also list unmanaged local items of the scope (projects). */ | |
| 125 | + includeLocal: boolean | |
| 126 | +} | |
| 127 | + | |
| 128 | +export function buildRows(input: BuildRowsInput): ScopeRow[] { | |
| 129 | + const { config, presets, catalogItems, plan, inventory, statuses } = input | |
| 130 | + const resolved = resolveItems(presets, config.presets, config.add) | |
| 131 | + 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}`)) | |
| 133 | + 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 => { | |
| 135 | + for (const it of inventory?.items ?? []) { | |
| 136 | + const st = statuses[it.id] | |
| 137 | + if (it.agent === agent && st?.catalogRef?.type === t && st.catalogRef.name === n) return st | |
| 138 | + } | |
| 139 | + return undefined | |
| 140 | + } | |
| 141 | + | |
| 142 | + const rows: ScopeRow[] = [] | |
| 143 | + | |
| 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 | + }) | |
| 173 | + } | |
| 174 | + | |
| 175 | + // Lock entries that are no longer enabled: removal or conflict actions without a configured item. | |
| 176 | + const seen = new Set<string>() | |
| 177 | + for (const a of plan?.actions ?? []) { | |
| 178 | + const key = `${a.type}/${a.name}` | |
| 179 | + if (entryKeys.has(key) || seen.has(key)) continue | |
| 180 | + if (a.kind !== 'remove' && a.kind !== 'conflict') continue | |
| 181 | + if (isHideAction(a, config, entryKeys, input.homeNames)) continue | |
| 182 | + seen.add(key) | |
| 183 | + const st = actionsOf(a.type, a.name).map(x => statusFromAction(x, undefined, false, x.agent)) | |
| 184 | + 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, | |
| 187 | + conflicts: st.flatMap(s => (s.conflict ? [s.conflict] : [])), viaPreset: false, onDemand: { on: false, exact: true } | |
| 188 | + }) | |
| 189 | + } | |
| 190 | + | |
| 191 | + if (input.includeLocal) { | |
| 192 | + const groups = new Map<string, ScopeRow>() | |
| 193 | + for (const it of inventory?.items ?? []) { | |
| 194 | + const st = statuses[it.id] | |
| 195 | + if (it.type === 'instructions' || it.type === 'hook' || it.source.plugin || st?.managed) continue | |
| 196 | + const t = it.type as CatalogItemType | |
| 197 | + const key = `${t}/${it.name}` | |
| 198 | + if (entryKeys.has(key) || seen.has(key)) continue | |
| 199 | + const state = st?.state ?? 'not-in-catalog' | |
| 200 | + 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 | + } | |
| 207 | + 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 } | |
| 210 | + } | |
| 211 | + row.statuses.push(status) | |
| 212 | + groups.set(key, row) | |
| 213 | + } | |
| 214 | + rows.push(...groups.values()) | |
| 215 | + } | |
| 216 | + | |
| 217 | + const kindOrder: Record<RowKind, number> = { entry: 0, removal: 1, local: 2 } | |
| 218 | + return rows.sort((a, b) => | |
| 219 | + ROW_TYPE_ORDER.indexOf(a.type) - ROW_TYPE_ORDER.indexOf(b.type) | |
| 220 | + || kindOrder[a.kind] - kindOrder[b.kind] | |
| 221 | + || a.name.localeCompare(b.name)) | |
| 222 | +} | |
| 223 | + | |
| 224 | +/** One merged status when every agent agrees, otherwise the caller shows one mini status per agent. */ | |
| 225 | +export function mergeStatuses(statuses: AgentRowStatus[]): AgentRowStatus | null { | |
| 226 | + if (!statuses.length) return null | |
| 227 | + const first = statuses[0]! | |
| 228 | + return statuses.every(s => s.label === first.label && s.tone === first.tone) ? first : null | |
| 229 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,229 @@ | |||
| 1 | +import type { | ||
| 2 | + AgentId, CatalogItem, CatalogItemType, ConflictKind, ItemStatus, PlanAction, Preset, ScopeConfig, ScopeInventory, SyncPlan | ||
| 3 | +} from '@skilly/core' | ||
| 4 | +import { ITEM_STATE_META, ITEM_STATE_SHORT, type Tone } from './status' | ||
| 5 | +import { resolveItems } from './resolve' | ||
| 6 | + | ||
| 7 | +/** | ||
| 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. | ||
| 11 | + */ | ||
| 12 | + | ||
| 13 | +export type RowAction = 'resolve' | 'review' | 'import' | ||
| 14 | + | ||
| 15 | +export interface AgentRowStatus { | ||
| 16 | + agent: AgentId | ||
| 17 | + tone: Tone | ||
| 18 | + label: string | ||
| 19 | + /** Short form for per-agent mini statuses. */ | ||
| 20 | + short: string | ||
| 21 | + action?: RowAction | ||
| 22 | + /** Conflict action of the plan, when there is one. */ | ||
| 23 | + conflict?: PlanAction | ||
| 24 | +} | ||
| 25 | + | ||
| 26 | +export type RowKind = 'entry' | 'removal' | 'local' | ||
| 27 | + | ||
| 28 | +export interface ScopeRow { | ||
| 29 | + key: string | ||
| 30 | + kind: RowKind | ||
| 31 | + type: CatalogItemType | ||
| 32 | + name: string | ||
| 33 | + /** Presets that provide the item. */ | ||
| 34 | + presets: string[] | ||
| 35 | + /** Added one by one. */ | ||
| 36 | + added: boolean | ||
| 37 | + /** Matched by an exclusion (exact name or glob). */ | ||
| 38 | + excluded: boolean | ||
| 39 | + /** Exclusion is an exact name (can be toggled) rather than a glob pattern. */ | ||
| 40 | + excludedExact: boolean | ||
| 41 | + excludePattern?: string | ||
| 42 | + inCatalog: boolean | ||
| 43 | + origin: string | ||
| 44 | + statuses: AgentRowStatus[] | ||
| 45 | + conflicts: PlanAction[] | ||
| 46 | + /** True when the switch toggles a preset exclusion; false when it removes an `add`. */ | ||
| 47 | + viaPreset: boolean | ||
| 48 | + onDemand: { on: boolean, exact: boolean, pattern?: string } | ||
| 49 | +} | ||
| 50 | + | ||
| 51 | +export const ROW_TYPE_ORDER: CatalogItemType[] = ['skill', 'rule', 'mcp', 'hook', 'agent'] | ||
| 52 | + | ||
| 53 | +export function globMatches(pattern: string, name: string): boolean { | ||
| 54 | + if (!pattern.includes('*')) return pattern === name | ||
| 55 | + const esc = (s: string) => s.replace(/[.+?^${}()|[\]\\]/g, c => '\\' + c) | ||
| 56 | + return new RegExp('^' + pattern.split('*').map(esc).join('.*') + '$').test(name) | ||
| 57 | +} | ||
| 58 | + | ||
| 59 | +/** How `name` is matched by a list: exactly, or by a glob pattern. */ | ||
| 60 | +export function matchIn(list: string[] | undefined, name: string): { exact: boolean, pattern?: string } | null { | ||
| 61 | + const l = list ?? [] | ||
| 62 | + if (l.includes(name)) return { exact: true } | ||
| 63 | + const g = l.find(p => p.includes('*') && globMatches(p, name)) | ||
| 64 | + return g ? { exact: false, pattern: g } : null | ||
| 65 | +} | ||
| 66 | + | ||
| 67 | +/** Label of a staged conflict resolution, shown next to the row. */ | ||
| 68 | +export const RESOLUTION_LABEL = { | ||
| 69 | + 'keep-local': 'keep local', | ||
| 70 | + 'take-catalog': 'take catalog', | ||
| 71 | + 'push-to-catalog': 'push to catalog' | ||
| 72 | +} as const | ||
| 73 | + | ||
| 74 | +const CONFLICT_STATUS: Record<ConflictKind, { label: string, short: string }> = { | ||
| 75 | + 'modified-locally': { label: 'Drift', short: 'Drift' }, | ||
| 76 | + 'exists-unmanaged': { label: 'Exists, not managed', short: 'Exists' }, | ||
| 77 | + 'missing-in-catalog': { label: 'Not in catalog', short: 'Missing' } | ||
| 78 | +} | ||
| 79 | + | ||
| 80 | +export interface HomeNames { skill: Set<string>, mcp: Set<string> } | ||
| 81 | + | ||
| 82 | +/** Plan actions that write the "hide global" entries are not items of the project: they have no row. */ | ||
| 83 | +export function isHideAction(a: PlanAction, config: ScopeConfig, entryKeys: Set<string>, homeNames?: HomeNames): boolean { | ||
| 84 | + if (a.type !== 'skill' && a.type !== 'mcp') return false | ||
| 85 | + if (entryKeys.has(`${a.type}/${a.name}`)) return false | ||
| 86 | + if (!homeNames || !homeNames[a.type].has(a.name)) return false | ||
| 87 | + const patterns = a.type === 'skill' ? config.hideGlobal.skills : config.hideGlobal.mcp | ||
| 88 | + return a.kind === 'remove' || !!matchIn(patterns, a.name) | ||
| 89 | +} | ||
| 90 | + | ||
| 91 | +function statusFromAction(a: PlanAction | undefined, inv: ItemStatus | undefined, excluded: boolean, agent: AgentId): AgentRowStatus { | ||
| 92 | + if (a) { | ||
| 93 | + switch (a.kind) { | ||
| 94 | + case 'conflict': { | ||
| 95 | + const c = CONFLICT_STATUS[a.conflict ?? 'modified-locally'] | ||
| 96 | + return { agent, tone: 'accent', label: c.label, short: c.short, action: 'resolve', conflict: a } | ||
| 97 | + } | ||
| 98 | + case 'add': return { agent, tone: 'info', label: 'Will add', short: 'Add', action: 'review' } | ||
| 99 | + case 'update': | ||
| 100 | + return a.catalogUpdated | ||
| 101 | + ? { agent, tone: 'info', label: 'Update available', short: 'Update', action: 'review' } | ||
| 102 | + : { agent, tone: 'info', label: 'Will update', short: 'Update', action: 'review' } | ||
| 103 | + case 'remove': return { agent, tone: 'accent', label: 'Will remove', short: 'Remove', action: 'review' } | ||
| 104 | + case 'unchanged': return { agent, tone: 'ok', label: 'Up to date', short: 'Up to date' } | ||
| 105 | + } | ||
| 106 | + } | ||
| 107 | + if (excluded) return { agent, tone: 'faint', label: 'Not installed', short: 'Off' } | ||
| 108 | + if (inv) { | ||
| 109 | + const meta = ITEM_STATE_META[inv.state] | ||
| 110 | + return { agent, tone: meta.tone, label: meta.label, short: ITEM_STATE_SHORT[inv.state] } | ||
| 111 | + } | ||
| 112 | + return { agent, tone: 'faint', label: 'Not written yet', short: 'Pending' } | ||
| 113 | +} | ||
| 114 | + | ||
| 115 | +export interface BuildRowsInput { | ||
| 116 | + config: ScopeConfig | ||
| 117 | + presets: Preset[] | ||
| 118 | + catalogItems: CatalogItem[] | ||
| 119 | + plan: SyncPlan | null | ||
| 120 | + inventory: ScopeInventory | null | ||
| 121 | + statuses: Record<string, ItemStatus> | ||
| 122 | + /** Names of the Home skills / MCP servers, to tell "hide global" actions from item actions. */ | ||
| 123 | + homeNames?: HomeNames | ||
| 124 | + /** Also list unmanaged local items of the scope (projects). */ | ||
| 125 | + includeLocal: boolean | ||
| 126 | +} | ||
| 127 | + | ||
| 128 | +export function buildRows(input: BuildRowsInput): ScopeRow[] { | ||
| 129 | + const { config, presets, catalogItems, plan, inventory, statuses } = input | ||
| 130 | + const resolved = resolveItems(presets, config.presets, config.add) | ||
| 131 | + 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}`)) | ||
| 133 | + 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 => { | ||
| 135 | + for (const it of inventory?.items ?? []) { | ||
| 136 | + const st = statuses[it.id] | ||
| 137 | + if (it.agent === agent && st?.catalogRef?.type === t && st.catalogRef.name === n) return st | ||
| 138 | + } | ||
| 139 | + return undefined | ||
| 140 | + } | ||
| 141 | + | ||
| 142 | + const rows: ScopeRow[] = [] | ||
| 143 | + | ||
| 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 | + }) | ||
| 173 | + } | ||
| 174 | + | ||
| 175 | + // Lock entries that are no longer enabled: removal or conflict actions without a configured item. | ||
| 176 | + const seen = new Set<string>() | ||
| 177 | + for (const a of plan?.actions ?? []) { | ||
| 178 | + const key = `${a.type}/${a.name}` | ||
| 179 | + if (entryKeys.has(key) || seen.has(key)) continue | ||
| 180 | + if (a.kind !== 'remove' && a.kind !== 'conflict') continue | ||
| 181 | + if (isHideAction(a, config, entryKeys, input.homeNames)) continue | ||
| 182 | + seen.add(key) | ||
| 183 | + const st = actionsOf(a.type, a.name).map(x => statusFromAction(x, undefined, false, x.agent)) | ||
| 184 | + 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, | ||
| 187 | + conflicts: st.flatMap(s => (s.conflict ? [s.conflict] : [])), viaPreset: false, onDemand: { on: false, exact: true } | ||
| 188 | + }) | ||
| 189 | + } | ||
| 190 | + | ||
| 191 | + if (input.includeLocal) { | ||
| 192 | + const groups = new Map<string, ScopeRow>() | ||
| 193 | + for (const it of inventory?.items ?? []) { | ||
| 194 | + const st = statuses[it.id] | ||
| 195 | + if (it.type === 'instructions' || it.type === 'hook' || it.source.plugin || st?.managed) continue | ||
| 196 | + const t = it.type as CatalogItemType | ||
| 197 | + const key = `${t}/${it.name}` | ||
| 198 | + if (entryKeys.has(key) || seen.has(key)) continue | ||
| 199 | + const state = st?.state ?? 'not-in-catalog' | ||
| 200 | + 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 | + } | ||
| 207 | + 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 } | ||
| 210 | + } | ||
| 211 | + row.statuses.push(status) | ||
| 212 | + groups.set(key, row) | ||
| 213 | + } | ||
| 214 | + rows.push(...groups.values()) | ||
| 215 | + } | ||
| 216 | + | ||
| 217 | + const kindOrder: Record<RowKind, number> = { entry: 0, removal: 1, local: 2 } | ||
| 218 | + return rows.sort((a, b) => | ||
| 219 | + ROW_TYPE_ORDER.indexOf(a.type) - ROW_TYPE_ORDER.indexOf(b.type) | ||
| 220 | + || kindOrder[a.kind] - kindOrder[b.kind] | ||
| 221 | + || a.name.localeCompare(b.name)) | ||
| 222 | +} | ||
| 223 | + | ||
| 224 | +/** One merged status when every agent agrees, otherwise the caller shows one mini status per agent. */ | ||
| 225 | +export function mergeStatuses(statuses: AgentRowStatus[]): AgentRowStatus | null { | ||
| 226 | + if (!statuses.length) return null | ||
| 227 | + const first = statuses[0]! | ||
| 228 | + return statuses.every(s => s.label === first.label && s.tone === first.tone) ? first : null | ||
| 229 | +} | ||
added
packages/ui/test/catalog-frontmatter.test.ts +62 -0 | new file mode 100644 | ||
| @@ -0,0 +1,62 @@ | ||
| 1 | +import { describe, expect, it } from 'vitest' | |
| 2 | +import { parseFrontmatter } from '../../core/src/util.ts' | |
| 3 | +import { getFm, getFmList, newItemContent, retitle, setFm, setFmList } from '../app/utils/catalog.ts' | |
| 4 | + | |
| 5 | +describe('newItemContent / retitle', () => { | |
| 6 | + it('uses the real name in the skill heading and a placeholder before a name exists', () => { | |
| 7 | + expect(newItemContent('skill', 'seo-audit')).toContain('# seo-audit\n') | |
| 8 | + expect(newItemContent('skill', '')).toContain('# New skill\n') | |
| 9 | + expect(newItemContent('skill', '')).not.toContain('new-item') | |
| 10 | + }) | |
| 11 | + | |
| 12 | + it('retitles the heading as the name is typed', () => { | |
| 13 | + let c = newItemContent('skill', '') | |
| 14 | + c = retitle(c, 'New skill', 'my') | |
| 15 | + c = retitle(c, 'my', 'my-skill') | |
| 16 | + expect(c).toContain('# my-skill\n') | |
| 17 | + expect(c).not.toContain('# my\n') | |
| 18 | + // an edited heading is left alone | |
| 19 | + const edited = c.replace('# my-skill', '# Custom title') | |
| 20 | + expect(retitle(edited, 'my-skill', 'other')).toBe(edited) | |
| 21 | + }) | |
| 22 | +}) | |
| 23 | + | |
| 24 | +describe('frontmatter lists (rule paths)', () => { | |
| 25 | + const base = '---\nname: ts\ndescription: "x"\n---\n\nbody\n' | |
| 26 | + | |
| 27 | + it('writes a block list the core YAML parser reads back', () => { | |
| 28 | + const globs = ['src/**/*.ts', 'app/**/*.{ts,tsx}', '*.md'] | |
| 29 | + const out = setFmList(base, 'paths', globs) | |
| 30 | + expect(parseFrontmatter(out).frontmatter.paths).toEqual(globs) | |
| 31 | + expect(getFmList(out, 'paths')).toEqual(globs) | |
| 32 | + expect(out.endsWith('\nbody\n')).toBe(true) | |
| 33 | + }) | |
| 34 | + | |
| 35 | + it('replaces and removes an existing list without touching other keys', () => { | |
| 36 | + let out = setFmList(base, 'paths', ['a/**', 'b/**']) | |
| 37 | + out = setFmList(out, 'paths', ['c/**']) | |
| 38 | + expect(getFmList(out, 'paths')).toEqual(['c/**']) | |
| 39 | + expect(getFm(out, 'name')).toBe('ts') | |
| 40 | + out = setFmList(out, 'paths', []) | |
| 41 | + expect(out).toBe(base) | |
| 42 | + }) | |
| 43 | + | |
| 44 | + it('reads flow lists and single scalars', () => { | |
| 45 | + expect(getFmList('---\npaths: ["a/**", \'b/*.{x,y}\']\n---\n', 'paths')).toEqual(['a/**', 'b/*.{x,y}']) | |
| 46 | + expect(getFmList('---\npaths: src/**/*.ts\n---\n', 'paths')).toEqual(['src/**/*.ts']) | |
| 47 | + expect(getFmList('---\nname: a\n---\n', 'paths')).toEqual([]) | |
| 48 | + }) | |
| 49 | + | |
| 50 | + it('keeps a list intact when another key is set', () => { | |
| 51 | + const withPaths = setFmList(base, 'paths', ['a/**']) | |
| 52 | + const out = setFm(withPaths, 'description', 'new') | |
| 53 | + expect(getFmList(out, 'paths')).toEqual(['a/**']) | |
| 54 | + expect(getFm(out, 'description')).toBe('new') | |
| 55 | + }) | |
| 56 | + | |
| 57 | + it('replaces an unindented block list when the key is rewritten', () => { | |
| 58 | + const c = '---\nname: a\npaths:\n- x/**\n- y/**\n---\nbody\n' | |
| 59 | + expect(getFmList(c, 'paths')).toEqual(['x/**', 'y/**']) | |
| 60 | + expect(getFmList(setFmList(c, 'paths', ['z']), 'paths')).toEqual(['z']) | |
| 61 | + }) | |
| 62 | +}) | |
| new file mode 100644 | |||
| @@ -0,0 +1,62 @@ | |||
| 1 | +import { describe, expect, it } from 'vitest' | ||
| 2 | +import { parseFrontmatter } from '../../core/src/util.ts' | ||
| 3 | +import { getFm, getFmList, newItemContent, retitle, setFm, setFmList } from '../app/utils/catalog.ts' | ||
| 4 | + | ||
| 5 | +describe('newItemContent / retitle', () => { | ||
| 6 | + it('uses the real name in the skill heading and a placeholder before a name exists', () => { | ||
| 7 | + expect(newItemContent('skill', 'seo-audit')).toContain('# seo-audit\n') | ||
| 8 | + expect(newItemContent('skill', '')).toContain('# New skill\n') | ||
| 9 | + expect(newItemContent('skill', '')).not.toContain('new-item') | ||
| 10 | + }) | ||
| 11 | + | ||
| 12 | + it('retitles the heading as the name is typed', () => { | ||
| 13 | + let c = newItemContent('skill', '') | ||
| 14 | + c = retitle(c, 'New skill', 'my') | ||
| 15 | + c = retitle(c, 'my', 'my-skill') | ||
| 16 | + expect(c).toContain('# my-skill\n') | ||
| 17 | + expect(c).not.toContain('# my\n') | ||
| 18 | + // an edited heading is left alone | ||
| 19 | + const edited = c.replace('# my-skill', '# Custom title') | ||
| 20 | + expect(retitle(edited, 'my-skill', 'other')).toBe(edited) | ||
| 21 | + }) | ||
| 22 | +}) | ||
| 23 | + | ||
| 24 | +describe('frontmatter lists (rule paths)', () => { | ||
| 25 | + const base = '---\nname: ts\ndescription: "x"\n---\n\nbody\n' | ||
| 26 | + | ||
| 27 | + it('writes a block list the core YAML parser reads back', () => { | ||
| 28 | + const globs = ['src/**/*.ts', 'app/**/*.{ts,tsx}', '*.md'] | ||
| 29 | + const out = setFmList(base, 'paths', globs) | ||
| 30 | + expect(parseFrontmatter(out).frontmatter.paths).toEqual(globs) | ||
| 31 | + expect(getFmList(out, 'paths')).toEqual(globs) | ||
| 32 | + expect(out.endsWith('\nbody\n')).toBe(true) | ||
| 33 | + }) | ||
| 34 | + | ||
| 35 | + it('replaces and removes an existing list without touching other keys', () => { | ||
| 36 | + let out = setFmList(base, 'paths', ['a/**', 'b/**']) | ||
| 37 | + out = setFmList(out, 'paths', ['c/**']) | ||
| 38 | + expect(getFmList(out, 'paths')).toEqual(['c/**']) | ||
| 39 | + expect(getFm(out, 'name')).toBe('ts') | ||
| 40 | + out = setFmList(out, 'paths', []) | ||
| 41 | + expect(out).toBe(base) | ||
| 42 | + }) | ||
| 43 | + | ||
| 44 | + it('reads flow lists and single scalars', () => { | ||
| 45 | + expect(getFmList('---\npaths: ["a/**", \'b/*.{x,y}\']\n---\n', 'paths')).toEqual(['a/**', 'b/*.{x,y}']) | ||
| 46 | + expect(getFmList('---\npaths: src/**/*.ts\n---\n', 'paths')).toEqual(['src/**/*.ts']) | ||
| 47 | + expect(getFmList('---\nname: a\n---\n', 'paths')).toEqual([]) | ||
| 48 | + }) | ||
| 49 | + | ||
| 50 | + it('keeps a list intact when another key is set', () => { | ||
| 51 | + const withPaths = setFmList(base, 'paths', ['a/**']) | ||
| 52 | + const out = setFm(withPaths, 'description', 'new') | ||
| 53 | + expect(getFmList(out, 'paths')).toEqual(['a/**']) | ||
| 54 | + expect(getFm(out, 'description')).toBe('new') | ||
| 55 | + }) | ||
| 56 | + | ||
| 57 | + it('replaces an unindented block list when the key is rewritten', () => { | ||
| 58 | + const c = '---\nname: a\npaths:\n- x/**\n- y/**\n---\nbody\n' | ||
| 59 | + expect(getFmList(c, 'paths')).toEqual(['x/**', 'y/**']) | ||
| 60 | + expect(getFmList(setFmList(c, 'paths', ['z']), 'paths')).toEqual(['z']) | ||
| 61 | + }) | ||
| 62 | +}) | ||
added
packages/ui/test/scope-views.test.ts +76 -0 | new file mode 100644 | ||
| @@ -0,0 +1,76 @@ | ||
| 1 | +import { describe, expect, it } from 'vitest' | |
| 2 | +import type { InventoryItem, ScopeConfig, SyncPlan } from '@skilly/core' | |
| 3 | +import { buildRows, matchIn, mergeStatuses } from '../app/utils/scopeRows' | |
| 4 | +import { projectSpecific, splitManagedBlock, homeDirOf } from '../app/utils/inventoryView' | |
| 5 | +import { summarizePlan, writtenToHint } from '../app/utils/planView' | |
| 6 | + | |
| 7 | +const config = (over: Partial<ScopeConfig> = {}): ScopeConfig => ({ | |
| 8 | + version: 1, targets: ['claude'], presets: ['base'], add: { skill: ['extra'] }, exclude: {}, onDemand: { skills: ['seo-*'] }, | |
| 9 | + hideGlobal: { skills: [], mcp: [] }, ...over | |
| 10 | +}) | |
| 11 | +const presets = [{ name: 'base', includes: [], items: { skill: ['pr-review', 'seo-audit'] } }] | |
| 12 | +const plan = (actions: SyncPlan['actions']): SyncPlan => ({ scope: 'project', root: '/p', actions, warnings: [], missing: [] }) | |
| 13 | +const act = (kind: any, name: string, extra: object = {}) => | |
| 14 | + ({ id: `${kind}:claude:skill/${name}`, kind, type: 'skill', name, agent: 'claude', files: [], configKeys: [], reason: '', ...extra }) as SyncPlan['actions'][number] | |
| 15 | + | |
| 16 | +describe('buildRows', () => { | |
| 17 | + it('lists preset and added items with origin, exclusion and on-demand glob', () => { | |
| 18 | + const rows = buildRows({ | |
| 19 | + config: config({ exclude: { skill: ['pr-review'] } }), presets, catalogItems: [], includeLocal: false, | |
| 20 | + plan: plan([act('unchanged', 'seo-audit'), act('conflict', 'extra', { conflict: 'modified-locally' })]), inventory: null, statuses: {} | |
| 21 | + }) | |
| 22 | + const by = Object.fromEntries(rows.map(r => [r.name, r])) | |
| 23 | + expect(Object.keys(by).sort()).toEqual(['extra', 'pr-review', 'seo-audit']) | |
| 24 | + expect(by['pr-review']!.excluded).toBe(true) | |
| 25 | + expect(by['pr-review']!.origin).toBe('Excluded from base') | |
| 26 | + expect(by['seo-audit']!.onDemand).toEqual({ on: true, exact: false, pattern: 'seo-*' }) | |
| 27 | + expect(by.extra!.origin).toBe('Catalog') | |
| 28 | + expect(by.extra!.statuses[0]!.action).toBe('resolve') | |
| 29 | + expect(by.extra!.conflicts).toHaveLength(1) | |
| 30 | + }) | |
| 31 | + | |
| 32 | + it('adds a removal row for lock entries that are no longer enabled', () => { | |
| 33 | + const rows = buildRows({ config: config({ presets: [], add: {} }), presets, catalogItems: [], includeLocal: false, plan: plan([act('remove', 'old')]), inventory: null, statuses: {} }) | |
| 34 | + expect(rows).toHaveLength(1) | |
| 35 | + expect(rows[0]!.kind).toBe('removal') | |
| 36 | + }) | |
| 37 | + | |
| 38 | + it('merges equal statuses and keeps different ones apart', () => { | |
| 39 | + const a = { agent: 'claude', tone: 'ok', label: 'Up to date', short: '' } as const | |
| 40 | + expect(mergeStatuses([a, { ...a, agent: 'codex' }])).not.toBeNull() | |
| 41 | + expect(mergeStatuses([a, { ...a, agent: 'codex', tone: 'accent', label: 'Drift' }])).toBeNull() | |
| 42 | + }) | |
| 43 | + | |
| 44 | + it('matches names exactly or by glob', () => { | |
| 45 | + expect(matchIn(['a', 'x-*'], 'x-1')).toEqual({ exact: false, pattern: 'x-*' }) | |
| 46 | + expect(matchIn(['a'], 'b')).toBeNull() | |
| 47 | + }) | |
| 48 | +}) | |
| 49 | + | |
| 50 | +describe('inventory view helpers', () => { | |
| 51 | + const item = { id: 'i', type: 'skill', name: 'seo', agent: 'claude', source: { path: '/h/.claude/skills/seo', kind: 'dir' }, details: {}, warnings: [] } as unknown as InventoryItem | |
| 52 | + it('flags items used by some but not all projects', () => { | |
| 53 | + const projects = [{ id: 'a', name: 'A' }, { id: 'b', name: 'B' }] | |
| 54 | + expect(projectSpecific(item, undefined, { 'skill/seo': ['a'] }, projects)?.hint).toBe('used in A') | |
| 55 | + expect(projectSpecific(item, undefined, { 'skill/seo': ['a', 'b'] }, projects)).toBeNull() | |
| 56 | + expect(projectSpecific(item, undefined, {}, projects)).toBeNull() | |
| 57 | + }) | |
| 58 | + it('groups Home items by directory', () => { | |
| 59 | + expect(homeDirOf(item, '/h')).toBe('~/.claude') | |
| 60 | + }) | |
| 61 | + it('splits the managed AGENTS.md block', () => { | |
| 62 | + const segs = splitManagedBlock('a\n<!-- skilly:begin (x) -->\nr\n<!-- skilly:end -->\nb') | |
| 63 | + expect(segs.map(s => s.managed)).toEqual([false, true, false]) | |
| 64 | + }) | |
| 65 | +}) | |
| 66 | + | |
| 67 | +describe('plan view helpers', () => { | |
| 68 | + it('summarizes drift and updates by distinct item', () => { | |
| 69 | + const s = summarizePlan(plan([act('conflict', 'a', { conflict: 'modified-locally' }), act('update', 'b'), act('update', 'b', { agent: 'codex' })])) | |
| 70 | + expect(s).toEqual({ tone: 'accent', label: '1 drift · 1 update' }) | |
| 71 | + expect(summarizePlan(null).label).toBe('Up to date') | |
| 72 | + }) | |
| 73 | + it('describes where a scope writes', () => { | |
| 74 | + expect(writtenToHint(['claude', 'codex'], false)).toBe('Written to .claude/, .agents/ and .codex/ · committed with the project') | |
| 75 | + }) | |
| 76 | +}) | |
| new file mode 100644 | |||
| @@ -0,0 +1,76 @@ | |||
| 1 | +import { describe, expect, it } from 'vitest' | ||
| 2 | +import type { InventoryItem, ScopeConfig, SyncPlan } from '@skilly/core' | ||
| 3 | +import { buildRows, matchIn, mergeStatuses } from '../app/utils/scopeRows' | ||
| 4 | +import { projectSpecific, splitManagedBlock, homeDirOf } from '../app/utils/inventoryView' | ||
| 5 | +import { summarizePlan, writtenToHint } from '../app/utils/planView' | ||
| 6 | + | ||
| 7 | +const config = (over: Partial<ScopeConfig> = {}): ScopeConfig => ({ | ||
| 8 | + version: 1, targets: ['claude'], presets: ['base'], add: { skill: ['extra'] }, exclude: {}, onDemand: { skills: ['seo-*'] }, | ||
| 9 | + hideGlobal: { skills: [], mcp: [] }, ...over | ||
| 10 | +}) | ||
| 11 | +const presets = [{ name: 'base', includes: [], items: { skill: ['pr-review', 'seo-audit'] } }] | ||
| 12 | +const plan = (actions: SyncPlan['actions']): SyncPlan => ({ scope: 'project', root: '/p', actions, warnings: [], missing: [] }) | ||
| 13 | +const act = (kind: any, name: string, extra: object = {}) => | ||
| 14 | + ({ id: `${kind}:claude:skill/${name}`, kind, type: 'skill', name, agent: 'claude', files: [], configKeys: [], reason: '', ...extra }) as SyncPlan['actions'][number] | ||
| 15 | + | ||
| 16 | +describe('buildRows', () => { | ||
| 17 | + it('lists preset and added items with origin, exclusion and on-demand glob', () => { | ||
| 18 | + const rows = buildRows({ | ||
| 19 | + config: config({ exclude: { skill: ['pr-review'] } }), presets, catalogItems: [], includeLocal: false, | ||
| 20 | + plan: plan([act('unchanged', 'seo-audit'), act('conflict', 'extra', { conflict: 'modified-locally' })]), inventory: null, statuses: {} | ||
| 21 | + }) | ||
| 22 | + const by = Object.fromEntries(rows.map(r => [r.name, r])) | ||
| 23 | + expect(Object.keys(by).sort()).toEqual(['extra', 'pr-review', 'seo-audit']) | ||
| 24 | + expect(by['pr-review']!.excluded).toBe(true) | ||
| 25 | + expect(by['pr-review']!.origin).toBe('Excluded from base') | ||
| 26 | + expect(by['seo-audit']!.onDemand).toEqual({ on: true, exact: false, pattern: 'seo-*' }) | ||
| 27 | + expect(by.extra!.origin).toBe('Catalog') | ||
| 28 | + expect(by.extra!.statuses[0]!.action).toBe('resolve') | ||
| 29 | + expect(by.extra!.conflicts).toHaveLength(1) | ||
| 30 | + }) | ||
| 31 | + | ||
| 32 | + it('adds a removal row for lock entries that are no longer enabled', () => { | ||
| 33 | + const rows = buildRows({ config: config({ presets: [], add: {} }), presets, catalogItems: [], includeLocal: false, plan: plan([act('remove', 'old')]), inventory: null, statuses: {} }) | ||
| 34 | + expect(rows).toHaveLength(1) | ||
| 35 | + expect(rows[0]!.kind).toBe('removal') | ||
| 36 | + }) | ||
| 37 | + | ||
| 38 | + it('merges equal statuses and keeps different ones apart', () => { | ||
| 39 | + const a = { agent: 'claude', tone: 'ok', label: 'Up to date', short: '' } as const | ||
| 40 | + expect(mergeStatuses([a, { ...a, agent: 'codex' }])).not.toBeNull() | ||
| 41 | + expect(mergeStatuses([a, { ...a, agent: 'codex', tone: 'accent', label: 'Drift' }])).toBeNull() | ||
| 42 | + }) | ||
| 43 | + | ||
| 44 | + it('matches names exactly or by glob', () => { | ||
| 45 | + expect(matchIn(['a', 'x-*'], 'x-1')).toEqual({ exact: false, pattern: 'x-*' }) | ||
| 46 | + expect(matchIn(['a'], 'b')).toBeNull() | ||
| 47 | + }) | ||
| 48 | +}) | ||
| 49 | + | ||
| 50 | +describe('inventory view helpers', () => { | ||
| 51 | + const item = { id: 'i', type: 'skill', name: 'seo', agent: 'claude', source: { path: '/h/.claude/skills/seo', kind: 'dir' }, details: {}, warnings: [] } as unknown as InventoryItem | ||
| 52 | + it('flags items used by some but not all projects', () => { | ||
| 53 | + const projects = [{ id: 'a', name: 'A' }, { id: 'b', name: 'B' }] | ||
| 54 | + expect(projectSpecific(item, undefined, { 'skill/seo': ['a'] }, projects)?.hint).toBe('used in A') | ||
| 55 | + expect(projectSpecific(item, undefined, { 'skill/seo': ['a', 'b'] }, projects)).toBeNull() | ||
| 56 | + expect(projectSpecific(item, undefined, {}, projects)).toBeNull() | ||
| 57 | + }) | ||
| 58 | + it('groups Home items by directory', () => { | ||
| 59 | + expect(homeDirOf(item, '/h')).toBe('~/.claude') | ||
| 60 | + }) | ||
| 61 | + it('splits the managed AGENTS.md block', () => { | ||
| 62 | + const segs = splitManagedBlock('a\n<!-- skilly:begin (x) -->\nr\n<!-- skilly:end -->\nb') | ||
| 63 | + expect(segs.map(s => s.managed)).toEqual([false, true, false]) | ||
| 64 | + }) | ||
| 65 | +}) | ||
| 66 | + | ||
| 67 | +describe('plan view helpers', () => { | ||
| 68 | + it('summarizes drift and updates by distinct item', () => { | ||
| 69 | + const s = summarizePlan(plan([act('conflict', 'a', { conflict: 'modified-locally' }), act('update', 'b'), act('update', 'b', { agent: 'codex' })])) | ||
| 70 | + expect(s).toEqual({ tone: 'accent', label: '1 drift · 1 update' }) | ||
| 71 | + expect(summarizePlan(null).label).toBe('Up to date') | ||
| 72 | + }) | ||
| 73 | + it('describes where a scope writes', () => { | ||
| 74 | + expect(writtenToHint(['claude', 'codex'], false)).toBe('Written to .claude/, .agents/ and .codex/ · committed with the project') | ||
| 75 | + }) | ||
| 76 | +}) | ||