vocs 2.0.4 → 2.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/dist/internal/config.d.ts +7 -1
  2. package/dist/internal/config.d.ts.map +1 -1
  3. package/dist/internal/config.js +14 -1
  4. package/dist/internal/config.js.map +1 -1
  5. package/dist/internal/icons.d.ts.map +1 -1
  6. package/dist/internal/icons.js +2 -1
  7. package/dist/internal/icons.js.map +1 -1
  8. package/dist/internal/mdx.d.ts.map +1 -1
  9. package/dist/internal/mdx.js +42 -0
  10. package/dist/internal/mdx.js.map +1 -1
  11. package/dist/internal/shiki-transformers.d.ts +9 -0
  12. package/dist/internal/shiki-transformers.d.ts.map +1 -1
  13. package/dist/internal/shiki-transformers.js +37 -1
  14. package/dist/internal/shiki-transformers.js.map +1 -1
  15. package/dist/internal/sidebar.d.ts +11 -0
  16. package/dist/internal/sidebar.d.ts.map +1 -1
  17. package/dist/internal/sidebar.js.map +1 -1
  18. package/dist/internal/twoslash/checker.js +5 -0
  19. package/dist/internal/twoslash/checker.js.map +1 -1
  20. package/dist/react/internal/FileTree.client.d.ts +16 -0
  21. package/dist/react/internal/FileTree.client.d.ts.map +1 -1
  22. package/dist/react/internal/FileTree.client.js +23 -3
  23. package/dist/react/internal/FileTree.client.js.map +1 -1
  24. package/dist/react/internal/FileTree.mdx.d.ts +1 -0
  25. package/dist/react/internal/FileTree.mdx.d.ts.map +1 -1
  26. package/dist/react/internal/FileTree.mdx.js +2 -2
  27. package/dist/react/internal/FileTree.mdx.js.map +1 -1
  28. package/dist/react/internal/Sidebar.d.ts.map +1 -1
  29. package/dist/react/internal/Sidebar.js +13 -7
  30. package/dist/react/internal/Sidebar.js.map +1 -1
  31. package/package.json +1 -1
  32. package/src/internal/config.ts +21 -2
  33. package/src/internal/icons.test.ts +9 -2
  34. package/src/internal/icons.ts +2 -1
  35. package/src/internal/mdx.test.ts +123 -0
  36. package/src/internal/mdx.ts +44 -0
  37. package/src/internal/shiki-transformers.test.ts +29 -0
  38. package/src/internal/shiki-transformers.ts +40 -2
  39. package/src/internal/sidebar.test.ts +28 -0
  40. package/src/internal/sidebar.ts +14 -0
  41. package/src/internal/twoslash/checker.ts +5 -0
  42. package/src/react/internal/FileTree.client.tsx +113 -16
  43. package/src/react/internal/FileTree.mdx.tsx +31 -12
  44. package/src/react/internal/Sidebar.tsx +30 -6
@@ -34,8 +34,15 @@ describe('resolveIconSync', () => {
34
34
  expect(resolveIconSync('lucide:nonexistent-icon-xyz')).toBeUndefined()
35
35
  })
36
36
 
37
- test('returns undefined for invalid format (no colon)', () => {
38
- expect(resolveIconSync('nocolon')).toBeUndefined()
37
+ test('resolves bare icon name as lucide', () => {
38
+ const bare = resolveIconSync('arrow-right')
39
+ const prefixed = resolveIconSync('lucide:arrow-right')
40
+ expect(bare).toBeDefined()
41
+ expect(bare).toBe(prefixed)
42
+ })
43
+
44
+ test('returns undefined for bare name not in lucide', () => {
45
+ expect(resolveIconSync('nonexistent-icon-xyz')).toBeUndefined()
39
46
  })
40
47
 
41
48
  test('returns undefined for empty string parts', () => {
@@ -182,7 +182,8 @@ export function matchIcon(
182
182
  export function resolveIconSync(icon: string): string | undefined {
183
183
  if (icon.startsWith('<svg')) return icon
184
184
 
185
- const [collection, iconName] = icon.split(':')
185
+ // Default to `lucide` collection when no `collection:` prefix is provided.
186
+ const [collection, iconName] = icon.includes(':') ? icon.split(':') : ['lucide', icon]
186
187
  if (!collection || !iconName) return undefined
187
188
 
188
189
  const iconSet = iconSets[collection]
@@ -267,6 +267,129 @@ describe('remarkRestoreUnknownTextDirectives', () => {
267
267
  })
268
268
 
269
269
  describe('remarkFileTree', () => {
270
+ async function parse(items: { text: string }[]) {
271
+ const tree = {
272
+ type: 'root',
273
+ children: [
274
+ {
275
+ type: 'containerDirective',
276
+ name: 'file-tree',
277
+ children: [
278
+ {
279
+ type: 'list',
280
+ children: items.map((item) => ({
281
+ type: 'listItem',
282
+ children: [
283
+ {
284
+ type: 'paragraph',
285
+ children: [{ type: 'text', value: item.text }],
286
+ },
287
+ ],
288
+ })),
289
+ },
290
+ ],
291
+ },
292
+ ],
293
+ }
294
+ await remarkFileTree(Config.define({ rootDir: process.cwd() }))(tree as never)
295
+ const fileTree = tree.children[0] as {
296
+ data?: { hProperties?: Record<string, string> }
297
+ }
298
+ return JSON.parse(fileTree.data?.hProperties?.['data-v-file-tree-items'] ?? '[]')
299
+ }
300
+
301
+ it('extracts {info="..."} tooltip from a plain row', async () => {
302
+ const items = await parse([{ text: 'vocs.config.ts{info="Site config"}' }])
303
+ expect(items[0]).toMatchObject({
304
+ name: 'vocs.config.ts',
305
+ type: 'file',
306
+ tooltip: 'Site config',
307
+ })
308
+ expect(items[0].comment).toBeUndefined()
309
+ })
310
+
311
+ it('extracts tooltip from a row that also has an inline comment', async () => {
312
+ const items = await parse([{ text: 'layout.tsx the root layout{info="Wraps every page"}' }])
313
+ expect(items[0]).toMatchObject({
314
+ name: 'layout.tsx',
315
+ type: 'file',
316
+ comment: 'the root layout',
317
+ tooltip: 'Wraps every page',
318
+ })
319
+ })
320
+
321
+ it('extracts tooltip from a folder row', async () => {
322
+ const items = await parse([{ text: '+app the app dir{info="App router root"}' }])
323
+ expect(items[0]).toMatchObject({
324
+ name: 'app',
325
+ type: 'folder',
326
+ comment: 'the app dir',
327
+ tooltip: 'App router root',
328
+ })
329
+ })
330
+
331
+ it('supports unquoted {info=...} values', async () => {
332
+ const items = await parse([{ text: 'page.tsx{info=Home route}' }])
333
+ expect(items[0]).toMatchObject({
334
+ name: 'page.tsx',
335
+ tooltip: 'Home route',
336
+ })
337
+ })
338
+
339
+ it('omits tooltip when value is empty', async () => {
340
+ const items = await parse([{ text: 'page.tsx{info=""}' }])
341
+ expect(items[0]).toMatchObject({ name: 'page.tsx' })
342
+ expect(items[0].tooltip).toBeUndefined()
343
+ })
344
+
345
+ it('omits tooltip when no {info} present', async () => {
346
+ const items = await parse([{ text: 'page.tsx' }])
347
+ expect(items[0]).toMatchObject({ name: 'page.tsx' })
348
+ expect(items[0].tooltip).toBeUndefined()
349
+ })
350
+
351
+ it('extracts tooltip from a trailing mdxTextExpression (MDX form)', async () => {
352
+ // Simulate what the MDX pipeline emits for `vocs.config.ts{info="Site config"}`:
353
+ // a text node followed by an mdxTextExpression whose value is the contents
354
+ // between the braces (no braces).
355
+ const tree = {
356
+ type: 'root',
357
+ children: [
358
+ {
359
+ type: 'containerDirective',
360
+ name: 'file-tree',
361
+ children: [
362
+ {
363
+ type: 'list',
364
+ children: [
365
+ {
366
+ type: 'listItem',
367
+ children: [
368
+ {
369
+ type: 'paragraph',
370
+ children: [
371
+ { type: 'text', value: 'vocs.config.ts' },
372
+ { type: 'mdxTextExpression', value: 'info="Site config: nav, theme"' },
373
+ ],
374
+ },
375
+ ],
376
+ },
377
+ ],
378
+ },
379
+ ],
380
+ },
381
+ ],
382
+ }
383
+ await remarkFileTree(Config.define({ rootDir: process.cwd() }))(tree as never)
384
+ const fileTree = tree.children[0] as { data?: { hProperties?: Record<string, string> } }
385
+ const items = JSON.parse(fileTree.data?.hProperties?.['data-v-file-tree-items'] ?? '[]')
386
+ expect(items[0]).toMatchObject({
387
+ name: 'vocs.config.ts',
388
+ type: 'file',
389
+ tooltip: 'Site config: nav, theme',
390
+ })
391
+ })
392
+
270
393
  it('preserves inline code in file comments', async () => {
271
394
  const tree = {
272
395
  type: 'root',
@@ -1110,9 +1110,21 @@ export function remarkFileTree(config: Config.Config): remarkFileTree.ReturnType
1110
1110
  comment?: string | undefined
1111
1111
  highlighted?: boolean | undefined
1112
1112
  icon?: string | undefined
1113
+ tooltip?: string | undefined
1113
1114
  items?: FileTreeItem[] | undefined
1114
1115
  }
1115
1116
 
1117
+ // Matches a trailing `{info="..."}` or `{info=...}` (quotes optional, value
1118
+ // may be empty) on a file-tree row.
1119
+ //
1120
+ // Used in two places: against a raw text node when the row is parsed as
1121
+ // Markdown, and against the body of an `mdxTextExpression` (which contains
1122
+ // the contents *between* the braces) when the row is parsed as MDX. MDX
1123
+ // intercepts `{...}` as a JS expression embed, so the braces aren't part of
1124
+ // the captured value in that case.
1125
+ const tooltipRegex = /\s*\{info(?:=(?:"([^"]*)"|([^}]*)))?\}\s*$/
1126
+ const tooltipBodyRegex = /^\s*info(?:=(?:"([^"]*)"|([^}]*)))?\s*$/
1127
+
1116
1128
  async function resolveAllIcons(items: FileTreeItem[]): Promise<void> {
1117
1129
  await Promise.all(
1118
1130
  items.map(async (item) => {
@@ -1159,6 +1171,37 @@ export function remarkFileTree(config: Config.Config): remarkFileTree.ReturnType
1159
1171
  )
1160
1172
  if (!paragraph) continue
1161
1173
 
1174
+ // Pre-extract trailing `{info="..."}` so its (possibly space-
1175
+ // containing) value doesn't get mis-split into name/comment below.
1176
+ //
1177
+ // MDX intercepts `{...}` as a JS expression embed (mdxTextExpression
1178
+ // with body `info="..."`); plain Markdown leaves it as text. Handle
1179
+ // both: a trailing mdxTextExpression takes precedence, otherwise
1180
+ // look at the last text node.
1181
+ let tooltip: string | undefined
1182
+ const lastChild = paragraph.children[paragraph.children.length - 1]
1183
+ if (lastChild?.type === 'mdxTextExpression') {
1184
+ const expr = lastChild as MdAst.PhrasingContent & { value: string }
1185
+ const match = expr.value.match(tooltipBodyRegex)
1186
+ if (match) {
1187
+ tooltip = (match[1] ?? match[2] ?? '').trim()
1188
+ // Drop the expression entirely; an mdxTextExpression has no
1189
+ // text representation in the row.
1190
+ paragraph.children.pop()
1191
+ }
1192
+ } else {
1193
+ const lastText = [...paragraph.children]
1194
+ .reverse()
1195
+ .find((c): c is MdAst.Text => c.type === 'text')
1196
+ if (lastText) {
1197
+ const match = lastText.value.match(tooltipRegex)
1198
+ if (match) {
1199
+ tooltip = (match[1] ?? match[2] ?? '').trim()
1200
+ lastText.value = lastText.value.replace(tooltipRegex, '')
1201
+ }
1202
+ }
1203
+ }
1204
+
1162
1205
  let name = ''
1163
1206
  let comment = ''
1164
1207
  let folder = false
@@ -1211,6 +1254,7 @@ export function remarkFileTree(config: Config.Config): remarkFileTree.ReturnType
1211
1254
  type: folder ? 'folder' : 'file',
1212
1255
  ...(comment && { comment }),
1213
1256
  ...(highlighted && { highlighted }),
1257
+ ...(tooltip && { tooltip }),
1214
1258
  }
1215
1259
 
1216
1260
  // Check for nested lists (children of this list item)
@@ -6,6 +6,7 @@ import {
6
6
  notationBlock,
7
7
  notationInclude,
8
8
  removeNotationEscape,
9
+ resetTwoslasher,
9
10
  resetTwoslashSnippets,
10
11
  shellNotation,
11
12
  shellPrompt,
@@ -212,6 +213,34 @@ const element = <div />`,
212
213
  highlighter.dispose()
213
214
  })
214
215
 
216
+ it('should keep producing correct output after the singleton twoslasher is reset', async () => {
217
+ const highlighter = await createHighlighter({
218
+ themes: ['github-dark'],
219
+ langs: ['typescript'],
220
+ })
221
+ const transformer = twoslash({ explicitTrigger: false })
222
+
223
+ const first = highlighter.codeToHtml('const a: number = 1', {
224
+ lang: 'typescript',
225
+ theme: 'github-dark',
226
+ transformers: [transformer],
227
+ })
228
+
229
+ // Force the singleton to drop; the next call must lazily reconstruct it
230
+ // and still produce equivalent rendered HTML.
231
+ resetTwoslasher()
232
+
233
+ const second = highlighter.codeToHtml('const a: number = 1', {
234
+ lang: 'typescript',
235
+ theme: 'github-dark',
236
+ transformers: [transformer],
237
+ })
238
+
239
+ expect(second).toBe(first)
240
+
241
+ highlighter.dispose()
242
+ })
243
+
215
244
  it('should typecheck virtual json files in check-only mode', async () => {
216
245
  const highlighter = await createHighlighter({
217
246
  themes: ['github-dark'],
@@ -222,9 +222,23 @@ export const twoslashErrors: TwoslashError[] = []
222
222
  const cwd = typeof process !== 'undefined' && typeof process.cwd === 'function' ? process.cwd() : ''
223
223
  const require = createRequire(import.meta.url)
224
224
 
225
- let twoslasher: TwoslashInstance
225
+ let twoslasher: TwoslashInstance | undefined
226
+ let twoslasherCalls = 0
226
227
  let typescript: TS | undefined
227
228
 
229
+ /**
230
+ * How often to recreate the singleton `twoslasher`. The underlying
231
+ * `LanguageService` retains every `SourceFile`/`Symbol`/`Type` it has ever
232
+ * resolved (including the user's full dependency graph when snippets import
233
+ * library types), and there's no way to evict individual entries. Without a
234
+ * periodic reset, peak RSS on a site with 1000+ snippets can exceed 5 GB.
235
+ *
236
+ * Recreating it costs ~1.5s of TS lib re-init per reset; with the default of
237
+ * 200, that's ≤ a few seconds extra on the largest sites in exchange for
238
+ * GB-scale memory savings.
239
+ */
240
+ const twoslasherResetInterval = 200
241
+
228
242
  export function twoslash(options: twoslash.Options): ShikiTransformer {
229
243
  const { cacheDir } = options
230
244
  const {
@@ -259,13 +273,24 @@ export function twoslash(options: twoslash.Options): ShikiTransformer {
259
273
  ignoreDeprecations: Number.parseInt(tsModule.versionMajorMinor, 10) >= 6 ? '6.0' : '5.0',
260
274
  moduleResolution: 100, // bundler (default, can be overridden)
261
275
  preserveSymlinks: false, // needed for monorepo/workspace symlinks
276
+ // Skip checking lib + .d.ts files. Twoslash snippets aren't authoring
277
+ // libraries — we only care about errors inside the snippet itself.
278
+ // Materially reduces peak RSS on large docs sites (avoids retaining
279
+ // ASTs for every lib.*.d.ts and @types/* file in TS's caches).
280
+ skipDefaultLibCheck: true,
281
+ skipLibCheck: true,
262
282
  types: ['node'], // include node types by default (process.env, etc.)
263
283
  ...(twoslashOptions?.compilerOptions ?? {}),
264
284
  },
265
285
  })
266
286
  }
267
287
 
268
- return twoslasher(code, lang, executeOptions)
288
+ const result = twoslasher(code, lang, executeOptions)
289
+
290
+ // Periodically drop the singleton so the underlying `LanguageService` (and
291
+ // its retained source-file / symbol caches) becomes GC-eligible.
292
+ if (++twoslasherCalls >= twoslasherResetInterval) resetTwoslasher()
293
+ return result
269
294
  }
270
295
  const activeTwoslasher = checkOnly
271
296
  ? checkOnlyTwoslasher({ throws, twoslashOptions })
@@ -369,6 +394,19 @@ export function resetTwoslashSnippets() {
369
394
  TwoslashChecker.reset()
370
395
  }
371
396
 
397
+ /**
398
+ * Drop the singleton `twoslasher` instance so its underlying `LanguageService`
399
+ * and the `SourceFile`/`Symbol`/`Type` caches it retains become eligible for
400
+ * garbage collection. The next twoslash call will lazily reconstruct it.
401
+ *
402
+ * Called automatically every `twoslasherResetInterval` snippets during a
403
+ * build; also safe to invoke explicitly between build phases.
404
+ */
405
+ export function resetTwoslasher() {
406
+ twoslasher = undefined
407
+ twoslasherCalls = 0
408
+ }
409
+
372
410
  function getTypeScript(): TS {
373
411
  if (typescript) return typescript
374
412
 
@@ -130,6 +130,34 @@ describe('flatten', () => {
130
130
  ]
131
131
  `)
132
132
  })
133
+
134
+ test('preserves badge on flattened items', () => {
135
+ expect(
136
+ flatten([
137
+ { text: 'A', link: '/a', badge: 'New' },
138
+ {
139
+ text: 'Group',
140
+ items: [{ text: 'B', link: '/b', badge: { text: 'Beta', variant: 'warning' } }],
141
+ },
142
+ ]),
143
+ ).toMatchInlineSnapshot(`
144
+ [
145
+ {
146
+ "badge": "New",
147
+ "link": "/a",
148
+ "text": "A",
149
+ },
150
+ {
151
+ "badge": {
152
+ "text": "Beta",
153
+ "variant": "warning",
154
+ },
155
+ "link": "/b",
156
+ "text": "B",
157
+ },
158
+ ]
159
+ `)
160
+ })
133
161
  })
134
162
 
135
163
  describe('fromConfig', () => {
@@ -1,6 +1,20 @@
1
1
  import type { Config } from './config.js'
2
2
 
3
+ export type SidebarItemBadge =
4
+ | string
5
+ | {
6
+ /** Text displayed inside the badge. */
7
+ text: string
8
+ /** Visual variant. Defaults to `'info'`. */
9
+ variant?: 'note' | 'info' | 'warning' | 'danger' | 'tip' | 'success' | undefined
10
+ }
11
+
3
12
  export type SidebarItem<strict extends boolean = false> = {
13
+ /**
14
+ * Badge rendered to the right of the item text.
15
+ * Pass a string for a default `info` badge, or an object to pick a variant.
16
+ */
17
+ badge?: SidebarItemBadge | undefined
4
18
  /** Whether or not to disable the sidebar item. */
5
19
  disabled?: boolean | undefined
6
20
  /** Whether to open the link in a new tab. */
@@ -168,6 +168,11 @@ function parseSnippet(snippet: collect.Snippet, tsModule: TS, key: string): Pars
168
168
  ignoreDeprecations: Number.parseInt(tsModule.versionMajorMinor, 10) >= 6 ? '6.0' : '5.0',
169
169
  moduleResolution: 100,
170
170
  preserveSymlinks: false,
171
+ // Skip checking lib + .d.ts files. Twoslash snippets aren't authoring
172
+ // libraries — we only want errors inside the snippet itself. Avoids
173
+ // walking every lib.*.d.ts / @types/* AST per Program.
174
+ skipDefaultLibCheck: true,
175
+ skipLibCheck: true,
171
176
  types: ['node'],
172
177
  ...(snippet.twoslashOptions?.compilerOptions ?? {}),
173
178
  } satisfies TypeScript.CompilerOptions
@@ -1,6 +1,69 @@
1
1
  'use client'
2
2
 
3
+ import { Popover } from '@base-ui/react/popover'
3
4
  import * as React from 'react'
5
+ import LucideInfo from '~icons/lucide/info'
6
+
7
+ const triggerBaseClassName =
8
+ 'vocs:flex vocs:items-center vocs:gap-2 vocs:whitespace-nowrap vocs:justify-self-start vocs:rounded-md vocs:px-2 vocs:py-0.5 vocs:-mx-2 vocs:-my-0.5 vocs:border vocs:border-transparent vocs:transition-colors vocs:data-[highlighted=true]:bg-surfaceTint vocs:data-[highlighted=true]:border-primary'
9
+
10
+ const tooltipTriggerClassName =
11
+ 'vocs:hover:bg-surfaceTint vocs:hover:border-primary vocs:data-[popup-open]:bg-surfaceTint vocs:data-[popup-open]:border-primary'
12
+
13
+ const folderButtonClassName = 'vocs:bg-transparent vocs:m-0 vocs:not-disabled:cursor-pointer'
14
+
15
+ const popupClassName =
16
+ 'vocs:max-w-[300px] vocs:bg-surface vocs:border vocs:border-primary vocs:rounded-md vocs:px-3 vocs:py-2 vocs:text-sm vocs:text-primary vocs:font-sans vocs:leading-snug vocs:shadow-md vocs:z-50'
17
+
18
+ function InfoIndicator() {
19
+ return (
20
+ <span
21
+ aria-hidden="true"
22
+ className="vocs:shrink-0 vocs:flex vocs:items-center vocs:justify-center vocs:size-4 vocs:text-muted"
23
+ >
24
+ <LucideInfo className="vocs:size-3.5" />
25
+ </span>
26
+ )
27
+ }
28
+
29
+ /**
30
+ * Wraps a file row's label area (icon + name) so the entire row becomes a
31
+ * hover-activated popover trigger. The trigger gets the active-style
32
+ * background and border while hovered or while the popover is open, and a
33
+ * decorative info icon is appended to make the affordance discoverable.
34
+ */
35
+ export function FileRowTrigger(props: FileRowTrigger.Props) {
36
+ const { children, content, highlighted, label } = props
37
+ return (
38
+ <Popover.Root>
39
+ <Popover.Trigger
40
+ render={<span />}
41
+ openOnHover
42
+ delay={150}
43
+ aria-label={`More info about ${label}`}
44
+ data-highlighted={highlighted ? 'true' : undefined}
45
+ className={`${triggerBaseClassName} ${tooltipTriggerClassName}`}
46
+ >
47
+ {children}
48
+ <InfoIndicator />
49
+ </Popover.Trigger>
50
+ <Popover.Portal>
51
+ <Popover.Positioner side="top" align="center" sideOffset={6}>
52
+ <Popover.Popup className={popupClassName}>{content}</Popover.Popup>
53
+ </Popover.Positioner>
54
+ </Popover.Portal>
55
+ </Popover.Root>
56
+ )
57
+ }
58
+
59
+ export declare namespace FileRowTrigger {
60
+ type Props = {
61
+ children: React.ReactNode
62
+ content: string
63
+ label: string
64
+ highlighted?: boolean | undefined
65
+ }
66
+ }
4
67
 
5
68
  export function FolderToggle(props: FolderToggle.Props) {
6
69
  const {
@@ -12,6 +75,7 @@ export function FolderToggle(props: FolderToggle.Props) {
12
75
  highlighted,
13
76
  labelColumnOffset,
14
77
  name,
78
+ tooltip,
15
79
  } = props
16
80
  const [isOpen, setIsOpen] = React.useState(true)
17
81
  const rowStyle = comment
@@ -20,28 +84,60 @@ export function FolderToggle(props: FolderToggle.Props) {
20
84
  }
21
85
  : undefined
22
86
 
87
+ const folderInner = (
88
+ <>
89
+ <span className="vocs:shrink-0">{isOpen && hasChildren ? folderOpenIcon : folderIcon}</span>
90
+ <span>{name}</span>
91
+ </>
92
+ )
93
+
23
94
  return (
24
95
  <div className="vocs:flex vocs:flex-col vocs:overflow-visible">
25
96
  <div className="vocs:relative vocs:flex vocs:items-center vocs:py-1">
26
- <button
27
- className="vocs:grid vocs:items-center vocs:gap-x-4 vocs:w-fit vocs:bg-transparent vocs:p-0 vocs:m-0 vocs:text-primary vocs:not-disabled:cursor-pointer"
28
- data-open={isOpen}
29
- disabled={!hasChildren}
30
- onClick={() => setIsOpen(!isOpen)}
97
+ <div
98
+ className="vocs:grid vocs:items-center vocs:gap-x-4 vocs:w-fit vocs:text-primary"
31
99
  style={rowStyle}
32
- type="button"
33
100
  >
34
- <span
35
- className="vocs:flex vocs:items-center vocs:gap-2 vocs:whitespace-nowrap vocs:justify-self-start vocs:data-[highlighted=true]:bg-surfaceTint vocs:data-[highlighted=true]:border vocs:data-[highlighted=true]:border-primary vocs:rounded-md vocs:px-2 vocs:py-0.5 vocs:-mx-2 vocs:-my-0.5"
36
- data-highlighted={highlighted}
37
- >
38
- <span className="vocs:shrink-0">
39
- {isOpen && hasChildren ? folderOpenIcon : folderIcon}
40
- </span>
41
- <span>{name}</span>
42
- </span>
101
+ {tooltip ? (
102
+ <Popover.Root>
103
+ <Popover.Trigger
104
+ render={
105
+ <button
106
+ disabled={!hasChildren}
107
+ onClick={() => setIsOpen(!isOpen)}
108
+ type="button"
109
+ />
110
+ }
111
+ openOnHover
112
+ delay={150}
113
+ aria-label={`More info about ${name}`}
114
+ data-highlighted={highlighted ? 'true' : undefined}
115
+ data-open={isOpen}
116
+ className={`${triggerBaseClassName} ${folderButtonClassName} ${tooltipTriggerClassName}`}
117
+ >
118
+ {folderInner}
119
+ <InfoIndicator />
120
+ </Popover.Trigger>
121
+ <Popover.Portal>
122
+ <Popover.Positioner side="top" align="center" sideOffset={6}>
123
+ <Popover.Popup className={popupClassName}>{tooltip}</Popover.Popup>
124
+ </Popover.Positioner>
125
+ </Popover.Portal>
126
+ </Popover.Root>
127
+ ) : (
128
+ <button
129
+ className={`${triggerBaseClassName} ${folderButtonClassName}`}
130
+ data-highlighted={highlighted}
131
+ data-open={isOpen}
132
+ disabled={!hasChildren}
133
+ onClick={() => setIsOpen(!isOpen)}
134
+ type="button"
135
+ >
136
+ {folderInner}
137
+ </button>
138
+ )}
43
139
  {comment && <span className="vocs:text-muted vocs:whitespace-nowrap">{comment}</span>}
44
- </button>
140
+ </div>
45
141
  </div>
46
142
  {hasChildren && isOpen && folderContent}
47
143
  </div>
@@ -54,6 +150,7 @@ export declare namespace FolderToggle {
54
150
  folderOpenIcon: React.ReactNode
55
151
  name: string
56
152
  comment?: string | undefined
153
+ tooltip?: string | undefined
57
154
  hasChildren: boolean
58
155
  highlighted?: boolean | undefined
59
156
  labelColumnOffset: number
@@ -1,7 +1,7 @@
1
1
  import LucideFile from '~icons/lucide/file'
2
2
  import LucideFolder from '~icons/lucide/folder'
3
3
  import LucideFolderOpen from '~icons/lucide/folder-open'
4
- import { FolderToggle } from './FileTree.client.js'
4
+ import { FileRowTrigger, FolderToggle } from './FileTree.client.js'
5
5
 
6
6
  export function FileTree(props: FileTree.Props) {
7
7
  const items: FileTree.Item[] = JSON.parse(props['data-v-file-tree-items'] ?? '[]')
@@ -33,6 +33,7 @@ export namespace FileTree {
33
33
  comment?: string
34
34
  highlighted?: boolean
35
35
  icon?: string
36
+ tooltip?: string
36
37
  items?: Item[]
37
38
  }
38
39
 
@@ -90,6 +91,7 @@ export namespace FileTree {
90
91
  <FolderToggle
91
92
  name={item.name}
92
93
  comment={item.comment}
94
+ tooltip={item.tooltip}
93
95
  hasChildren={!!hasChildren}
94
96
  highlighted={item.highlighted}
95
97
  labelColumnOffset={depth * indentSize}
@@ -105,19 +107,36 @@ export namespace FileTree {
105
107
  className="vocs:grid vocs:items-center vocs:gap-x-4 vocs:text-primary"
106
108
  style={rowStyle}
107
109
  >
108
- <span
109
- className="vocs:flex vocs:items-center vocs:gap-2 vocs:whitespace-nowrap vocs:justify-self-start vocs:data-[highlighted=true]:bg-surfaceTint vocs:data-[highlighted=true]:border vocs:data-[highlighted=true]:border-primary vocs:rounded-md vocs:px-2 vocs:py-0.5 vocs:-mx-2 vocs:-my-0.5"
110
- data-highlighted={item.highlighted}
111
- >
112
- {item.name !== '...' && (
113
- <span className="vocs:shrink-0">
114
- <FileIcon icon={item.icon} />
110
+ {item.tooltip ? (
111
+ <FileRowTrigger
112
+ content={item.tooltip}
113
+ label={item.name}
114
+ highlighted={item.highlighted}
115
+ >
116
+ {item.name !== '...' && (
117
+ <span className="vocs:shrink-0">
118
+ <FileIcon icon={item.icon} />
119
+ </span>
120
+ )}
121
+ <span className={item.name === '...' ? 'vocs:text-muted' : undefined}>
122
+ {item.name}
123
+ </span>
124
+ </FileRowTrigger>
125
+ ) : (
126
+ <span
127
+ className="vocs:flex vocs:items-center vocs:gap-2 vocs:whitespace-nowrap vocs:justify-self-start vocs:data-[highlighted=true]:bg-surfaceTint vocs:data-[highlighted=true]:border vocs:data-[highlighted=true]:border-primary vocs:rounded-md vocs:px-2 vocs:py-0.5 vocs:-mx-2 vocs:-my-0.5"
128
+ data-highlighted={item.highlighted}
129
+ >
130
+ {item.name !== '...' && (
131
+ <span className="vocs:shrink-0">
132
+ <FileIcon icon={item.icon} />
133
+ </span>
134
+ )}
135
+ <span className={item.name === '...' ? 'vocs:text-muted' : undefined}>
136
+ {item.name}
115
137
  </span>
116
- )}
117
- <span className={item.name === '...' ? 'vocs:text-muted' : undefined}>
118
- {item.name}
119
138
  </span>
120
- </span>
139
+ )}
121
140
  {item.comment && (
122
141
  <span className="vocs:text-muted vocs:whitespace-nowrap">{item.comment}</span>
123
142
  )}