fix(ui): visual QA fixes (icons, folder picker, missing project, layout, readability)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
8804311 parent: 8e6675a modified
.gitignore +1 -0 | @@ -5,3 +5,4 @@ dist/ | ||
| 5 | 5 | .data/ |
| 6 | 6 | *.log |
| 7 | 7 | .idea/ |
| 8 | +.qa/ | |
| @@ -5,3 +5,4 @@ dist/ | |||
| 5 | .data/ | 5 | .data/ |
| 6 | *.log | 6 | *.log |
| 7 | .idea/ | 7 | .idea/ |
| 8 | +.qa/ | ||
modified
packages/server/src/index.ts +10 -1 | @@ -90,6 +90,14 @@ function tokenMatches(given: string | undefined, expected: string): boolean { | ||
| 90 | 90 | return a.length === b.length && timingSafeEqual(a, b) |
| 91 | 91 | } |
| 92 | 92 | |
| 93 | +async function pathKind(p: string): Promise<'dir' | 'file' | 'missing'> { | |
| 94 | + try { | |
| 95 | + return (await stat(p)).isDirectory() ? 'dir' : 'file' | |
| 96 | + } catch { | |
| 97 | + return 'missing' | |
| 98 | + } | |
| 99 | +} | |
| 100 | + | |
| 93 | 101 | async function isDir(p: string): Promise<boolean> { |
| 94 | 102 | try { |
| 95 | 103 | return (await stat(p)).isDirectory() |
| @@ -179,7 +187,8 @@ export function createApp(options: AppOptions): Hono { | ||
| 179 | 187 | if (!body || typeof body.path !== 'string' || !body.path.trim() || !path.isAbsolute(body.path)) { |
| 180 | 188 | return c.json(err('An absolute "path" is required'), 400) |
| 181 | 189 | } |
| 182 | - if (!(await isDir(body.path))) return c.json(err('Not a directory'), 400) | |
| 190 | + const kind = await pathKind(body.path) | |
| 191 | + if (kind !== 'dir') return c.json(err(kind === 'missing' ? 'Folder not found' : 'This path is a file, not a folder'), 400) | |
| 183 | 192 | const project = await store.add(body.path) |
| 184 | 193 | if (!project) return c.json(err('Project already added'), 409) |
| 185 | 194 | return c.json(await summarize(project, false), 201) |
| @@ -90,6 +90,14 @@ function tokenMatches(given: string | undefined, expected: string): boolean { | |||
| 90 | return a.length === b.length && timingSafeEqual(a, b) | 90 | return a.length === b.length && timingSafeEqual(a, b) |
| 91 | } | 91 | } |
| 92 | 92 | ||
| 93 | +async function pathKind(p: string): Promise<'dir' | 'file' | 'missing'> { | ||
| 94 | + try { | ||
| 95 | + return (await stat(p)).isDirectory() ? 'dir' : 'file' | ||
| 96 | + } catch { | ||
| 97 | + return 'missing' | ||
| 98 | + } | ||
| 99 | +} | ||
| 100 | + | ||
| 93 | async function isDir(p: string): Promise<boolean> { | 101 | async function isDir(p: string): Promise<boolean> { |
| 94 | try { | 102 | try { |
| 95 | return (await stat(p)).isDirectory() | 103 | return (await stat(p)).isDirectory() |
| @@ -179,7 +187,8 @@ export function createApp(options: AppOptions): Hono { | |||
| 179 | if (!body || typeof body.path !== 'string' || !body.path.trim() || !path.isAbsolute(body.path)) { | 187 | if (!body || typeof body.path !== 'string' || !body.path.trim() || !path.isAbsolute(body.path)) { |
| 180 | return c.json(err('An absolute "path" is required'), 400) | 188 | return c.json(err('An absolute "path" is required'), 400) |
| 181 | } | 189 | } |
| 182 | - if (!(await isDir(body.path))) return c.json(err('Not a directory'), 400) | 190 | + const kind = await pathKind(body.path) |
| 191 | + if (kind !== 'dir') return c.json(err(kind === 'missing' ? 'Folder not found' : 'This path is a file, not a folder'), 400) | ||
| 183 | const project = await store.add(body.path) | 192 | const project = await store.add(body.path) |
| 184 | if (!project) return c.json(err('Project already added'), 409) | 193 | if (!project) return c.json(err('Project already added'), 409) |
| 185 | return c.json(await summarize(project, false), 201) | 194 | return c.json(await summarize(project, false), 201) |
modified
packages/ui/app/assets/css/main.css +3 -2 | @@ -13,6 +13,7 @@ | ||
| 13 | 13 | .md-body pre { background: var(--ui-bg-elevated); padding: 0.75rem; border-radius: 0.5rem; overflow-x: auto; margin: 0.5rem 0; } |
| 14 | 14 | .md-body pre code { background: transparent; padding: 0; } |
| 15 | 15 | .md-body blockquote { border-left: 3px solid var(--ui-border-accented); padding-left: 0.75rem; color: var(--ui-text-muted); margin: 0.5rem 0; } |
| 16 | -.md-body table { border-collapse: collapse; margin: 0.5rem 0; } | |
| 17 | -.md-body th, .md-body td { border: 1px solid var(--ui-border); padding: 0.25rem 0.5rem; } | |
| 16 | +.md-body table { display: block; max-width: 100%; overflow-x: auto; border-collapse: collapse; margin: 0.5rem 0; } | |
| 17 | +.md-body th, .md-body td { border: 1px solid var(--ui-border); padding: 0.25rem 0.5rem; text-align: left; vertical-align: top; } | |
| 18 | +.md-body th { background: var(--ui-bg-elevated); font-weight: 600; } | |
| 18 | 19 | .md-body hr { border-color: var(--ui-border); margin: 1rem 0; } |
| @@ -13,6 +13,7 @@ | |||
| 13 | .md-body pre { background: var(--ui-bg-elevated); padding: 0.75rem; border-radius: 0.5rem; overflow-x: auto; margin: 0.5rem 0; } | 13 | .md-body pre { background: var(--ui-bg-elevated); padding: 0.75rem; border-radius: 0.5rem; overflow-x: auto; margin: 0.5rem 0; } |
| 14 | .md-body pre code { background: transparent; padding: 0; } | 14 | .md-body pre code { background: transparent; padding: 0; } |
| 15 | .md-body blockquote { border-left: 3px solid var(--ui-border-accented); padding-left: 0.75rem; color: var(--ui-text-muted); margin: 0.5rem 0; } | 15 | .md-body blockquote { border-left: 3px solid var(--ui-border-accented); padding-left: 0.75rem; color: var(--ui-text-muted); margin: 0.5rem 0; } |
| 16 | -.md-body table { border-collapse: collapse; margin: 0.5rem 0; } | 16 | +.md-body table { display: block; max-width: 100%; overflow-x: auto; border-collapse: collapse; margin: 0.5rem 0; } |
| 17 | -.md-body th, .md-body td { border: 1px solid var(--ui-border); padding: 0.25rem 0.5rem; } | 17 | +.md-body th, .md-body td { border: 1px solid var(--ui-border); padding: 0.25rem 0.5rem; text-align: left; vertical-align: top; } |
| 18 | +.md-body th { background: var(--ui-bg-elevated); font-weight: 600; } | ||
| 18 | .md-body hr { border-color: var(--ui-border); margin: 1rem 0; } | 19 | .md-body hr { border-color: var(--ui-border); margin: 1rem 0; } |
modified
packages/ui/app/components/AddProjectModal.vue +27 -21 | @@ -16,6 +16,9 @@ const submitting = ref(false) | ||
| 16 | 16 | const submitError = ref<string | null>(null) |
| 17 | 17 | const highlighted = ref<string | null>(null) |
| 18 | 18 | |
| 19 | +// The highlighted entry wins over the folder being browsed, so "Select" adds what the user clicked. | |
| 20 | +const selectedPath = computed(() => highlighted.value ?? listing.value?.path ?? null) | |
| 21 | + | |
| 19 | 22 | const crumbs = computed(() => (listing.value?.path ? pathSegments(listing.value.path) : [])) |
| 20 | 23 | |
| 21 | 24 | async function browse(path?: string) { |
| @@ -43,11 +46,7 @@ async function submit(path: string) { | ||
| 43 | 46 | await router.push(`/projects/${encodeURIComponent(created.id)}`) |
| 44 | 47 | } catch (e) { |
| 45 | 48 | const err = e as { status?: number, message: string } |
| 46 | - submitError.value = err.status === 409 | |
| 47 | - ? 'This project was already added.' | |
| 48 | - : err.status === 400 | |
| 49 | - ? `Not a valid folder: ${err.message}` | |
| 50 | - : err.message | |
| 49 | + submitError.value = err.status === 409 ? 'This project was already added.' : err.message | |
| 51 | 50 | } finally { |
| 52 | 51 | submitting.value = false |
| 53 | 52 | } |
| @@ -114,26 +113,32 @@ watch(open, (v) => { | ||
| 114 | 113 | <span class="text-muted">..</span> |
| 115 | 114 | </button> |
| 116 | 115 | </li> |
| 117 | - <li v-for="e in listing.entries" :key="e.path"> | |
| 116 | + <li | |
| 117 | + v-for="e in listing.entries" | |
| 118 | + :key="e.path" | |
| 119 | + class="flex items-center hover:bg-elevated/60" | |
| 120 | + :class="highlighted === e.path ? 'bg-primary/10' : ''" | |
| 121 | + > | |
| 118 | 122 | <button |
| 119 | 123 | type="button" |
| 120 | - class="flex w-full items-center gap-2 px-3 py-2 text-left hover:bg-elevated/60" | |
| 121 | - :class="highlighted === e.path ? 'bg-elevated' : ''" | |
| 124 | + class="flex min-w-0 flex-1 items-center gap-2 py-2 pl-3 text-left" | |
| 125 | + :title="e.path" | |
| 122 | 126 | @click="highlight(e.path)" |
| 123 | 127 | @dblclick="browse(e.path)" |
| 124 | 128 | > |
| 125 | - <UIcon name="i-lucide-folder" class="size-4 shrink-0 text-muted" /> | |
| 129 | + <UIcon :name="listing.path ? 'i-lucide-folder' : /^[a-zA-Z]:[\\/]?$/.test(e.name) ? 'i-lucide-hard-drive' : 'i-lucide-house'" class="size-4 shrink-0 text-muted" /> | |
| 126 | 130 | <span class="flex-1 truncate">{{ e.name }}</span> |
| 127 | - <UBadge v-if="e.hasAgentConfig" label="agent config" color="primary" variant="subtle" size="sm" icon="i-lucide-sparkles" /> | |
| 128 | - <UButton | |
| 129 | - icon="i-lucide-chevron-right" | |
| 130 | - size="xs" | |
| 131 | - color="neutral" | |
| 132 | - variant="ghost" | |
| 133 | - aria-label="Open folder" | |
| 134 | - @click.stop="browse(e.path)" | |
| 135 | - /> | |
| 131 | + <UBadge v-if="e.hasAgentConfig" label="agent config" color="primary" variant="subtle" size="sm" icon="i-lucide-sparkles" class="shrink-0" /> | |
| 136 | 132 | </button> |
| 133 | + <UButton | |
| 134 | + icon="i-lucide-chevron-right" | |
| 135 | + size="xs" | |
| 136 | + color="neutral" | |
| 137 | + variant="ghost" | |
| 138 | + class="mx-2 shrink-0" | |
| 139 | + :aria-label="`Open ${e.name}`" | |
| 140 | + @click="browse(e.path)" | |
| 141 | + /> | |
| 137 | 142 | </li> |
| 138 | 143 | <li v-if="!listing.entries.length" class="px-3 py-8 text-center text-muted">No subfolder here.</li> |
| 139 | 144 | </ul> |
| @@ -143,15 +148,16 @@ watch(open, (v) => { | ||
| 143 | 148 | |
| 144 | 149 | <template #footer> |
| 145 | 150 | <div class="flex w-full items-center justify-between gap-2"> |
| 146 | - <span class="truncate font-mono text-xs text-muted">{{ listing?.path ?? 'Select a drive or folder' }}</span> | |
| 151 | + <span v-if="selectedPath" class="truncate font-mono text-xs text-muted" :title="selectedPath">{{ selectedPath }}</span> | |
| 152 | + <span v-else class="truncate text-xs text-muted">Select a drive or folder</span> | |
| 147 | 153 | <div class="flex shrink-0 gap-2"> |
| 148 | 154 | <UButton label="Cancel" color="neutral" variant="outline" @click="open = false" /> |
| 149 | 155 | <UButton |
| 150 | 156 | label="Select this folder" |
| 151 | 157 | icon="i-lucide-check" |
| 152 | - :disabled="!listing?.path" | |
| 158 | + :disabled="!selectedPath" | |
| 153 | 159 | :loading="submitting" |
| 154 | - @click="submit(listing!.path!)" | |
| 160 | + @click="submit(selectedPath!)" | |
| 155 | 161 | /> |
| 156 | 162 | </div> |
| 157 | 163 | </div> |
| @@ -16,6 +16,9 @@ const submitting = ref(false) | |||
| 16 | const submitError = ref<string | null>(null) | 16 | const submitError = ref<string | null>(null) |
| 17 | const highlighted = ref<string | null>(null) | 17 | const highlighted = ref<string | null>(null) |
| 18 | 18 | ||
| 19 | +// The highlighted entry wins over the folder being browsed, so "Select" adds what the user clicked. | ||
| 20 | +const selectedPath = computed(() => highlighted.value ?? listing.value?.path ?? null) | ||
| 21 | + | ||
| 19 | const crumbs = computed(() => (listing.value?.path ? pathSegments(listing.value.path) : [])) | 22 | const crumbs = computed(() => (listing.value?.path ? pathSegments(listing.value.path) : [])) |
| 20 | 23 | ||
| 21 | async function browse(path?: string) { | 24 | async function browse(path?: string) { |
| @@ -43,11 +46,7 @@ async function submit(path: string) { | |||
| 43 | await router.push(`/projects/${encodeURIComponent(created.id)}`) | 46 | await router.push(`/projects/${encodeURIComponent(created.id)}`) |
| 44 | } catch (e) { | 47 | } catch (e) { |
| 45 | const err = e as { status?: number, message: string } | 48 | const err = e as { status?: number, message: string } |
| 46 | - submitError.value = err.status === 409 | 49 | + submitError.value = err.status === 409 ? 'This project was already added.' : err.message |
| 47 | - ? 'This project was already added.' | ||
| 48 | - : err.status === 400 | ||
| 49 | - ? `Not a valid folder: ${err.message}` | ||
| 50 | - : err.message | ||
| 51 | } finally { | 50 | } finally { |
| 52 | submitting.value = false | 51 | submitting.value = false |
| 53 | } | 52 | } |
| @@ -114,26 +113,32 @@ watch(open, (v) => { | |||
| 114 | <span class="text-muted">..</span> | 113 | <span class="text-muted">..</span> |
| 115 | </button> | 114 | </button> |
| 116 | </li> | 115 | </li> |
| 117 | - <li v-for="e in listing.entries" :key="e.path"> | 116 | + <li |
| 117 | + v-for="e in listing.entries" | ||
| 118 | + :key="e.path" | ||
| 119 | + class="flex items-center hover:bg-elevated/60" | ||
| 120 | + :class="highlighted === e.path ? 'bg-primary/10' : ''" | ||
| 121 | + > | ||
| 118 | <button | 122 | <button |
| 119 | type="button" | 123 | type="button" |
| 120 | - class="flex w-full items-center gap-2 px-3 py-2 text-left hover:bg-elevated/60" | 124 | + class="flex min-w-0 flex-1 items-center gap-2 py-2 pl-3 text-left" |
| 121 | - :class="highlighted === e.path ? 'bg-elevated' : ''" | 125 | + :title="e.path" |
| 122 | @click="highlight(e.path)" | 126 | @click="highlight(e.path)" |
| 123 | @dblclick="browse(e.path)" | 127 | @dblclick="browse(e.path)" |
| 124 | > | 128 | > |
| 125 | - <UIcon name="i-lucide-folder" class="size-4 shrink-0 text-muted" /> | 129 | + <UIcon :name="listing.path ? 'i-lucide-folder' : /^[a-zA-Z]:[\\/]?$/.test(e.name) ? 'i-lucide-hard-drive' : 'i-lucide-house'" class="size-4 shrink-0 text-muted" /> |
| 126 | <span class="flex-1 truncate">{{ e.name }}</span> | 130 | <span class="flex-1 truncate">{{ e.name }}</span> |
| 127 | - <UBadge v-if="e.hasAgentConfig" label="agent config" color="primary" variant="subtle" size="sm" icon="i-lucide-sparkles" /> | 131 | + <UBadge v-if="e.hasAgentConfig" label="agent config" color="primary" variant="subtle" size="sm" icon="i-lucide-sparkles" class="shrink-0" /> |
| 128 | - <UButton | ||
| 129 | - icon="i-lucide-chevron-right" | ||
| 130 | - size="xs" | ||
| 131 | - color="neutral" | ||
| 132 | - variant="ghost" | ||
| 133 | - aria-label="Open folder" | ||
| 134 | - @click.stop="browse(e.path)" | ||
| 135 | - /> | ||
| 136 | </button> | 132 | </button> |
| 133 | + <UButton | ||
| 134 | + icon="i-lucide-chevron-right" | ||
| 135 | + size="xs" | ||
| 136 | + color="neutral" | ||
| 137 | + variant="ghost" | ||
| 138 | + class="mx-2 shrink-0" | ||
| 139 | + :aria-label="`Open ${e.name}`" | ||
| 140 | + @click="browse(e.path)" | ||
| 141 | + /> | ||
| 137 | </li> | 142 | </li> |
| 138 | <li v-if="!listing.entries.length" class="px-3 py-8 text-center text-muted">No subfolder here.</li> | 143 | <li v-if="!listing.entries.length" class="px-3 py-8 text-center text-muted">No subfolder here.</li> |
| 139 | </ul> | 144 | </ul> |
| @@ -143,15 +148,16 @@ watch(open, (v) => { | |||
| 143 | 148 | ||
| 144 | <template #footer> | 149 | <template #footer> |
| 145 | <div class="flex w-full items-center justify-between gap-2"> | 150 | <div class="flex w-full items-center justify-between gap-2"> |
| 146 | - <span class="truncate font-mono text-xs text-muted">{{ listing?.path ?? 'Select a drive or folder' }}</span> | 151 | + <span v-if="selectedPath" class="truncate font-mono text-xs text-muted" :title="selectedPath">{{ selectedPath }}</span> |
| 152 | + <span v-else class="truncate text-xs text-muted">Select a drive or folder</span> | ||
| 147 | <div class="flex shrink-0 gap-2"> | 153 | <div class="flex shrink-0 gap-2"> |
| 148 | <UButton label="Cancel" color="neutral" variant="outline" @click="open = false" /> | 154 | <UButton label="Cancel" color="neutral" variant="outline" @click="open = false" /> |
| 149 | <UButton | 155 | <UButton |
| 150 | label="Select this folder" | 156 | label="Select this folder" |
| 151 | icon="i-lucide-check" | 157 | icon="i-lucide-check" |
| 152 | - :disabled="!listing?.path" | 158 | + :disabled="!selectedPath" |
| 153 | :loading="submitting" | 159 | :loading="submitting" |
| 154 | - @click="submit(listing!.path!)" | 160 | + @click="submit(selectedPath!)" |
| 155 | /> | 161 | /> |
| 156 | </div> | 162 | </div> |
| 157 | </div> | 163 | </div> |
modified
packages/ui/app/components/AppSidebar.vue +1 -1 | @@ -27,7 +27,7 @@ const soon = [ | ||
| 27 | 27 | |
| 28 | 28 | <div class="mt-4 flex items-center justify-between px-2"> |
| 29 | 29 | <span class="text-xs font-semibold uppercase tracking-wide text-muted">Projects</span> |
| 30 | - <UTooltip text="Add project"> | |
| 30 | + <UTooltip text="Add project" :content="{ side: 'right' }"> | |
| 31 | 31 | <UButton icon="i-lucide-plus" size="xs" color="neutral" variant="ghost" aria-label="Add project" @click="$emit('add')" /> |
| 32 | 32 | </UTooltip> |
| 33 | 33 | </div> |
| @@ -27,7 +27,7 @@ const soon = [ | |||
| 27 | 27 | ||
| 28 | <div class="mt-4 flex items-center justify-between px-2"> | 28 | <div class="mt-4 flex items-center justify-between px-2"> |
| 29 | <span class="text-xs font-semibold uppercase tracking-wide text-muted">Projects</span> | 29 | <span class="text-xs font-semibold uppercase tracking-wide text-muted">Projects</span> |
| 30 | - <UTooltip text="Add project"> | 30 | + <UTooltip text="Add project" :content="{ side: 'right' }"> |
| 31 | <UButton icon="i-lucide-plus" size="xs" color="neutral" variant="ghost" aria-label="Add project" @click="$emit('add')" /> | 31 | <UButton icon="i-lucide-plus" size="xs" color="neutral" variant="ghost" aria-label="Add project" @click="$emit('add')" /> |
| 32 | </UTooltip> | 32 | </UTooltip> |
| 33 | </div> | 33 | </div> |
modified
packages/ui/app/components/ItemDetail.vue +9 -3 | @@ -12,12 +12,18 @@ const filePath = computed<string | null>(() => { | ||
| 12 | 12 | return null |
| 13 | 13 | }) |
| 14 | 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 | + | |
| 15 | 18 | /** Skill file currently displayed (defaults to SKILL.md). */ |
| 16 | 19 | const currentFile = ref<string | null>(null) |
| 17 | 20 | watch(() => props.item?.id, () => { currentFile.value = null }) |
| 18 | 21 | const shownPath = computed(() => currentFile.value ?? filePath.value) |
| 19 | 22 | |
| 20 | -const skillFiles = computed(() => (props.item?.type === 'skill' ? (props.item.details as SkillDetails).files : [])) | |
| 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 | + : [])) | |
| 21 | 27 | |
| 22 | 28 | const frontmatter = computed(() => { |
| 23 | 29 | const d = props.item?.details as { frontmatter?: Record<string, unknown> } | undefined |
| @@ -68,7 +74,7 @@ const rows = computed(() => { | ||
| 68 | 74 | </script> |
| 69 | 75 | |
| 70 | 76 | <template> |
| 71 | - <USlideover v-model:open="open" :title="item?.name" :description="item?.description" :ui="{ content: 'max-w-2xl' }"> | |
| 77 | + <USlideover v-model:open="open" :title="item?.name" :description="item?.description" :ui="{ content: 'sm:max-w-2xl', description: 'line-clamp-3' }"> | |
| 72 | 78 | <template #body> |
| 73 | 79 | <div v-if="item" class="space-y-5"> |
| 74 | 80 | <UAlert |
| @@ -90,7 +96,7 @@ const rows = computed(() => { | ||
| 90 | 96 | <tbody class="divide-y divide-default"> |
| 91 | 97 | <tr v-for="[k, v] in rows" :key="k"> |
| 92 | 98 | <th class="w-36 bg-elevated/40 px-3 py-2 text-left align-top text-xs font-medium text-muted">{{ k }}</th> |
| 93 | - <td class="px-3 py-2 font-mono text-xs break-all">{{ v }}</td> | |
| 99 | + <td class="px-3 py-2 text-xs" :class="MONO_ROWS.has(k) ? 'font-mono break-all' : 'break-words'">{{ v }}</td> | |
| 94 | 100 | </tr> |
| 95 | 101 | </tbody> |
| 96 | 102 | </table> |
| @@ -12,12 +12,18 @@ const filePath = computed<string | null>(() => { | |||
| 12 | return null | 12 | return null |
| 13 | }) | 13 | }) |
| 14 | 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 | + | ||
| 15 | /** Skill file currently displayed (defaults to SKILL.md). */ | 18 | /** Skill file currently displayed (defaults to SKILL.md). */ |
| 16 | const currentFile = ref<string | null>(null) | 19 | const currentFile = ref<string | null>(null) |
| 17 | watch(() => props.item?.id, () => { currentFile.value = null }) | 20 | watch(() => props.item?.id, () => { currentFile.value = null }) |
| 18 | const shownPath = computed(() => currentFile.value ?? filePath.value) | 21 | const shownPath = computed(() => currentFile.value ?? filePath.value) |
| 19 | 22 | ||
| 20 | -const skillFiles = computed(() => (props.item?.type === 'skill' ? (props.item.details as SkillDetails).files : [])) | 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 | + : [])) | ||
| 21 | 27 | ||
| 22 | const frontmatter = computed(() => { | 28 | const frontmatter = computed(() => { |
| 23 | const d = props.item?.details as { frontmatter?: Record<string, unknown> } | undefined | 29 | const d = props.item?.details as { frontmatter?: Record<string, unknown> } | undefined |
| @@ -68,7 +74,7 @@ const rows = computed(() => { | |||
| 68 | </script> | 74 | </script> |
| 69 | 75 | ||
| 70 | <template> | 76 | <template> |
| 71 | - <USlideover v-model:open="open" :title="item?.name" :description="item?.description" :ui="{ content: 'max-w-2xl' }"> | 77 | + <USlideover v-model:open="open" :title="item?.name" :description="item?.description" :ui="{ content: 'sm:max-w-2xl', description: 'line-clamp-3' }"> |
| 72 | <template #body> | 78 | <template #body> |
| 73 | <div v-if="item" class="space-y-5"> | 79 | <div v-if="item" class="space-y-5"> |
| 74 | <UAlert | 80 | <UAlert |
| @@ -90,7 +96,7 @@ const rows = computed(() => { | |||
| 90 | <tbody class="divide-y divide-default"> | 96 | <tbody class="divide-y divide-default"> |
| 91 | <tr v-for="[k, v] in rows" :key="k"> | 97 | <tr v-for="[k, v] in rows" :key="k"> |
| 92 | <th class="w-36 bg-elevated/40 px-3 py-2 text-left align-top text-xs font-medium text-muted">{{ k }}</th> | 98 | <th class="w-36 bg-elevated/40 px-3 py-2 text-left align-top text-xs font-medium text-muted">{{ k }}</th> |
| 93 | - <td class="px-3 py-2 font-mono text-xs break-all">{{ v }}</td> | 99 | + <td class="px-3 py-2 text-xs" :class="MONO_ROWS.has(k) ? 'font-mono break-all' : 'break-words'">{{ v }}</td> |
| 94 | </tr> | 100 | </tr> |
| 95 | </tbody> | 101 | </tbody> |
| 96 | </table> | 102 | </table> |
modified
packages/ui/app/components/ItemTable.vue +5 -4 | @@ -21,7 +21,7 @@ const VISIBILITY = { | ||
| 21 | 21 | } as const |
| 22 | 22 | |
| 23 | 23 | const TH = 'px-3 py-2 text-left text-xs font-medium uppercase tracking-wide text-muted' |
| 24 | -const TD = 'px-3 py-2.5 align-top' | |
| 24 | +const TD = 'px-3 py-2 align-middle' | |
| 25 | 25 | const MAX_ENV = 4 |
| 26 | 26 | </script> |
| 27 | 27 | |
| @@ -78,7 +78,7 @@ const MAX_ENV = 4 | ||
| 78 | 78 | <UIcon name="i-lucide-triangle-alert" class="size-4 shrink-0 text-warning" /> |
| 79 | 79 | </UTooltip> |
| 80 | 80 | </div> |
| 81 | - <p v-if="item.description && type !== 'hook'" class="line-clamp-2 text-xs text-muted">{{ item.description }}</p> | |
| 81 | + <p v-if="item.description && type !== 'hook'" class="line-clamp-1 text-xs text-muted" :title="item.description">{{ item.description }}</p> | |
| 82 | 82 | </div> |
| 83 | 83 | </div> |
| 84 | 84 | </td> |
| @@ -97,8 +97,9 @@ const MAX_ENV = 4 | ||
| 97 | 97 | <UTooltip v-if="item.source.symlinkTarget" :text="item.source.symlinkTarget"> |
| 98 | 98 | <UBadge label="symlink" icon="i-lucide-link" color="neutral" variant="subtle" size="sm" /> |
| 99 | 99 | </UTooltip> |
| 100 | - <span v-if="item.source.symlinkTarget" class="max-w-48 truncate font-mono text-xs text-muted" :title="item.source.symlinkTarget"> | |
| 101 | - {{ item.source.symlinkTarget }} | |
| 100 | + <!-- rtl keeps the end of the path (the meaningful part) visible when truncated --> | |
| 101 | + <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"> | |
| 102 | + <bdi>{{ item.source.symlinkTarget }}</bdi> | |
| 102 | 103 | </span> |
| 103 | 104 | <span v-if="!item.source.plugin && !item.source.symlinkTarget" class="text-xs text-dimmed">local</span> |
| 104 | 105 | </div> |
| @@ -21,7 +21,7 @@ const VISIBILITY = { | |||
| 21 | } as const | 21 | } as const |
| 22 | 22 | ||
| 23 | const TH = 'px-3 py-2 text-left text-xs font-medium uppercase tracking-wide text-muted' | 23 | const TH = 'px-3 py-2 text-left text-xs font-medium uppercase tracking-wide text-muted' |
| 24 | -const TD = 'px-3 py-2.5 align-top' | 24 | +const TD = 'px-3 py-2 align-middle' |
| 25 | const MAX_ENV = 4 | 25 | const MAX_ENV = 4 |
| 26 | </script> | 26 | </script> |
| 27 | 27 | ||
| @@ -78,7 +78,7 @@ const MAX_ENV = 4 | |||
| 78 | <UIcon name="i-lucide-triangle-alert" class="size-4 shrink-0 text-warning" /> | 78 | <UIcon name="i-lucide-triangle-alert" class="size-4 shrink-0 text-warning" /> |
| 79 | </UTooltip> | 79 | </UTooltip> |
| 80 | </div> | 80 | </div> |
| 81 | - <p v-if="item.description && type !== 'hook'" class="line-clamp-2 text-xs text-muted">{{ item.description }}</p> | 81 | + <p v-if="item.description && type !== 'hook'" class="line-clamp-1 text-xs text-muted" :title="item.description">{{ item.description }}</p> |
| 82 | </div> | 82 | </div> |
| 83 | </div> | 83 | </div> |
| 84 | </td> | 84 | </td> |
| @@ -97,8 +97,9 @@ const MAX_ENV = 4 | |||
| 97 | <UTooltip v-if="item.source.symlinkTarget" :text="item.source.symlinkTarget"> | 97 | <UTooltip v-if="item.source.symlinkTarget" :text="item.source.symlinkTarget"> |
| 98 | <UBadge label="symlink" icon="i-lucide-link" color="neutral" variant="subtle" size="sm" /> | 98 | <UBadge label="symlink" icon="i-lucide-link" color="neutral" variant="subtle" size="sm" /> |
| 99 | </UTooltip> | 99 | </UTooltip> |
| 100 | - <span v-if="item.source.symlinkTarget" class="max-w-48 truncate font-mono text-xs text-muted" :title="item.source.symlinkTarget"> | 100 | + <!-- rtl keeps the end of the path (the meaningful part) visible when truncated --> |
| 101 | - {{ item.source.symlinkTarget }} | 101 | + <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"> |
| 102 | + <bdi>{{ item.source.symlinkTarget }}</bdi> | ||
| 102 | </span> | 103 | </span> |
| 103 | <span v-if="!item.source.plugin && !item.source.symlinkTarget" class="text-xs text-dimmed">local</span> | 104 | <span v-if="!item.source.plugin && !item.source.symlinkTarget" class="text-xs text-dimmed">local</span> |
| 104 | </div> | 105 | </div> |
modified
packages/ui/app/components/MarkdownView.vue +3 -3 | @@ -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 class="truncate font-mono text-xs text-muted" :title="path">{{ path }}</span> | |
| 46 | + <span dir="rtl" class="truncate text-left font-mono text-xs text-muted" :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 | |
| @@ -63,14 +63,14 @@ watch(() => props.path, load, { immediate: true }) | ||
| 63 | 63 | :description="`Only the first part of this file is shown (${formatBytes(file.sizeBytes)} in total).`" |
| 64 | 64 | /> |
| 65 | 65 | |
| 66 | - <pre v-if="raw || !isMarkdown" class="max-h-[60vh] overflow-auto whitespace-pre-wrap rounded-lg bg-elevated p-3 font-mono text-xs">{{ file.content }}</pre> | |
| 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> | |
| 67 | 67 | <template v-else> |
| 68 | 68 | <div v-if="fmRows.length" class="overflow-hidden rounded-lg border border-default"> |
| 69 | 69 | <table class="w-full text-xs"> |
| 70 | 70 | <tbody class="divide-y divide-default"> |
| 71 | 71 | <tr v-for="[k, v] in fmRows" :key="k"> |
| 72 | 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 font-mono break-words">{{ v }}</td> | |
| 73 | + <td class="px-2 py-1.5 break-words">{{ v }}</td> | |
| 74 | 74 | </tr> |
| 75 | 75 | </tbody> |
| 76 | 76 | </table> |
| @@ -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 class="truncate font-mono text-xs text-muted" :title="path">{{ path }}</span> | 46 | + <span dir="rtl" class="truncate text-left font-mono text-xs text-muted" :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 | ||
| @@ -63,14 +63,14 @@ watch(() => props.path, load, { immediate: true }) | |||
| 63 | :description="`Only the first part of this file is shown (${formatBytes(file.sizeBytes)} in total).`" | 63 | :description="`Only the first part of this file is shown (${formatBytes(file.sizeBytes)} in total).`" |
| 64 | /> | 64 | /> |
| 65 | 65 | ||
| 66 | - <pre v-if="raw || !isMarkdown" class="max-h-[60vh] overflow-auto whitespace-pre-wrap rounded-lg bg-elevated p-3 font-mono text-xs">{{ file.content }}</pre> | 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> |
| 67 | <template v-else> | 67 | <template v-else> |
| 68 | <div v-if="fmRows.length" class="overflow-hidden rounded-lg border border-default"> | 68 | <div v-if="fmRows.length" class="overflow-hidden rounded-lg border border-default"> |
| 69 | <table class="w-full text-xs"> | 69 | <table class="w-full text-xs"> |
| 70 | <tbody class="divide-y divide-default"> | 70 | <tbody class="divide-y divide-default"> |
| 71 | <tr v-for="[k, v] in fmRows" :key="k"> | 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> | 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 font-mono break-words">{{ v }}</td> | 73 | + <td class="px-2 py-1.5 break-words">{{ v }}</td> |
| 74 | </tr> | 74 | </tr> |
| 75 | </tbody> | 75 | </tbody> |
| 76 | </table> | 76 | </table> |
modified
packages/ui/app/components/ScopeView.vue +26 -3 | @@ -4,7 +4,7 @@ import type { AgentId, InventoryItem, ItemType, ScopeInventory } from '@skilly/c | ||
| 4 | 4 | const props = defineProps<{ kind: 'home' | 'project', projectId?: string }>() |
| 5 | 5 | |
| 6 | 6 | const { request } = useApi() |
| 7 | -const { projects, remove } = useProjects() | |
| 7 | +const { projects, remove, refresh: refreshProjects } = useProjects() | |
| 8 | 8 | const toast = useToast() |
| 9 | 9 | const router = useRouter() |
| 10 | 10 | |
| @@ -34,6 +34,8 @@ async function load(showLoading = true) { | ||
| 34 | 34 | error.value = null |
| 35 | 35 | } catch (e) { |
| 36 | 36 | error.value = (e as Error).message |
| 37 | + // The folder may have disappeared: refresh the summaries so `exists` is up to date. | |
| 38 | + if (props.kind === 'project') await refreshProjects() | |
| 37 | 39 | } finally { |
| 38 | 40 | loading.value = false |
| 39 | 41 | } |
| @@ -67,6 +69,10 @@ const filtered = computed(() => { | ||
| 67 | 69 | const byType = computed(() => { |
| 68 | 70 | const m = Object.fromEntries(TYPE_ORDER.map(t => [t, [] as InventoryItem[]])) as Record<ItemType, InventoryItem[]> |
| 69 | 71 | for (const i of filtered.value) m[i.type].push(i) |
| 72 | + // Sort by name, then agent; hooks and instruction files keep their scan order (it is meaningful). | |
| 73 | + for (const t of ['skill', 'rule', 'mcp', 'agent'] as const) { | |
| 74 | + m[t].sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }) || a.agent.localeCompare(b.agent)) | |
| 75 | + } | |
| 70 | 76 | return m |
| 71 | 77 | }) |
| 72 | 78 | |
| @@ -116,7 +122,11 @@ async function confirmRemove() { | ||
| 116 | 122 | <template> |
| 117 | 123 | <UDashboardPanel :id="`scope-${kind}`"> |
| 118 | 124 | <template #header> |
| 119 | - <UDashboardNavbar :title="title" :icon="kind === 'home' ? 'i-lucide-house' : 'i-lucide-folder'"> | |
| 125 | + <UDashboardNavbar :icon="kind === 'home' ? 'i-lucide-house' : 'i-lucide-folder'"> | |
| 126 | + <template #title> | |
| 127 | + <!-- The default title is a flex box, which cannot show an ellipsis for long project names --> | |
| 128 | + <span class="truncate" :title="title">{{ title }}</span> | |
| 129 | + </template> | |
| 120 | 130 | <template #right> |
| 121 | 131 | <UButton |
| 122 | 132 | icon="i-lucide-refresh-cw" |
| @@ -149,6 +159,19 @@ async function confirmRemove() { | ||
| 149 | 159 | <!-- Error --> |
| 150 | 160 | <div v-else-if="error && !inventory" class="mx-auto max-w-lg py-12"> |
| 151 | 161 | <UAlert |
| 162 | + v-if="folderMissing" | |
| 163 | + color="warning" | |
| 164 | + variant="subtle" | |
| 165 | + icon="i-lucide-folder-x" | |
| 166 | + title="This folder no longer exists on disk" | |
| 167 | + :description="`${rootPath} was moved or deleted. You can remove the project from skilly, or retry once the folder is back.`" | |
| 168 | + :actions="[ | |
| 169 | + { label: 'Retry', color: 'neutral', variant: 'outline', onClick: () => load() }, | |
| 170 | + { label: 'Remove from skilly', color: 'error', variant: 'soft', onClick: () => { removeOpen = true } } | |
| 171 | + ]" | |
| 172 | + /> | |
| 173 | + <UAlert | |
| 174 | + v-else | |
| 152 | 175 | color="error" |
| 153 | 176 | variant="subtle" |
| 154 | 177 | icon="i-lucide-circle-alert" |
| @@ -205,7 +228,7 @@ async function confirmRemove() { | ||
| 205 | 228 | <ul class="mt-2 space-y-1.5 text-xs"> |
| 206 | 229 | <li v-for="(e, i) in inventory.errors" :key="i"> |
| 207 | 230 | <AgentBadge v-if="e.agent" :agent="e.agent" compact class="mr-1" /> |
| 208 | - <span class="font-mono">{{ e.path }}</span> | |
| 231 | + <span class="font-mono break-all" :title="e.path">{{ relativeTo(rootPath, e.path) }}</span> | |
| 209 | 232 | <span class="block text-muted">{{ e.message }}</span> |
| 210 | 233 | </li> |
| 211 | 234 | </ul> |
| @@ -4,7 +4,7 @@ import type { AgentId, InventoryItem, ItemType, ScopeInventory } from '@skilly/c | |||
| 4 | const props = defineProps<{ kind: 'home' | 'project', projectId?: string }>() | 4 | const props = defineProps<{ kind: 'home' | 'project', projectId?: string }>() |
| 5 | 5 | ||
| 6 | const { request } = useApi() | 6 | const { request } = useApi() |
| 7 | -const { projects, remove } = useProjects() | 7 | +const { projects, remove, refresh: refreshProjects } = useProjects() |
| 8 | const toast = useToast() | 8 | const toast = useToast() |
| 9 | const router = useRouter() | 9 | const router = useRouter() |
| 10 | 10 | ||
| @@ -34,6 +34,8 @@ async function load(showLoading = true) { | |||
| 34 | error.value = null | 34 | error.value = null |
| 35 | } catch (e) { | 35 | } catch (e) { |
| 36 | error.value = (e as Error).message | 36 | error.value = (e as Error).message |
| 37 | + // The folder may have disappeared: refresh the summaries so `exists` is up to date. | ||
| 38 | + if (props.kind === 'project') await refreshProjects() | ||
| 37 | } finally { | 39 | } finally { |
| 38 | loading.value = false | 40 | loading.value = false |
| 39 | } | 41 | } |
| @@ -67,6 +69,10 @@ const filtered = computed(() => { | |||
| 67 | const byType = computed(() => { | 69 | const byType = computed(() => { |
| 68 | const m = Object.fromEntries(TYPE_ORDER.map(t => [t, [] as InventoryItem[]])) as Record<ItemType, InventoryItem[]> | 70 | const m = Object.fromEntries(TYPE_ORDER.map(t => [t, [] as InventoryItem[]])) as Record<ItemType, InventoryItem[]> |
| 69 | for (const i of filtered.value) m[i.type].push(i) | 71 | for (const i of filtered.value) m[i.type].push(i) |
| 72 | + // Sort by name, then agent; hooks and instruction files keep their scan order (it is meaningful). | ||
| 73 | + for (const t of ['skill', 'rule', 'mcp', 'agent'] as const) { | ||
| 74 | + m[t].sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }) || a.agent.localeCompare(b.agent)) | ||
| 75 | + } | ||
| 70 | return m | 76 | return m |
| 71 | }) | 77 | }) |
| 72 | 78 | ||
| @@ -116,7 +122,11 @@ async function confirmRemove() { | |||
| 116 | <template> | 122 | <template> |
| 117 | <UDashboardPanel :id="`scope-${kind}`"> | 123 | <UDashboardPanel :id="`scope-${kind}`"> |
| 118 | <template #header> | 124 | <template #header> |
| 119 | - <UDashboardNavbar :title="title" :icon="kind === 'home' ? 'i-lucide-house' : 'i-lucide-folder'"> | 125 | + <UDashboardNavbar :icon="kind === 'home' ? 'i-lucide-house' : 'i-lucide-folder'"> |
| 126 | + <template #title> | ||
| 127 | + <!-- The default title is a flex box, which cannot show an ellipsis for long project names --> | ||
| 128 | + <span class="truncate" :title="title">{{ title }}</span> | ||
| 129 | + </template> | ||
| 120 | <template #right> | 130 | <template #right> |
| 121 | <UButton | 131 | <UButton |
| 122 | icon="i-lucide-refresh-cw" | 132 | icon="i-lucide-refresh-cw" |
| @@ -149,6 +159,19 @@ async function confirmRemove() { | |||
| 149 | <!-- Error --> | 159 | <!-- Error --> |
| 150 | <div v-else-if="error && !inventory" class="mx-auto max-w-lg py-12"> | 160 | <div v-else-if="error && !inventory" class="mx-auto max-w-lg py-12"> |
| 151 | <UAlert | 161 | <UAlert |
| 162 | + v-if="folderMissing" | ||
| 163 | + color="warning" | ||
| 164 | + variant="subtle" | ||
| 165 | + icon="i-lucide-folder-x" | ||
| 166 | + title="This folder no longer exists on disk" | ||
| 167 | + :description="`${rootPath} was moved or deleted. You can remove the project from skilly, or retry once the folder is back.`" | ||
| 168 | + :actions="[ | ||
| 169 | + { label: 'Retry', color: 'neutral', variant: 'outline', onClick: () => load() }, | ||
| 170 | + { label: 'Remove from skilly', color: 'error', variant: 'soft', onClick: () => { removeOpen = true } } | ||
| 171 | + ]" | ||
| 172 | + /> | ||
| 173 | + <UAlert | ||
| 174 | + v-else | ||
| 152 | color="error" | 175 | color="error" |
| 153 | variant="subtle" | 176 | variant="subtle" |
| 154 | icon="i-lucide-circle-alert" | 177 | icon="i-lucide-circle-alert" |
| @@ -205,7 +228,7 @@ async function confirmRemove() { | |||
| 205 | <ul class="mt-2 space-y-1.5 text-xs"> | 228 | <ul class="mt-2 space-y-1.5 text-xs"> |
| 206 | <li v-for="(e, i) in inventory.errors" :key="i"> | 229 | <li v-for="(e, i) in inventory.errors" :key="i"> |
| 207 | <AgentBadge v-if="e.agent" :agent="e.agent" compact class="mr-1" /> | 230 | <AgentBadge v-if="e.agent" :agent="e.agent" compact class="mr-1" /> |
| 208 | - <span class="font-mono">{{ e.path }}</span> | 231 | + <span class="font-mono break-all" :title="e.path">{{ relativeTo(rootPath, e.path) }}</span> |
| 209 | <span class="block text-muted">{{ e.message }}</span> | 232 | <span class="block text-muted">{{ e.message }}</span> |
| 210 | </li> | 233 | </li> |
| 211 | </ul> | 234 | </ul> |
modified
packages/ui/app/layouts/default.vue +3 -2 | @@ -6,8 +6,9 @@ onMounted(refresh) | ||
| 6 | 6 | </script> |
| 7 | 7 | |
| 8 | 8 | <template> |
| 9 | - <UDashboardGroup> | |
| 10 | - <UDashboardSidebar :ui="{ footer: 'border-t border-default' }"> | |
| 9 | + <UDashboardGroup unit="rem"> | |
| 10 | + <!-- Sizes in rem: a percentage sidebar gets too narrow for project names on small windows --> | |
| 11 | + <UDashboardSidebar :default-size="15" :min-size="13" :max-size="22" resizable :ui="{ footer: 'border-t border-default' }"> | |
| 11 | 12 | <template #header> |
| 12 | 13 | <NuxtLink to="/" class="flex items-center gap-2 px-1 py-1"> |
| 13 | 14 | <span class="grid size-8 place-items-center rounded-lg bg-primary text-inverted"> |
| @@ -6,8 +6,9 @@ onMounted(refresh) | |||
| 6 | </script> | 6 | </script> |
| 7 | 7 | ||
| 8 | <template> | 8 | <template> |
| 9 | - <UDashboardGroup> | 9 | + <UDashboardGroup unit="rem"> |
| 10 | - <UDashboardSidebar :ui="{ footer: 'border-t border-default' }"> | 10 | + <!-- Sizes in rem: a percentage sidebar gets too narrow for project names on small windows --> |
| 11 | + <UDashboardSidebar :default-size="15" :min-size="13" :max-size="22" resizable :ui="{ footer: 'border-t border-default' }"> | ||
| 11 | <template #header> | 12 | <template #header> |
| 12 | <NuxtLink to="/" class="flex items-center gap-2 px-1 py-1"> | 13 | <NuxtLink to="/" class="flex items-center gap-2 px-1 py-1"> |
| 13 | <span class="grid size-8 place-items-center rounded-lg bg-primary text-inverted"> | 14 | <span class="grid size-8 place-items-center rounded-lg bg-primary text-inverted"> |
modified
packages/ui/app/pages/index.vue +2 -0 | @@ -7,6 +7,8 @@ const addOpen = useState('skilly-add-open', () => false) | ||
| 7 | 7 | |
| 8 | 8 | onMounted(() => { |
| 9 | 9 | if (!home.value) refreshHome() |
| 10 | + // Projects may have changed on disk (e.g. a folder was deleted) since the layout loaded them. | |
| 11 | + if (loaded.value) refreshProjects() | |
| 10 | 12 | }) |
| 11 | 13 | |
| 12 | 14 | function countItems(inv: ScopeInventory): Record<ItemType, number> { |
| @@ -7,6 +7,8 @@ const addOpen = useState('skilly-add-open', () => false) | |||
| 7 | 7 | ||
| 8 | onMounted(() => { | 8 | onMounted(() => { |
| 9 | if (!home.value) refreshHome() | 9 | if (!home.value) refreshHome() |
| 10 | + // Projects may have changed on disk (e.g. a folder was deleted) since the layout loaded them. | ||
| 11 | + if (loaded.value) refreshProjects() | ||
| 10 | }) | 12 | }) |
| 11 | 13 | ||
| 12 | function countItems(inv: ScopeInventory): Record<ItemType, number> { | 14 | function countItems(inv: ScopeInventory): Record<ItemType, number> { |
modified
packages/ui/app/utils/format.ts +10 -0 | @@ -43,3 +43,13 @@ export function pathSegments(p: string): { name: string, path: string }[] { | ||
| 43 | 43 | }) |
| 44 | 44 | return out |
| 45 | 45 | } |
| 46 | + | |
| 47 | +/** Returns `p` relative to `root` when it lives inside it (keeps `p` unchanged otherwise). */ | |
| 48 | +export function relativeTo(root: string, p: string): string { | |
| 49 | + const norm = (s: string) => s.replace(/[\/]+$/, '') | |
| 50 | + const r = norm(root) | |
| 51 | + if (r && p.length > r.length && p.slice(0, r.length).toLowerCase() === r.toLowerCase() && /[\/]/.test(p[r.length]!)) { | |
| 52 | + return p.slice(r.length + 1) | |
| 53 | + } | |
| 54 | + return p | |
| 55 | +} | |
| @@ -43,3 +43,13 @@ export function pathSegments(p: string): { name: string, path: string }[] { | |||
| 43 | }) | 43 | }) |
| 44 | return out | 44 | return out |
| 45 | } | 45 | } |
| 46 | + | ||
| 47 | +/** Returns `p` relative to `root` when it lives inside it (keeps `p` unchanged otherwise). */ | ||
| 48 | +export function relativeTo(root: string, p: string): string { | ||
| 49 | + const norm = (s: string) => s.replace(/[\/]+$/, '') | ||
| 50 | + const r = norm(root) | ||
| 51 | + if (r && p.length > r.length && p.slice(0, r.length).toLowerCase() === r.toLowerCase() && /[\/]/.test(p[r.length]!)) { | ||
| 52 | + return p.slice(r.length + 1) | ||
| 53 | + } | ||
| 54 | + return p | ||
| 55 | +} | ||
modified
packages/ui/nuxt.config.ts +2 -1 | @@ -13,7 +13,8 @@ export default defineNuxtConfig({ | ||
| 13 | 13 | icon: { |
| 14 | 14 | // Icons come from the locally installed lucide collection (no network needed at runtime). |
| 15 | 15 | provider: 'none', |
| 16 | - clientBundle: { scan: true }, | |
| 16 | + // Scan .ts too: icon names also live in app/utils (e.g. TYPE_META), not only in templates. | |
| 17 | + clientBundle: { scan: { globInclude: ['**/*.{vue,ts}'] } }, | |
| 17 | 18 | serverBundle: 'local' |
| 18 | 19 | }, |
| 19 | 20 | nitro: { |
| @@ -13,7 +13,8 @@ export default defineNuxtConfig({ | |||
| 13 | icon: { | 13 | icon: { |
| 14 | // Icons come from the locally installed lucide collection (no network needed at runtime). | 14 | // Icons come from the locally installed lucide collection (no network needed at runtime). |
| 15 | provider: 'none', | 15 | provider: 'none', |
| 16 | - clientBundle: { scan: true }, | 16 | + // Scan .ts too: icon names also live in app/utils (e.g. TYPE_META), not only in templates. |
| 17 | + clientBundle: { scan: { globInclude: ['**/*.{vue,ts}'] } }, | ||
| 17 | serverBundle: 'local' | 18 | serverBundle: 'local' |
| 18 | }, | 19 | }, |
| 19 | nitro: { | 20 | nitro: { |