diff --git a/web-studio/src/routes/playground/route.tsx b/web-studio/src/routes/playground/route.tsx index e93323beb9..bd72459044 100644 --- a/web-studio/src/routes/playground/route.tsx +++ b/web-studio/src/routes/playground/route.tsx @@ -596,6 +596,7 @@ function PlaygroundWorkbench() { file={selectedFile} hideDirectoryHeader onClose={() => setSelectedFile(null)} + onNavigate={(uri) => void revealResource(uri)} showCloseButton={false} /> diff --git a/web-studio/src/routes/resources/-components/dir-browser.tsx b/web-studio/src/routes/resources/-components/dir-browser.tsx index 7a22ed7129..682d1f8e28 100644 --- a/web-studio/src/routes/resources/-components/dir-browser.tsx +++ b/web-studio/src/routes/resources/-components/dir-browser.tsx @@ -177,6 +177,7 @@ function DetailPane({ {}} + onNavigate={onOpenFile} showCloseButton={false} /> ) diff --git a/web-studio/src/routes/resources/-components/file-preview.test.tsx b/web-studio/src/routes/resources/-components/file-preview.test.tsx new file mode 100644 index 0000000000..e296a5e674 --- /dev/null +++ b/web-studio/src/routes/resources/-components/file-preview.test.tsx @@ -0,0 +1,91 @@ +// @vitest-environment jsdom + +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { fireEvent, render, screen } from '@testing-library/react' +import type { PropsWithChildren } from 'react' +import { describe, expect, it, vi } from 'vitest' + +import type { VikingFsEntry } from '../-types/viking-fm' +import { FilePreview } from './file-preview' + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})) + +vi.mock('#/gen/ov-client/client.gen', () => ({ + client: { + buildUrl: ({ query }: { query: { uri: string } }) => + `/api/v1/content/download?uri=${encodeURIComponent(query.uri)}`, + }, +})) + +vi.mock('#/lib/ov-client', () => ({ + getContentDownload: vi.fn(), + ovClient: { getOptions: () => ({ baseUrl: '' }) }, +})) + +vi.mock('../-hooks/viking-fm', () => ({ + useInvalidateVikingFs: () => ({ + invalidateList: vi.fn(), + invalidatePreview: vi.fn(), + invalidateTree: vi.fn(), + }), + useVikingFilePreview: () => ({ + canLoadContent: false, + isContentLoaded: true, + isFetching: false, + isLoading: false, + preview: { + content: '[Target](./target.md)', + fileType: 'markdown', + shouldAutoRead: true, + }, + refetch: vi.fn(), + }), + useVikingFsStat: () => ({ + data: undefined, + isLoading: false, + }), +})) + +const file: VikingFsEntry = { + abstract: '', + isDir: false, + modTime: '2026-08-04 12:00', + modTimestamp: null, + name: 'index.md', + overview: '', + size: '24 B', + sizeBytes: 24, + uri: 'viking://resources/wiki/index.md', +} + +describe('FilePreview Markdown links', () => { + it('opens internal Markdown links in the resource preview', () => { + const onNavigate = vi.fn() + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }) + const wrapper = ({ children }: PropsWithChildren) => ( + {children} + ) + + render( + , + { wrapper }, + ) + + const link = screen.getByRole('link', { name: 'Target' }) + expect(link.getAttribute('href')).toBe('viking://resources/wiki/target.md') + + fireEvent.click(link) + + expect(onNavigate).toHaveBeenCalledOnce() + expect(onNavigate).toHaveBeenCalledWith('viking://resources/wiki/target.md') + }) +}) diff --git a/web-studio/src/routes/resources/-components/file-preview.tsx b/web-studio/src/routes/resources/-components/file-preview.tsx index dba314d7c4..bebd3ac940 100644 --- a/web-studio/src/routes/resources/-components/file-preview.tsx +++ b/web-studio/src/routes/resources/-components/file-preview.tsx @@ -106,6 +106,7 @@ interface FilePreviewProps { file: VikingFsEntry | null hideDirectoryHeader?: boolean onClose: () => void + onNavigate?: (uri: string) => void showCloseButton?: boolean } @@ -1024,6 +1025,7 @@ export function FilePreview({ file, hideDirectoryHeader = false, onClose, + onNavigate, showCloseButton = true, }: FilePreviewProps) { const { t } = useTranslation('resources') @@ -1598,15 +1600,28 @@ export function FilePreview({ /> ), a: ({ href, children }) => { - const resolvedHref = href - ? resolveMarkdownAssetUrl(String(href), file.uri) - : String(href || '') + const target = href + ? resolveMarkdownAssetTarget(String(href), file.uri) + : null + const isInternal = + target?.kind === 'viking' && Boolean(onNavigate) + const resolvedHref = target + ? isInternal + ? target.value + : resolveMarkdownAssetUrl(target.value, file.uri) + : '' const isExternal = /^(https?:|mailto:|tel:)/i.test( resolvedHref, ) return ( { + if (target?.kind === 'viking' && onNavigate) { + event.preventDefault() + onNavigate(target.value) + } + }} target={isExternal ? '_blank' : undefined} rel={isExternal ? 'noreferrer noopener' : undefined} > diff --git a/web-studio/src/routes/resources/-components/find-palette.tsx b/web-studio/src/routes/resources/-components/find-palette.tsx index 2aaaf7829c..1257c4dd0f 100644 --- a/web-studio/src/routes/resources/-components/find-palette.tsx +++ b/web-studio/src/routes/resources/-components/find-palette.tsx @@ -538,6 +538,10 @@ export function FindPalette({ setIndex(-1)} + onNavigate={(uri) => { + onNavigate(uri) + onClose() + }} showCloseButton={false} /> diff --git a/web-studio/src/routes/resources/-components/lazy-file-preview.tsx b/web-studio/src/routes/resources/-components/lazy-file-preview.tsx index a53fcf55e7..95cdc6581a 100644 --- a/web-studio/src/routes/resources/-components/lazy-file-preview.tsx +++ b/web-studio/src/routes/resources/-components/lazy-file-preview.tsx @@ -13,11 +13,13 @@ export function LazyFilePreview({ file, hideDirectoryHeader, onClose, + onNavigate, showCloseButton, }: { file: VikingFsEntry | null hideDirectoryHeader?: boolean onClose: () => void + onNavigate?: (uri: string) => void showCloseButton?: boolean }) { return ( @@ -32,6 +34,7 @@ export function LazyFilePreview({ file={file} hideDirectoryHeader={hideDirectoryHeader} onClose={onClose} + onNavigate={onNavigate} showCloseButton={showCloseButton} />