Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 54 additions & 25 deletions layers/registry/app/components/SkillFileTreeNode.vue
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<script setup lang="ts">
import type { SkillFileTreeNode as TreeNode } from '../utils/skill-file-tree'
import { formatByteSize } from '../utils/skill-context-cost'
import { fileIcon, isInlineRenderable } from '../utils/skill-file-tree'
import { fileIcon, isInlineRenderable, splitFileLabel } from '../utils/skill-file-tree'

const props = defineProps<{
node: TreeNode
Expand Down Expand Up @@ -55,6 +55,8 @@ function onFileClick(event: MouseEvent) {
emit('select', props.node.path)
}

const label = computed(() => splitFileLabel(props.node.name))

const inlineRenderable = computed(() =>
props.node.kind === 'file' && !!props.node.asset && isInlineRenderable(props.node.asset.type),
)
Expand All @@ -68,22 +70,29 @@ const inlineRenderable = computed(() =>
class="tree-row"
:aria-expanded="open"
:aria-controls="`${idPrefix}-${node.path}`"
:title="node.path"
@click="open = !open"
>
<UIcon
name="i-lucide-chevron-right"
class="size-3 text-muted transition-transform shrink-0"
:class="{ 'rotate-90': open }"
aria-hidden="true"
/>
<SkillFileIcon
:name="open ? 'default-folder-opened' : 'default-folder'"
class="size-4 shrink-0"
/>
<span class="tree-label">{{ node.name }}</span>
<span
class="tree-label"
:style="{ '--label-tail': `${label.tail.length}ch` }"
>
<span class="tree-label-head">{{ label.head }}</span>
<span v-if="label.tail">{{ label.tail }}</span>
</span>
<span
class="tree-size"
>{{ folderFileCount }} {{ folderFileCount === 1 ? 'file' : 'files' }}</span>
<UIcon
name="i-lucide-chevron-right"
class="size-3 text-muted transition-transform shrink-0"
:class="{ 'rotate-90': open }"
aria-hidden="true"
/>
</button>
<ul
v-show="open"
Expand Down Expand Up @@ -122,12 +131,17 @@ const inlineRenderable = computed(() =>
:title="node.path"
@click="onFileClick"
>
<span class="tree-spacer" aria-hidden="true" />
<SkillFileIcon
:name="fileIcon(node.name)"
class="size-4 shrink-0"
/>
<span class="tree-label">{{ node.name }}</span>
<span
class="tree-label"
:style="{ '--label-tail': `${label.tail.length}ch` }"
>
<span class="tree-label-head">{{ label.head }}</span>
<span v-if="label.tail">{{ label.tail }}</span>
</span>
<span
v-if="node.asset?.size"
class="tree-size"
Expand All @@ -142,12 +156,17 @@ const inlineRenderable = computed(() =>
:title="node.path"
@click="onFileClick"
>
<span class="tree-spacer" aria-hidden="true" />
<SkillFileIcon
:name="fileIcon(node.name)"
class="size-4 shrink-0"
/>
<span class="tree-label">{{ node.name }}</span>
<span
class="tree-label"
:style="{ '--label-tail': `${label.tail.length}ch` }"
>
<span class="tree-label-head">{{ label.head }}</span>
<span v-if="label.tail">{{ label.tail }}</span>
</span>
<span
v-if="node.asset?.size"
class="tree-size"
Expand All @@ -161,12 +180,17 @@ const inlineRenderable = computed(() =>
class="tree-row file"
:title="node.path"
>
<span class="tree-spacer" aria-hidden="true" />
<SkillFileIcon
:name="fileIcon(node.name)"
class="size-4 shrink-0"
/>
<span class="tree-label">{{ node.name }}</span>
<span
class="tree-label"
:style="{ '--label-tail': `${label.tail.length}ch` }"
>
<span class="tree-label-head">{{ label.head }}</span>
<span v-if="label.tail">{{ label.tail }}</span>
</span>
<span
v-if="node.asset?.size"
class="tree-size"
Expand All @@ -185,13 +209,14 @@ const inlineRenderable = computed(() =>
.tree-children {
list-style: none;
margin: 0;
padding-left: 0.875rem;
padding-left: 0.375rem;
border-left: 1px dashed var(--ui-border);
margin-left: 0.4375rem;
/* Puts the guide line under the parent folder icon. */
margin-left: 0.9375rem;
}
.tree-row {
display: flex;
align-items: flex-start;
align-items: center;
gap: 0.375rem;
width: 100%;
padding: 0.25rem 0.5rem;
Expand All @@ -217,21 +242,25 @@ const inlineRenderable = computed(() =>
background: var(--ui-bg-muted);
font-weight: 500;
}
.tree-spacer {
width: 0.75rem;
flex-shrink: 0;
}
.tree-size {
flex-shrink: 0;
font-size: 0.6875rem;
font-variant-numeric: tabular-nums;
color: var(--ui-text-muted);
}
/* The explorer column is narrow, so long names wrap instead of hiding the
part that tells two files apart. */
.tree-label {
min-width: 0;
display: flex;
flex: 1;
overflow-wrap: anywhere;
min-width: 0;
white-space: nowrap;
container-type: inline-size;
}
/* The tree is monospace. Rounding the head down to whole characters puts the
ellipsis flush against the tail, with no part-character gap between them. */
.tree-label-head {
min-width: 0;
max-width: round(down, calc(100cqw - var(--label-tail)), 1ch);
overflow: hidden;
text-overflow: ellipsis;
}
</style>
23 changes: 23 additions & 0 deletions layers/registry/app/utils/skill-file-tree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,3 +173,26 @@ export function highlightLangFromPath(path: string): string {
export function isInlineRenderable(type: 'markdown' | 'code' | 'image' | 'data' | 'other'): boolean {
return type === 'markdown' || type === 'code' || type === 'data'
}

const LABEL_SPLIT_MIN = 17
const LABEL_TAIL_MIN = 6
const LABEL_TAIL_MAX = 12
const LABEL_TAIL_FALLBACK = 8
const LABEL_BOUNDARY = /[-_. ]/g

/**
* Splits a long file name so the tree can truncate its middle and keep its
* end. Names like `setup-v1.md` and `setup-v2.md` then stay apart in a narrow
* column. The tail starts after a separator when one gives a tail of 6 to 12
* characters, so a word is not cut in half.
*/
export function splitFileLabel(name: string): { head: string, tail: string } {
if (name.length < LABEL_SPLIT_MIN)
return { head: name, tail: '' }
const cut = [...name.matchAll(LABEL_BOUNDARY)]
.map(match => match.index + 1)
.reverse()
.find(index => name.length - index >= LABEL_TAIL_MIN && name.length - index <= LABEL_TAIL_MAX)
?? name.length - LABEL_TAIL_FALLBACK
return { head: name.slice(0, cut), tail: name.slice(cut) }
}
17 changes: 16 additions & 1 deletion test/unit/skill-file-tree.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { SkillFileTreeNode } from '../../layers/registry/app/utils/skill-file-tree'
import { describe, expect, it } from 'vitest'
import { buildSkillFileTree } from '../../layers/registry/app/utils/skill-file-tree'
import { buildSkillFileTree, splitFileLabel } from '../../layers/registry/app/utils/skill-file-tree'

function file(path: string, type: 'markdown' | 'code' = 'markdown') {
return { path, size: 100, type }
Expand Down Expand Up @@ -55,3 +55,18 @@ describe('skill file tree', () => {
expect(tree.at(-1)).toMatchObject({ kind: 'file', asset: { path: 'SKILL.md', size: 7900 } })
})
})

describe('splitFileLabel', () => {
it('keeps a short name whole', () => {
expect(splitFileLabel('SKILL.md')).toEqual({ head: 'SKILL.md', tail: '' })
})

it('starts the kept tail at a word boundary', () => {
expect(splitFileLabel('package-lock.json')).toEqual({ head: 'package-', tail: 'lock.json' })
expect(splitFileLabel('shipreel.example.yml')).toEqual({ head: 'shipreel.', tail: 'example.yml' })
})

it('keeps the last eight characters when no boundary fits', () => {
expect(splitFileLabel('abcdefghijklmnopqrstuvwxyz.md')).toEqual({ head: 'abcdefghijklmnopqrstu', tail: 'vwxyz.md' })
})
})
Loading