Skip to content

Commit b5cf16b

Browse files
fix: address file preview review feedback
1 parent ed126ac commit b5cf16b

7 files changed

Lines changed: 282 additions & 30 deletions

File tree

src/renderer/src/components/SkipToMainContentLink.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ export const SkipToMainContentLink = memo(
3838
href="#main-content"
3939
className="sr-only focus:not-sr-only focus:fixed focus:top-0 focus:left-0 focus:z-50 focus:bg-primary focus:text-primary-foreground focus:p-2"
4040
>
41-
[Press Enter] Skip focus to main content
41+
Skip to main content
4242
</a>
4343
)
4444
},

src/renderer/src/components/skills/FileContent.browser.test.tsx

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,73 @@ describe('FileContent Markdown modes', () => {
6767
await expect.element(screen.getByText('Visible body')).toBeInTheDocument()
6868
})
6969

70+
it('renders language-less code fences as block code without AST attributes', async () => {
71+
const { FileContent } = await import('./FileContent')
72+
const screen = await render(
73+
<FileContent
74+
content={makeTextContent({
75+
content:
76+
'# Skill\n\n```\nnpx skills list --json\n```\n\nInline `skill` stays compact.',
77+
})}
78+
/>,
79+
)
80+
81+
await screen.getByRole('radio', { name: /Show rendered Markdown/i }).click()
82+
83+
const blockCode = screen.getByText(/npx skills list --json/).query()
84+
const inlineCode = screen.getByText('skill', { exact: true }).query()
85+
86+
expect(blockCode).toBeInstanceOf(HTMLElement)
87+
expect(inlineCode).toBeInstanceOf(HTMLElement)
88+
expect(blockCode?.closest('pre')).toBeInstanceOf(HTMLPreElement)
89+
expect(inlineCode?.closest('pre')).toBeNull()
90+
expect(screen.container.querySelector('[node]')).toBeNull()
91+
})
92+
93+
it('renders language-tagged code fences as block code', async () => {
94+
const { FileContent } = await import('./FileContent')
95+
const screen = await render(
96+
<FileContent
97+
content={makeTextContent({
98+
content: '# Skill\n\n```ts\nconst ok = true\n```',
99+
})}
100+
/>,
101+
)
102+
103+
await screen.getByRole('radio', { name: /Show rendered Markdown/i }).click()
104+
105+
const blockCode = screen.getByText(/const ok = true/).query()
106+
107+
expect(blockCode).toBeInstanceOf(HTMLElement)
108+
expect(blockCode?.closest('pre')).toBeInstanceOf(HTMLPreElement)
109+
})
110+
111+
it('locks Reading Mode to vertical scrolling when Markdown is wider than the pane', async () => {
112+
const { FileContent } = await import('./FileContent')
113+
const wideInline = 'very-long-inline-token-'.repeat(30)
114+
const wideBlock = 'wide command '.repeat(40)
115+
const screen = await render(
116+
<FileContent
117+
content={makeTextContent({
118+
content: `# Wide\n\n\`${wideInline}\`\n\n\`\`\`\n${wideBlock}\n\`\`\``,
119+
})}
120+
/>,
121+
)
122+
123+
await screen.getByRole('radio', { name: /Show rendered Markdown/i }).click()
124+
125+
const scrollPane = screen.container.querySelector(
126+
'[data-markdown-reading-scroll]',
127+
)
128+
expect(scrollPane).toBeInstanceOf(HTMLElement)
129+
const pane = scrollPane as HTMLElement
130+
131+
// Programmatic scroll mirrors trackpad horizontal gestures in the renderer.
132+
expect(pane.scrollWidth).toBeGreaterThan(pane.clientWidth)
133+
pane.scrollTo({ left: 240 })
134+
expect(pane.scrollLeft).toBe(0)
135+
})
136+
70137
it('adds a bottom spacer after source code so the final line can breathe', async () => {
71138
const { FileContent } = await import('./FileContent')
72139
const screen = await render(

src/renderer/src/components/skills/FileContent.tsx

Lines changed: 87 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -71,11 +71,12 @@ const TextPreview = React.memo(function TextPreview({
7171
file,
7272
}: TextPreviewProps): React.ReactElement {
7373
const isMarkdown = isMarkdownPreview(file)
74+
const fileIdentity = `${file.name}:${file.extension}`
7475
const [mode, setMode] = useState<TextPreviewMode>('code')
7576

7677
useEffect(() => {
7778
setMode('code')
78-
}, [file.name, file.extension])
79+
}, [fileIdentity])
7980

8081
const handleModeChange = (next: string): void => {
8182
// Radix emits an empty string when the active item is clicked again.
@@ -249,8 +250,11 @@ const MarkdownReadingPreview = React.memo(function MarkdownReadingPreview({
249250
)
250251

251252
return (
252-
<div className="flex-1 min-h-0 overflow-auto bg-background">
253-
<article className="max-w-none px-7 py-6 pb-10 text-sm leading-7 text-foreground">
253+
<div
254+
className="flex-1 min-h-0 overflow-y-auto overflow-x-hidden bg-background"
255+
data-markdown-reading-scroll
256+
>
257+
<article className="min-w-0 max-w-full break-words px-7 py-6 pb-10 text-sm leading-7 text-foreground">
254258
<ReactMarkdown
255259
remarkPlugins={[remarkGfm]}
256260
components={markdownComponents}
@@ -298,11 +302,49 @@ function looksLikeYamlFrontmatter(rawBlock: string): boolean {
298302
})
299303
}
300304

305+
/**
306+
* Remove react-markdown's internal AST prop before DOM spreading.
307+
* @param props - Component override props from react-markdown.
308+
* @returns The same props without the non-DOM `node` field.
309+
* @example
310+
* omitMarkdownNode({ node: astNode, href: '/docs' }) // => { href: '/docs' }
311+
*/
312+
function omitMarkdownNode<Props extends { node?: unknown }>(
313+
props: Props,
314+
): Omit<Props, 'node'> {
315+
const { node, ...domProps } = props
316+
// `node` is useful for custom renderers, but invalid on real DOM elements.
317+
void node
318+
return domProps
319+
}
320+
321+
/**
322+
* Detect code blocks produced by react-markdown.
323+
* @param children - Rendered code text from the Markdown AST.
324+
* @param className - Optional `language-*` class from a fenced code info string.
325+
* @returns True for fenced/indented code blocks, including fences with no language.
326+
* @example
327+
* isMarkdownCodeBlock('pnpm validate\n') // => true
328+
*/
329+
function isMarkdownCodeBlock(
330+
children: React.ReactNode,
331+
className?: string,
332+
): boolean {
333+
// Language-tagged fences are always block code.
334+
if (className?.includes('language-')) return true
335+
336+
// Guard the react-markdown v10 newline heuristic with browser tests: block
337+
// code keeps a trailing newline, while inline code does not.
338+
return typeof children === 'string' && children.endsWith('\n')
339+
}
340+
301341
const markdownComponents: Components = {
302342
a({ children, className, href, ...props }) {
343+
const domProps = omitMarkdownNode(props)
344+
303345
return (
304346
<a
305-
{...props}
347+
{...domProps}
306348
href={href}
307349
target="_blank"
308350
rel="noreferrer"
@@ -316,9 +358,11 @@ const markdownComponents: Components = {
316358
)
317359
},
318360
blockquote({ children, className, ...props }) {
361+
const domProps = omitMarkdownNode(props)
362+
319363
return (
320364
<blockquote
321-
{...props}
365+
{...domProps}
322366
className={cn(
323367
'my-4 border-l-2 border-primary/60 pl-4 text-muted-foreground',
324368
className,
@@ -329,11 +373,12 @@ const markdownComponents: Components = {
329373
)
330374
},
331375
code({ children, className, ...props }) {
332-
const isBlock = className?.includes('language-') ?? false
333-
if (isBlock) {
376+
const domProps = omitMarkdownNode(props)
377+
378+
if (isMarkdownCodeBlock(children, className)) {
334379
return (
335380
<code
336-
{...props}
381+
{...domProps}
337382
className={cn(
338383
'block overflow-x-auto rounded-md border border-border bg-muted px-3 py-2 font-mono text-xs leading-6 text-foreground',
339384
className,
@@ -346,7 +391,7 @@ const markdownComponents: Components = {
346391

347392
return (
348393
<code
349-
{...props}
394+
{...domProps}
350395
className={cn(
351396
'rounded bg-muted px-1.5 py-0.5 font-mono text-[0.85em] text-foreground',
352397
className,
@@ -357,9 +402,11 @@ const markdownComponents: Components = {
357402
)
358403
},
359404
h1({ children, className, ...props }) {
405+
const domProps = omitMarkdownNode(props)
406+
360407
return (
361408
<h1
362-
{...props}
409+
{...domProps}
363410
className={cn(
364411
'mb-4 mt-0 border-b border-border pb-3 text-2xl font-semibold leading-tight',
365412
className,
@@ -370,9 +417,11 @@ const markdownComponents: Components = {
370417
)
371418
},
372419
h2({ children, className, ...props }) {
420+
const domProps = omitMarkdownNode(props)
421+
373422
return (
374423
<h2
375-
{...props}
424+
{...domProps}
376425
className={cn(
377426
'mb-3 mt-7 border-b border-border/70 pb-2 text-xl font-semibold leading-tight',
378427
className,
@@ -383,48 +432,60 @@ const markdownComponents: Components = {
383432
)
384433
},
385434
h3({ children, className, ...props }) {
435+
const domProps = omitMarkdownNode(props)
436+
386437
return (
387438
<h3
388-
{...props}
439+
{...domProps}
389440
className={cn('mb-2 mt-6 text-base font-semibold', className)}
390441
>
391442
{children}
392443
</h3>
393444
)
394445
},
395446
li({ children, className, ...props }) {
447+
const domProps = omitMarkdownNode(props)
448+
396449
return (
397-
<li {...props} className={cn('my-1 pl-1', className)}>
450+
<li {...domProps} className={cn('my-1 pl-1', className)}>
398451
{children}
399452
</li>
400453
)
401454
},
402455
ol({ children, className, ...props }) {
456+
const domProps = omitMarkdownNode(props)
457+
403458
return (
404-
<ol {...props} className={cn('my-4 list-decimal pl-6', className)}>
459+
<ol {...domProps} className={cn('my-4 list-decimal pl-6', className)}>
405460
{children}
406461
</ol>
407462
)
408463
},
409464
p({ children, className, ...props }) {
465+
const domProps = omitMarkdownNode(props)
466+
410467
return (
411-
<p {...props} className={cn('my-3', className)}>
468+
<p {...domProps} className={cn('my-3', className)}>
412469
{children}
413470
</p>
414471
)
415472
},
416473
pre({ children, className, ...props }) {
474+
const domProps = omitMarkdownNode(props)
475+
417476
return (
418-
<pre {...props} className={cn('my-4', className)}>
477+
<pre {...domProps} className={cn('my-4', className)}>
419478
{children}
420479
</pre>
421480
)
422481
},
423482
table({ children, className, ...props }) {
483+
const domProps = omitMarkdownNode(props)
484+
424485
return (
425486
<div className="my-4 overflow-x-auto rounded-md border border-border">
426487
<table
427-
{...props}
488+
{...domProps}
428489
className={cn('w-full border-collapse text-left text-sm', className)}
429490
>
430491
{children}
@@ -433,19 +494,23 @@ const markdownComponents: Components = {
433494
)
434495
},
435496
td({ children, className, ...props }) {
497+
const domProps = omitMarkdownNode(props)
498+
436499
return (
437500
<td
438-
{...props}
501+
{...domProps}
439502
className={cn('border-t border-border px-3 py-2 align-top', className)}
440503
>
441504
{children}
442505
</td>
443506
)
444507
},
445508
th({ children, className, ...props }) {
509+
const domProps = omitMarkdownNode(props)
510+
446511
return (
447512
<th
448-
{...props}
513+
{...domProps}
449514
className={cn(
450515
'border-b border-border bg-muted px-3 py-2 font-semibold',
451516
className,
@@ -456,8 +521,10 @@ const markdownComponents: Components = {
456521
)
457522
},
458523
ul({ children, className, ...props }) {
524+
const domProps = omitMarkdownNode(props)
525+
459526
return (
460-
<ul {...props} className={cn('my-4 list-disc pl-6', className)}>
527+
<ul {...domProps} className={cn('my-4 list-disc pl-6', className)}>
461528
{children}
462529
</ul>
463530
)

src/renderer/src/components/skills/filePreviewLanguage.test.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,29 @@ describe('filePreviewLanguage', () => {
1010
it('maps common skill file extensions to Shiki language ids', () => {
1111
expect(languageFromExtension('.ts')).toBe('typescript')
1212
expect(languageFromExtension('.tsx')).toBe('tsx')
13+
expect(languageFromExtension('.TS')).toBe('typescript')
14+
expect(languageFromExtension('.TSX')).toBe('tsx')
15+
expect(languageFromExtension('ts')).toBe('typescript')
1316
expect(languageFromExtension('.env.example')).toBe('dotenv')
1417
expect(languageFromExtension('.svg')).toBe('xml')
18+
expect(languageFromExtension('')).toBeUndefined()
19+
expect(languageFromExtension(null)).toBeUndefined()
20+
expect(languageFromExtension(undefined)).toBeUndefined()
21+
})
22+
23+
it('maps known file extensions to correct Shiki language IDs for preview', () => {
24+
expect(languageForPreview({ name: 'file.ts', extension: '.ts' })).toBe(
25+
'typescript',
26+
)
27+
expect(languageForPreview({ name: 'file.tsx', extension: '.tsx' })).toBe(
28+
'tsx',
29+
)
30+
expect(languageForPreview({ name: 'file.js', extension: '.js' })).toBe(
31+
'javascript',
32+
)
33+
expect(languageForPreview({ name: 'file.json', extension: '.json' })).toBe(
34+
'json',
35+
)
1536
})
1637

1738
it('falls back to safe text highlighting for unknown extensions', () => {
@@ -25,8 +46,26 @@ describe('filePreviewLanguage', () => {
2546

2647
it('detects Markdown files for Reading Mode', () => {
2748
expect(isMarkdownPreview({ name: 'SKILL.md', extension: '.md' })).toBe(true)
49+
expect(
50+
isMarkdownPreview({ name: 'README.markdown', extension: '.markdown' }),
51+
).toBe(true)
52+
expect(
53+
isMarkdownPreview({ name: 'README.mdown', extension: '.mdown' }),
54+
).toBe(true)
55+
expect(isMarkdownPreview({ name: 'SKILL.MD', extension: '.MD' })).toBe(true)
56+
expect(isMarkdownPreview({ name: 'Skill.Md', extension: '.Md' })).toBe(true)
57+
expect(isMarkdownPreview({ name: 'SKILL.md', extension: 'md' })).toBe(true)
58+
expect(isMarkdownPreview({ name: 'README', extension: '' })).toBe(true)
2859
expect(
2960
isMarkdownPreview({ name: 'component.tsx', extension: '.tsx' }),
3061
).toBe(false)
62+
expect(
63+
isMarkdownPreview({ name: 'readme.md.txt', extension: '.txt' }),
64+
).toBe(false)
65+
expect(isMarkdownPreview({ name: 'notes', extension: '' })).toBe(false)
66+
expect(isMarkdownPreview({ name: 'notes', extension: null })).toBe(false)
67+
expect(isMarkdownPreview({ name: 'notes', extension: undefined })).toBe(
68+
false,
69+
)
3170
})
3271
})

0 commit comments

Comments
 (0)