Merge pull request #2863 from ChangeHow/codex/markdown-image-gallery
feat: add assistant Markdown image galleries
This commit is contained in:
@@ -0,0 +1,115 @@
|
||||
import React from 'react';
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
|
||||
import type { ToolPopupContent } from './message/types';
|
||||
import {
|
||||
extractMarkdownImageCandidates,
|
||||
MAX_MARKDOWN_IMAGE_COUNT,
|
||||
type MarkdownImageCandidate,
|
||||
} from './markdown/markdownCore';
|
||||
import { resolveMarkdownImageSource } from './markdown/markdownImageAssets';
|
||||
|
||||
const MarkdownImageThumbnail: React.FC<{
|
||||
candidate: MarkdownImageCandidate;
|
||||
directory: string;
|
||||
onShowPopup?: (content: ToolPopupContent) => void;
|
||||
}> = ({ candidate, directory, onShowPopup }) => {
|
||||
const [image, setImage] = React.useState<{
|
||||
url: string;
|
||||
status: 'loading' | 'ready' | 'error';
|
||||
}>({ url: '', status: 'loading' });
|
||||
|
||||
React.useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
setImage({ url: '', status: 'loading' });
|
||||
void resolveMarkdownImageSource(candidate.source, directory, controller.signal)
|
||||
.then((url) => {
|
||||
if (!controller.signal.aborted) setImage({ url, status: 'loading' });
|
||||
})
|
||||
.catch(() => {
|
||||
if (!controller.signal.aborted) setImage({ url: '', status: 'error' });
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [candidate.source, directory]);
|
||||
|
||||
const openPreview = React.useCallback(() => {
|
||||
if (image.status !== 'ready' || !onShowPopup) return;
|
||||
onShowPopup({
|
||||
open: true,
|
||||
title: candidate.filename,
|
||||
content: '',
|
||||
metadata: { tool: 'markdown-image-preview', filename: candidate.filename },
|
||||
image: { url: image.url, filename: candidate.filename },
|
||||
});
|
||||
}, [candidate.filename, image, onShowPopup]);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="w-[100px] shrink-0 text-left outline-none focus-visible:ring-2 focus-visible:ring-[var(--interactive-focus-ring)]"
|
||||
aria-label={candidate.filename}
|
||||
disabled={image.status !== 'ready'}
|
||||
onClick={openPreview}
|
||||
data-openchamber-markdown-image-action="true"
|
||||
data-openchamber-markdown-image-source={candidate.source}
|
||||
data-openchamber-markdown-image-filename={candidate.filename}
|
||||
>
|
||||
<span className="flex h-[72px] w-[100px] items-center justify-center overflow-hidden rounded-lg border border-border/40 bg-muted/10">
|
||||
{image.url && image.status !== 'error' ? (
|
||||
<img
|
||||
src={image.url}
|
||||
alt={candidate.filename}
|
||||
className="h-full w-full object-contain"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
referrerPolicy="no-referrer"
|
||||
onLoad={() => setImage((current) => ({ ...current, status: 'ready' }))}
|
||||
onError={() => setImage({ url: '', status: 'error' })}
|
||||
data-openchamber-markdown-image="true"
|
||||
data-openchamber-markdown-image-thumbnail="true"
|
||||
data-openchamber-markdown-image-state={image.status}
|
||||
/>
|
||||
) : (
|
||||
<Icon name="file-image" className="h-5 w-5 text-muted-foreground" />
|
||||
)}
|
||||
</span>
|
||||
<span
|
||||
className="mt-1 flex w-[100px] items-center justify-center gap-1 text-muted-foreground"
|
||||
title={candidate.filename}
|
||||
data-openchamber-markdown-image-caption="true"
|
||||
>
|
||||
<Icon name="file-image" className="h-3 w-3 shrink-0" />
|
||||
<span className="min-w-0 truncate typography-meta">{candidate.filename}</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export const MarkdownImageGallery: React.FC<{
|
||||
contents: readonly string[];
|
||||
onShowPopup?: (content: ToolPopupContent) => void;
|
||||
}> = ({ contents, onShowPopup }) => {
|
||||
const directory = useEffectiveDirectory() ?? '';
|
||||
const candidates = React.useMemo(
|
||||
() => extractMarkdownImageCandidates(contents, MAX_MARKDOWN_IMAGE_COUNT),
|
||||
[contents],
|
||||
);
|
||||
|
||||
if (candidates.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="mt-3 flex max-w-full gap-2 overflow-x-auto pb-1"
|
||||
data-openchamber-markdown-image-gallery="true"
|
||||
>
|
||||
{candidates.map((candidate) => (
|
||||
<MarkdownImageThumbnail
|
||||
key={candidate.source}
|
||||
candidate={candidate}
|
||||
directory={directory}
|
||||
onShowPopup={onShowPopup}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -17,6 +17,10 @@ const SimpleMarkdownRendererLazy = lazyWithChunkRecovery(() =>
|
||||
loadMarkdownRendererModule().then((m) => ({ default: m.SimpleMarkdownRenderer }))
|
||||
);
|
||||
|
||||
const MarkdownImageGalleryLazy = lazyWithChunkRecovery(() =>
|
||||
import('./MarkdownImageGallery').then((m) => ({ default: m.MarkdownImageGallery }))
|
||||
);
|
||||
|
||||
const fallback = <div className="break-words w-full min-w-0" />;
|
||||
|
||||
const fallbackContentClassName = (variant: unknown): string => {
|
||||
@@ -48,3 +52,9 @@ export const SimpleMarkdownRenderer: React.FC<React.ComponentPropsWithoutRef<typ
|
||||
<SimpleMarkdownRendererLazy {...props} />
|
||||
</React.Suspense>
|
||||
);
|
||||
|
||||
export const MarkdownImageGallery: React.FC<React.ComponentPropsWithoutRef<typeof MarkdownImageGalleryLazy>> = (props) => (
|
||||
<React.Suspense fallback={null}>
|
||||
<MarkdownImageGalleryLazy {...props} />
|
||||
</React.Suspense>
|
||||
);
|
||||
|
||||
@@ -19,7 +19,8 @@ import { isDesktopLocalOriginActive, isDesktopShell, isVSCodeRuntime } from '@/l
|
||||
import { isMobileSurfaceRuntime } from '@/lib/runtimeSurface';
|
||||
import { ensureOutsideFileGrantForDesktop } from '@/lib/outsideFileGrants';
|
||||
import { getDirectoryForFilePath, isFilePathWithinDirectory, toAbsoluteFilePath } from '@/lib/path-utils';
|
||||
import { renderMarkdownBlocks, renderMarkdownSync } from './markdown/markdownCore';
|
||||
import { getMarkdownImageFilename, renderMarkdownBlocks, renderMarkdownSync } from './markdown/markdownCore';
|
||||
import { resolveMarkdownImageSource } from './markdown/markdownImageAssets';
|
||||
import { ensureMarkdownShikiTheme } from './markdown/markdownTheme';
|
||||
import { getMarkdownSyntaxVars } from './markdown/markdownSyntaxVars';
|
||||
import {
|
||||
@@ -107,6 +108,59 @@ const useExternalLinkInteractions = ({
|
||||
}, [containerRef, enabled]);
|
||||
};
|
||||
|
||||
const useMarkdownImageLinkInteractions = ({
|
||||
containerRef,
|
||||
directory,
|
||||
enabled,
|
||||
onShowPopup,
|
||||
}: {
|
||||
containerRef: React.RefObject<HTMLDivElement | null>;
|
||||
directory: string;
|
||||
enabled: boolean;
|
||||
onShowPopup?: (content: ToolPopupContent) => void;
|
||||
}) => {
|
||||
React.useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!enabled || !container || !onShowPopup) return;
|
||||
|
||||
const controller = new AbortController();
|
||||
const handleClick = (event: MouseEvent) => {
|
||||
if (event.defaultPrevented || event.button !== 0) return;
|
||||
const target = event.target;
|
||||
if (!(target instanceof Element)) return;
|
||||
|
||||
const link = target.closest<HTMLAnchorElement>('[data-openchamber-markdown-image-link="true"]');
|
||||
if (!link || !container.contains(link)) return;
|
||||
|
||||
const source = link.getAttribute('data-openchamber-markdown-image-source') ?? '';
|
||||
const filename = link.getAttribute('data-openchamber-markdown-image-filename')
|
||||
|| getMarkdownImageFilename(source, '');
|
||||
if (!source || !filename) return;
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
void resolveMarkdownImageSource(source, directory, controller.signal)
|
||||
.then((url) => {
|
||||
if (controller.signal.aborted || !link.isConnected) return;
|
||||
onShowPopup({
|
||||
open: true,
|
||||
title: filename,
|
||||
content: '',
|
||||
metadata: { tool: 'markdown-image-preview', filename },
|
||||
image: { url, filename },
|
||||
});
|
||||
})
|
||||
.catch(() => undefined);
|
||||
};
|
||||
|
||||
container.addEventListener('click', handleClick);
|
||||
return () => {
|
||||
controller.abort();
|
||||
container.removeEventListener('click', handleClick);
|
||||
};
|
||||
}, [containerRef, directory, enabled, onShowPopup]);
|
||||
};
|
||||
|
||||
const DEFAULT_MERMAID_CONTROLS: MermaidControlOptions = {
|
||||
download: true,
|
||||
copy: true,
|
||||
@@ -140,6 +194,7 @@ interface MarkdownRendererProps {
|
||||
variant?: MarkdownVariant;
|
||||
onShowPopup?: (content: ToolPopupContent) => void;
|
||||
enableFileReferences?: boolean;
|
||||
enableLocalImages?: boolean;
|
||||
}
|
||||
|
||||
const FILE_LINK_SELECTOR = '[data-openchamber-file-link="true"]';
|
||||
@@ -497,6 +552,10 @@ const useFileReferenceInteractions = ({
|
||||
let linkedCount = 0;
|
||||
|
||||
for (const candidate of Array.from(candidates)) {
|
||||
if (candidate.matches('[data-openchamber-markdown-image-link="true"]')) {
|
||||
clearFileLinkAttributes(candidate);
|
||||
continue;
|
||||
}
|
||||
const rawCandidate = extractPathCandidateFromElement(candidate);
|
||||
const resolved = getResolvedReference(rawCandidate, effectiveDirectory);
|
||||
clearFileLinkAttributes(candidate);
|
||||
@@ -831,6 +890,7 @@ const useMorphdomMarkdown = ({
|
||||
text,
|
||||
streaming,
|
||||
cacheKey,
|
||||
deferImages = false,
|
||||
syntaxVars,
|
||||
ctx,
|
||||
}: {
|
||||
@@ -838,6 +898,7 @@ const useMorphdomMarkdown = ({
|
||||
text: string;
|
||||
streaming: boolean;
|
||||
cacheKey: string;
|
||||
deferImages?: boolean;
|
||||
syntaxVars: Record<string, string>;
|
||||
ctx: DecorateContext;
|
||||
}) => {
|
||||
@@ -876,7 +937,7 @@ const useMorphdomMarkdown = ({
|
||||
// `display:contents` keeps margin-collapsing/spacing identical to a flat
|
||||
// HTML body — the wrapper exists only for per-block reconciliation.
|
||||
block.style.display = 'contents';
|
||||
block.innerHTML = renderMarkdownSync(text);
|
||||
block.innerHTML = renderMarkdownSync(text, deferImages);
|
||||
// Decorate synchronously too: wrap code blocks in their framed card,
|
||||
// mark inline code, build table controls, etc. The async pass re-decorates
|
||||
// its own DOM before morphing, so without this the first paint shows bare
|
||||
@@ -888,7 +949,7 @@ const useMorphdomMarkdown = ({
|
||||
refreshMermaidViewers();
|
||||
}
|
||||
}
|
||||
}, [containerRef, text, ctx, refreshMermaidViewers]);
|
||||
}, [containerRef, text, deferImages, ctx, refreshMermaidViewers]);
|
||||
|
||||
React.useEffect(() => () => {
|
||||
mermaidViewerRef.current?.cleanup();
|
||||
@@ -901,7 +962,7 @@ const useMorphdomMarkdown = ({
|
||||
const target = container.querySelector<HTMLElement>('[data-markdown-content]') ?? container;
|
||||
let active = true;
|
||||
|
||||
void renderMarkdownBlocks(text, streaming, cacheKey).then((blocks) => {
|
||||
void renderMarkdownBlocks(text, streaming, cacheKey, deferImages).then((blocks) => {
|
||||
if (!active) return;
|
||||
const existing = Array.from(target.children) as HTMLElement[];
|
||||
|
||||
@@ -952,7 +1013,7 @@ const useMorphdomMarkdown = ({
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [containerRef, text, streaming, cacheKey, ctx, refreshMermaidViewers]);
|
||||
}, [containerRef, text, streaming, cacheKey, deferImages, ctx, refreshMermaidViewers]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
@@ -999,6 +1060,7 @@ const MarkdownRendererImpl: React.FC<MarkdownRendererProps> = ({
|
||||
variant = 'assistant',
|
||||
onShowPopup,
|
||||
enableFileReferences = true,
|
||||
enableLocalImages = false,
|
||||
}) => {
|
||||
streamPerfCount('ui.markdown_renderer.render');
|
||||
if (isStreaming) streamPerfCount('ui.markdown_renderer.render.streaming');
|
||||
@@ -1030,15 +1092,33 @@ const MarkdownRendererImpl: React.FC<MarkdownRendererProps> = ({
|
||||
enabled: enableFileReferences && !isStreaming,
|
||||
});
|
||||
useExternalLinkInteractions({ containerRef });
|
||||
useMarkdownImageLinkInteractions({
|
||||
containerRef,
|
||||
directory: effectiveDirectory,
|
||||
enabled: enableLocalImages && variant === 'assistant' && !isStreaming,
|
||||
onShowPopup,
|
||||
});
|
||||
|
||||
const syntaxVars = React.useMemo(() => getMarkdownSyntaxVars(currentTheme), [currentTheme]);
|
||||
const ctx = useDecorateContext(currentTheme, live, effectiveDirectory ? handlePreviewLoopback : undefined, DEFAULT_MERMAID_CONTROLS);
|
||||
const cacheKey = `markdown-${part?.id ? `part-${part.id}` : `message-${messageId}`}`;
|
||||
|
||||
useMorphdomMarkdown({ containerRef, text: content, streaming: live, cacheKey, syntaxVars, ctx });
|
||||
useMorphdomMarkdown({
|
||||
containerRef,
|
||||
text: content,
|
||||
streaming: live,
|
||||
cacheKey,
|
||||
deferImages: enableLocalImages && variant === 'assistant' && !isStreaming,
|
||||
syntaxVars,
|
||||
ctx,
|
||||
});
|
||||
|
||||
const markdownContent = (
|
||||
<div className={cn('break-words w-full min-w-0', className)} ref={containerRef}>
|
||||
<div
|
||||
className={cn('break-words w-full min-w-0', className)}
|
||||
ref={containerRef}
|
||||
data-openchamber-finalized-assistant-images={enableLocalImages && variant === 'assistant' && !isStreaming ? 'true' : undefined}
|
||||
>
|
||||
<div className={markdownContentClassName(variant)} data-markdown-content />
|
||||
</div>
|
||||
);
|
||||
@@ -1065,6 +1145,7 @@ export const MarkdownRenderer = React.memo(MarkdownRendererImpl, (prev, next) =>
|
||||
&& prev.messageId === next.messageId
|
||||
&& prev.onShowPopup === next.onShowPopup
|
||||
&& prev.enableFileReferences === next.enableFileReferences
|
||||
&& prev.enableLocalImages === next.enableLocalImages
|
||||
&& prev.part?.id === next.part?.id;
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,21 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { describe, expect, mock, test } from 'bun:test';
|
||||
|
||||
mock.module('dompurify', () => ({
|
||||
default: {
|
||||
isSupported: true,
|
||||
addHook: () => undefined,
|
||||
sanitize: (html: string) => html,
|
||||
},
|
||||
}));
|
||||
mock.module('./markdown-worker', () => ({
|
||||
highlightCodeInWorker: async () => null,
|
||||
}));
|
||||
|
||||
import { escapeRawMarkdownHtml, MARKDOWN_FORBIDDEN_TAGS } from './markdownSecurity';
|
||||
|
||||
const { extractMarkdownImageCandidates, renderMarkdownSync } = await import('./markdownCore');
|
||||
const { resolveMarkdownImageSource } = await import('./markdownImageAssets');
|
||||
|
||||
describe('markdown sanitization', () => {
|
||||
test('turns raw assistant HTML into inert visible text', () => {
|
||||
const payload = '<style>@import url("https://example.test/theme.css");</style>';
|
||||
@@ -16,3 +30,102 @@ describe('markdown sanitization', () => {
|
||||
expect(MARKDOWN_FORBIDDEN_TAGS).toContain('style');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Markdown images', () => {
|
||||
test('keeps local image links in text and emits inert image placeholders', () => {
|
||||
const html = renderMarkdownSync([
|
||||
'[linked image](packages/vscode/extension.jpg)',
|
||||
'',
|
||||
].join('\n\n'), true);
|
||||
|
||||
expect(html).toContain('data-openchamber-markdown-image-link="true"');
|
||||
expect(html.match(/data-openchamber-markdown-image-source="packages\/vscode\/extension.jpg"/g)).toHaveLength(1);
|
||||
expect(html).toContain('data-openchamber-markdown-image-placeholder="true"');
|
||||
expect(html).toContain('image syntax');
|
||||
expect(html).not.toContain('src="packages/vscode/extension.jpg"');
|
||||
expect(html).not.toContain('data-openchamber-markdown-image-state');
|
||||
expect(html.match(/<a /g)).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('keeps HTTP links as links and defers remote image tokens to finalized rendering', () => {
|
||||
const html = renderMarkdownSync([
|
||||
'[remote link](https://example.test/image.png)',
|
||||
'',
|
||||
].join('\n\n'), true);
|
||||
|
||||
expect(html).toContain('<a href="https://example.test/image.png"');
|
||||
expect(html).not.toContain('<img');
|
||||
expect(html).toContain('data-openchamber-markdown-image-placeholder="true"');
|
||||
expect(html).toContain('remote image');
|
||||
});
|
||||
|
||||
test('preserves file URLs inertly and never activates unknown schemes', () => {
|
||||
const html = renderMarkdownSync([
|
||||
'',
|
||||
')',
|
||||
].join('\n\n'), true);
|
||||
|
||||
expect(html).toContain('role="img"');
|
||||
expect(html).not.toContain('src="file:');
|
||||
expect(html).not.toContain('src="javascript:');
|
||||
});
|
||||
|
||||
test('collects a single ordered gallery across mixed Markdown and ignores code', () => {
|
||||
const candidates = extractMarkdownImageCandidates([
|
||||
[
|
||||
'Before [local link](screens/first%20view.png) and ``.',
|
||||
'',
|
||||
'- ',
|
||||
'- ',
|
||||
'',
|
||||
'```md',
|
||||
'',
|
||||
'```',
|
||||
].join('\n'),
|
||||
'After .',
|
||||
]);
|
||||
|
||||
expect(candidates).toEqual([
|
||||
{ source: 'screens/first%20view.png', filename: 'first view.png' },
|
||||
{ source: 'https://example.test/second.webp?size=2', filename: 'second.webp' },
|
||||
{ source: 'data:image/png;base64,AAAA', filename: 'third' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('limits one finalized message gallery to twelve unique candidates', () => {
|
||||
const markdown = Array.from({ length: 14 }, (_, index) => ``).join('\n');
|
||||
|
||||
const candidates = extractMarkdownImageCandidates([markdown]);
|
||||
|
||||
expect(candidates).toHaveLength(12);
|
||||
expect(candidates.at(-1)?.source).toBe('screens/11.png');
|
||||
});
|
||||
|
||||
test('validates embedded image bytes against the declared MIME type', async () => {
|
||||
const png = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==';
|
||||
const signal = new AbortController().signal;
|
||||
|
||||
expect(await resolveMarkdownImageSource(`data:image/png;base64,${png}`, '', signal)).toBe(`data:image/png;base64,${png}`);
|
||||
await resolveMarkdownImageSource(`data:image/jpeg;base64,${png}`, '', signal).then(
|
||||
() => { throw new Error('Expected mismatched image data to fail'); },
|
||||
(error: unknown) => expect((error as Error).message).toBe('Unsupported image data'),
|
||||
);
|
||||
});
|
||||
|
||||
test('does not resolve images after cancellation', async () => {
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
|
||||
await resolveMarkdownImageSource('https://example.test/image.png', '', controller.signal).then(
|
||||
() => { throw new Error('Expected an aborted image load to fail'); },
|
||||
(error: unknown) => expect((error as Error).name).toBe('AbortError'),
|
||||
);
|
||||
});
|
||||
|
||||
test('keeps the existing image renderer outside finalized assistant text', () => {
|
||||
const html = renderMarkdownSync('');
|
||||
|
||||
expect(html).toContain('<img src="https://example.test/image.png"');
|
||||
expect(html).not.toContain('data-openchamber-markdown-image-placeholder');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { marked, type Tokens } from 'marked';
|
||||
import { Marked, marked, type Tokens } from 'marked';
|
||||
import remend from 'remend';
|
||||
import katex from 'katex';
|
||||
import DOMPurify from 'dompurify';
|
||||
@@ -10,6 +10,95 @@ import { escapeRawMarkdownHtml, MARKDOWN_FORBIDDEN_TAGS } from './markdownSecuri
|
||||
const escapeAttr = (value: string): string =>
|
||||
value.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
||||
|
||||
const LOCAL_IMAGE_EXTENSION_RE = /\.(?:png|jpe?g|gif|webp)(?:[?#].*)?$/i;
|
||||
const WINDOWS_ABSOLUTE_PATH_RE = /^[A-Za-z]:[\\/]/;
|
||||
const URL_SCHEME_RE = /^[A-Za-z][A-Za-z\d+.-]*:/;
|
||||
|
||||
export interface MarkdownImageCandidate {
|
||||
source: string;
|
||||
filename: string;
|
||||
}
|
||||
|
||||
export const MAX_MARKDOWN_IMAGE_COUNT = 12;
|
||||
|
||||
const isLocalMarkdownImageSource = (source: string): boolean => {
|
||||
if (/^\/\//.test(source) || !LOCAL_IMAGE_EXTENSION_RE.test(source)) return false;
|
||||
return WINDOWS_ABSOLUTE_PATH_RE.test(source)
|
||||
|| /^file:\/\//i.test(source)
|
||||
|| !URL_SCHEME_RE.test(source);
|
||||
};
|
||||
|
||||
const isSupportedMarkdownImageSource = (source: string): boolean => (
|
||||
/^(?:https?:)?\/\//i.test(source)
|
||||
|| /^data:image\/(?:png|jpeg|gif|webp);base64,/i.test(source)
|
||||
|| isLocalMarkdownImageSource(source)
|
||||
);
|
||||
|
||||
export const getMarkdownImageFilename = (source: string, fallback: string): string => {
|
||||
if (/^data:/i.test(source)) return fallback.trim();
|
||||
|
||||
const path = source.split(/[?#]/, 1)[0]?.replace(/\\/g, '/') ?? '';
|
||||
const encodedName = path.split('/').filter(Boolean).at(-1) ?? '';
|
||||
if (!encodedName) return fallback.trim();
|
||||
try {
|
||||
return decodeURIComponent(encodedName);
|
||||
} catch {
|
||||
return encodedName;
|
||||
}
|
||||
};
|
||||
|
||||
export const extractMarkdownImageCandidates = (
|
||||
markdownTexts: readonly string[],
|
||||
limit = MAX_MARKDOWN_IMAGE_COUNT,
|
||||
): MarkdownImageCandidate[] => {
|
||||
if (limit <= 0) return [];
|
||||
|
||||
const candidates: MarkdownImageCandidate[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const markdown of markdownTexts) {
|
||||
if (!markdown || candidates.length >= limit) continue;
|
||||
const tokens = marked.lexer(markdown);
|
||||
marked.walkTokens(tokens, (token) => {
|
||||
if (candidates.length >= limit) return;
|
||||
|
||||
if (token.type !== 'image' && token.type !== 'link') return;
|
||||
if (token.type === 'link' && !isLocalMarkdownImageSource(token.href ?? '')) return;
|
||||
|
||||
const source = token.href ?? '';
|
||||
if (!source || !isSupportedMarkdownImageSource(source) || seen.has(source)) return;
|
||||
const fallback = typeof token.text === 'string' ? token.text : '';
|
||||
const filename = getMarkdownImageFilename(source, fallback);
|
||||
if (!filename) return;
|
||||
|
||||
seen.add(source);
|
||||
candidates.push({ source, filename });
|
||||
});
|
||||
}
|
||||
|
||||
return candidates;
|
||||
};
|
||||
|
||||
const renderMarkdownImage = ({
|
||||
href,
|
||||
title,
|
||||
text,
|
||||
}: {
|
||||
href: string;
|
||||
title?: string | null;
|
||||
text: string;
|
||||
}): string => {
|
||||
const source = href ?? '';
|
||||
const alt = escapeAttr(text ?? '');
|
||||
const titleAttr = title ? ` title="${escapeAttr(title)}"` : '';
|
||||
const supported = isSupportedMarkdownImageSource(source);
|
||||
if (!supported) {
|
||||
return `<span role="img" aria-label="${alt}"${titleAttr}>${alt}</span>`;
|
||||
}
|
||||
|
||||
return `<span role="img" aria-label="${alt}"${titleAttr} data-openchamber-markdown-image-placeholder="true">${alt}</span>`;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Streaming block segmentation (port of OpenCode's markdown-stream)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -162,7 +251,7 @@ const blockMathExtension = {
|
||||
},
|
||||
};
|
||||
|
||||
const parser = marked.use({
|
||||
const createParser = (deferImages: boolean) => new Marked().use({
|
||||
gfm: true,
|
||||
breaks: false,
|
||||
extensions: [inlineMathExtension, blockMathExtension],
|
||||
@@ -175,6 +264,11 @@ const parser = marked.use({
|
||||
},
|
||||
link({ href, title, text }) {
|
||||
const target = href ?? '';
|
||||
if (deferImages && isLocalMarkdownImageSource(target)) {
|
||||
const titleAttr = title ? ` title="${escapeAttr(title)}"` : '';
|
||||
const filename = getMarkdownImageFilename(target, '');
|
||||
return `<a href="${escapeAttr(target)}"${titleAttr} class="external-link" target="_blank" rel="noopener noreferrer" data-openchamber-markdown-image-link="true" data-openchamber-markdown-image-source="${escapeAttr(target)}" data-openchamber-markdown-image-filename="${escapeAttr(filename)}">${text}</a>`;
|
||||
}
|
||||
const agentName = parseAgentHref(target);
|
||||
if (agentName) {
|
||||
return `<a href="${escapeAttr(buildAgentMentionUrl(agentName))}" data-openchamber-agent-mention="true" class="text-primary hover:underline" target="_blank" rel="noopener noreferrer">${text}</a>`;
|
||||
@@ -186,9 +280,13 @@ const parser = marked.use({
|
||||
const titleAttr = title ? ` title="${escapeAttr(title)}"` : '';
|
||||
return `<a href="${escapeAttr(target)}"${titleAttr} class="external-link" target="_blank" rel="noopener noreferrer">${text}</a>`;
|
||||
},
|
||||
...(deferImages ? { image: renderMarkdownImage } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
const parser = createParser(false);
|
||||
const imageParser = createParser(true);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Math (KaTeX) — post-process the parsed HTML, skipping code/pre/kbd content
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -341,8 +439,8 @@ const touch = (key: string, entry: { hash: string; html: string }): void => {
|
||||
if (oldest) htmlCache.delete(oldest);
|
||||
};
|
||||
|
||||
const parseBlock = async (block: MarkdownBlock): Promise<string> => {
|
||||
const parsed = await Promise.resolve(parser.parse(block.src));
|
||||
const parseBlock = async (block: MarkdownBlock, deferImages: boolean): Promise<string> => {
|
||||
const parsed = await Promise.resolve((deferImages ? imageParser : parser).parse(block.src));
|
||||
const withMath = renderMathExpressions(parsed);
|
||||
const highlighted = block.highlight ? await highlightCodeBlocks(withMath) : withMath;
|
||||
return sanitize(highlighted);
|
||||
@@ -357,9 +455,9 @@ const parseBlock = async (block: MarkdownBlock): Promise<string> => {
|
||||
* is synchronous (marked is not configured `async`), so this never blocks on a
|
||||
* worker round-trip.
|
||||
*/
|
||||
export const renderMarkdownSync = (text: string): string => {
|
||||
export const renderMarkdownSync = (text: string, deferImages = false): string => {
|
||||
if (!text) return '';
|
||||
const parsed = parser.parse(text) as string;
|
||||
const parsed = (deferImages ? imageParser : parser).parse(text) as string;
|
||||
const withMath = renderMathExpressions(parsed);
|
||||
return sanitize(withMath);
|
||||
};
|
||||
@@ -382,6 +480,7 @@ export const renderMarkdownBlocks = async (
|
||||
text: string,
|
||||
streaming: boolean,
|
||||
cacheKey: string,
|
||||
deferImages = false,
|
||||
): Promise<RenderedBlock[]> => {
|
||||
if (!text) return [];
|
||||
|
||||
@@ -389,14 +488,14 @@ export const renderMarkdownBlocks = async (
|
||||
return Promise.all(
|
||||
blocks.map(async (block, index) => {
|
||||
const contentHash = hash(block.raw);
|
||||
const id = `${contentHash}:${block.mode}:${block.highlight ? 1 : 0}`;
|
||||
const key = `${cacheKey}:${index}:${block.mode}`;
|
||||
const id = `${contentHash}:${block.mode}:${block.highlight ? 1 : 0}:${deferImages ? 1 : 0}`;
|
||||
const key = `${cacheKey}:${index}:${block.mode}:${deferImages ? 1 : 0}`;
|
||||
const cached = htmlCache.get(key);
|
||||
if (cached && cached.hash === contentHash) {
|
||||
touch(key, cached);
|
||||
return { id, html: cached.html };
|
||||
}
|
||||
const html = await parseBlock(block);
|
||||
const html = await parseBlock(block, deferImages);
|
||||
touch(key, { hash: contentHash, html });
|
||||
return { id, html };
|
||||
}),
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import { isFilePathWithinDirectory, toAbsoluteFilePath } from '@/lib/path-utils';
|
||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||
|
||||
const MAX_MARKDOWN_IMAGE_BYTES = 10 * 1024 * 1024;
|
||||
const SUPPORTED_IMAGE_MIME_TYPES = new Set([
|
||||
'image/png',
|
||||
'image/jpeg',
|
||||
'image/gif',
|
||||
'image/webp',
|
||||
]);
|
||||
|
||||
const parseLocalImagePath = (source: string): string => {
|
||||
let value = source;
|
||||
if (/^file:\/\//i.test(value)) {
|
||||
try {
|
||||
const fileUrl = new URL(value);
|
||||
if (fileUrl.protocol !== 'file:') return '';
|
||||
value = fileUrl.host && fileUrl.host !== 'localhost'
|
||||
? `//${fileUrl.host}${fileUrl.pathname}`
|
||||
: fileUrl.pathname;
|
||||
if (/^\/[A-Za-z]:\//.test(value)) value = value.slice(1);
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
const path = value.split(/[?#]/, 1)[0] ?? '';
|
||||
try {
|
||||
return decodeURIComponent(path);
|
||||
} catch {
|
||||
return path;
|
||||
}
|
||||
};
|
||||
|
||||
const blobToDataUrl = (blob: Blob): Promise<string> => new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => typeof reader.result === 'string'
|
||||
? resolve(reader.result)
|
||||
: reject(new Error('Unable to encode image'));
|
||||
reader.onerror = () => reject(reader.error ?? new Error('Unable to encode image'));
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
|
||||
const hasImageSignature = async (blob: Blob, mimeType: string): Promise<boolean> => {
|
||||
const bytes = new Uint8Array(await blob.slice(0, 12).arrayBuffer());
|
||||
const ascii = (start: number, end: number) => String.fromCharCode(...bytes.slice(start, end));
|
||||
if (mimeType === 'image/png') {
|
||||
return bytes[0] === 0x89 && ascii(1, 4) === 'PNG'
|
||||
&& bytes[4] === 0x0d && bytes[5] === 0x0a && bytes[6] === 0x1a && bytes[7] === 0x0a;
|
||||
}
|
||||
if (mimeType === 'image/jpeg') {
|
||||
return bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff;
|
||||
}
|
||||
if (mimeType === 'image/gif') {
|
||||
const gif = ascii(0, 6);
|
||||
return gif === 'GIF87a' || gif === 'GIF89a';
|
||||
}
|
||||
return mimeType === 'image/webp' && ascii(0, 4) === 'RIFF' && ascii(8, 12) === 'WEBP';
|
||||
};
|
||||
|
||||
const validateImageBlob = async (blob: Blob, mimeType: string): Promise<void> => {
|
||||
if (!SUPPORTED_IMAGE_MIME_TYPES.has(mimeType)) throw new Error('Unsupported image type');
|
||||
if (blob.size > MAX_MARKDOWN_IMAGE_BYTES) throw new Error('Image is too large');
|
||||
if (!await hasImageSignature(blob, mimeType)) throw new Error('Unsupported image data');
|
||||
};
|
||||
|
||||
const validateDataImage = async (source: string): Promise<void> => {
|
||||
const match = /^data:(image\/(?:png|jpeg|gif|webp));base64,([\s\S]*)$/i.exec(source);
|
||||
if (!match?.[1] || match[2] === undefined) throw new Error('Invalid image data URL');
|
||||
const encoded = match[2];
|
||||
if (encoded.length > Math.ceil(MAX_MARKDOWN_IMAGE_BYTES * 4 / 3) + 4) {
|
||||
throw new Error('Image is too large');
|
||||
}
|
||||
|
||||
let binary: string;
|
||||
try {
|
||||
binary = atob(encoded);
|
||||
} catch {
|
||||
throw new Error('Invalid image data URL');
|
||||
}
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index);
|
||||
await validateImageBlob(new Blob([bytes]), match[1].toLowerCase());
|
||||
};
|
||||
|
||||
export const resolveMarkdownImageSource = async (
|
||||
source: string,
|
||||
directory: string,
|
||||
signal: AbortSignal,
|
||||
): Promise<string> => {
|
||||
if (signal.aborted) throw new DOMException('Image load aborted', 'AbortError');
|
||||
if (/^(?:https?:)?\/\//i.test(source)) return source;
|
||||
|
||||
if (/^data:image\/(?:png|jpeg|gif|webp);base64,/i.test(source)) {
|
||||
await validateDataImage(source);
|
||||
if (signal.aborted) throw new DOMException('Image load aborted', 'AbortError');
|
||||
return source;
|
||||
}
|
||||
|
||||
const localPath = parseLocalImagePath(source);
|
||||
const absolutePath = toAbsoluteFilePath(directory, localPath);
|
||||
if (!directory || !localPath || !isFilePathWithinDirectory(absolutePath, directory)) {
|
||||
throw new Error('Image path is outside the active workspace');
|
||||
}
|
||||
|
||||
const statResponse = await runtimeFetch('/api/fs/stat', {
|
||||
query: { path: absolutePath, directory, optional: 'true' },
|
||||
signal,
|
||||
});
|
||||
if (!statResponse.ok) throw new Error(`Unable to inspect image (${statResponse.status})`);
|
||||
const stat = await statResponse.json() as { isFile?: boolean; size?: number };
|
||||
if (!stat.isFile) throw new Error('Image path is not a file');
|
||||
if (typeof stat.size === 'number' && stat.size > MAX_MARKDOWN_IMAGE_BYTES) {
|
||||
throw new Error('Image is too large');
|
||||
}
|
||||
|
||||
const response = await runtimeFetch('/api/fs/raw', {
|
||||
query: { path: absolutePath, directory },
|
||||
signal,
|
||||
});
|
||||
if (!response.ok) throw new Error(`Unable to load image (${response.status})`);
|
||||
|
||||
const mimeType = (response.headers.get('content-type') ?? '').split(';', 1)[0]?.toLowerCase() ?? '';
|
||||
const contentLength = Number(response.headers.get('content-length'));
|
||||
if (Number.isFinite(contentLength) && contentLength > MAX_MARKDOWN_IMAGE_BYTES) {
|
||||
throw new Error('Image is too large');
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
await validateImageBlob(blob, mimeType);
|
||||
return blobToDataUrl(blob);
|
||||
};
|
||||
@@ -21,7 +21,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip
|
||||
import { ArrowsMerge } from '@/components/icons/ArrowsMerge';
|
||||
import type { ContentChangeReason } from '@/hooks/useChatAutoFollow';
|
||||
|
||||
import { SimpleMarkdownRenderer } from '../MarkdownRenderer';
|
||||
import { MarkdownImageGallery, SimpleMarkdownRenderer } from '../MarkdownRenderer';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { flattenAssistantTextParts, suggestPlanTitleFromText } from '@/lib/messages/messageText';
|
||||
@@ -1211,6 +1211,11 @@ const AssistantMessageBody = React.memo(({
|
||||
const assistantTextParts = React.useMemo(() => {
|
||||
return visibleParts.filter((part) => part.type === 'text');
|
||||
}, [visibleParts]);
|
||||
const finalizedAssistantMarkdownContents = React.useMemo(() => (
|
||||
isMessageCompleted
|
||||
? assistantTextParts.map(extractTextContent).filter((text) => text.trim().length > 0)
|
||||
: []
|
||||
), [assistantTextParts, isMessageCompleted]);
|
||||
const assistantPlanText = React.useMemo(() => flattenAssistantTextParts(assistantTextParts), [assistantTextParts]);
|
||||
const suggestedPlanTitle = React.useMemo(() => suggestPlanTitleFromText(assistantPlanText), [assistantPlanText]);
|
||||
|
||||
@@ -1863,6 +1868,7 @@ const AssistantMessageBody = React.memo(({
|
||||
chatRenderMode={chatRenderMode}
|
||||
onContentChange={onContentChange}
|
||||
onShowPopup={onShowPopup}
|
||||
enableMarkdownImages={isMessageCompleted}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -2017,6 +2023,7 @@ const AssistantMessageBody = React.memo(({
|
||||
collapsedPreviewCount,
|
||||
expandedTools,
|
||||
isMobile,
|
||||
isMessageCompleted,
|
||||
isActivityOwnerMessage,
|
||||
isSortedRenderMode,
|
||||
lastRenderableTextPartIndex,
|
||||
@@ -2228,6 +2235,10 @@ const AssistantMessageBody = React.memo(({
|
||||
)}
|
||||
</div>
|
||||
<MessageFilesDisplay files={parts} onShowPopup={onShowPopup} />
|
||||
<MarkdownImageGallery
|
||||
contents={finalizedAssistantMarkdownContents}
|
||||
onShowPopup={onShowPopup}
|
||||
/>
|
||||
{shouldRenderStandaloneActionsAfterContent && (
|
||||
<div className={INLINE_MESSAGE_ACTIONS_CLASS_NAME} data-message-actions="true">
|
||||
<div className="flex items-center gap-1.5" data-message-action-group="true">
|
||||
|
||||
@@ -19,6 +19,7 @@ interface AssistantTextPartProps {
|
||||
chatRenderMode?: 'sorted' | 'live';
|
||||
onContentChange?: (reason?: ContentChangeReason, messageId?: string) => void;
|
||||
onShowPopup?: (content: ToolPopupContent) => void;
|
||||
enableMarkdownImages?: boolean;
|
||||
}
|
||||
|
||||
const AssistantTextPart: React.FC<AssistantTextPartProps> = ({
|
||||
@@ -27,6 +28,7 @@ const AssistantTextPart: React.FC<AssistantTextPartProps> = ({
|
||||
streamPhase,
|
||||
chatRenderMode = 'live',
|
||||
onShowPopup,
|
||||
enableMarkdownImages = false,
|
||||
}) => {
|
||||
// Use part directly from props — parent provides the latest version from the store.
|
||||
// No store subscription here to avoid re-render cascade from unrelated delta events.
|
||||
@@ -101,6 +103,7 @@ const AssistantTextPart: React.FC<AssistantTextPartProps> = ({
|
||||
disableStreamAnimation={chatRenderMode === 'sorted'}
|
||||
variant={part.type === 'reasoning' ? 'reasoning' : 'assistant'}
|
||||
enableFileReferences={isFinalized}
|
||||
enableLocalImages={enableMarkdownImages && !isStreaming && part.type === 'text'}
|
||||
onShowPopup={onShowPopup}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -55,6 +55,21 @@ Use this doc when you ask an agent to change tool/header/description behavior.
|
||||
HTML is sanitized as defense in depth, with script and style elements
|
||||
forbidden, so message content cannot inject active DOM or application-wide
|
||||
CSS into any runtime surface.
|
||||
- Final assistant Markdown collects HTTP(S), embedded, and workspace-local
|
||||
PNG/JPEG/GIF/WebP image candidates into one 100px thumbnail gallery in the
|
||||
message-completion area after all message text and above the turn's changed
|
||||
files. Each muted filename caption includes the shared image-file icon.
|
||||
HTTP(S) images keep their browser URL. Embedded and workspace-local images
|
||||
are limited to 10 MiB, validated as PNG/JPEG/GIF/WebP, and local paths are
|
||||
fetched through the active runtime before conversion to data URLs. Local
|
||||
Markdown links whose target has one of
|
||||
those image suffixes stay links in the text and open the same existing
|
||||
full-screen image preview as the gallery; image syntax does not insert a
|
||||
large inline image. A
|
||||
completed assistant message hydrates at most 12 unique image candidates,
|
||||
including persisted text parts that omit their optional part-level end time.
|
||||
Gallery clicks do not introduce or alter preview chrome: desktop and mobile
|
||||
both reuse the pre-existing attachment image preview overlay.
|
||||
- `read` and `skill` are **static navigation tools** and render via `StaticToolRow`.
|
||||
- Every other tool, including search/fetch, OpenCode built-ins, custom tools, plugins, and MCP tools, is **expandable** and renders through `ToolPart`.
|
||||
- The managed `openchamber` plugin tool uses the expandable path and hides its broad protocol input. The plugin supplies the selected action's human description as the native tool title; the UI renders that metadata without owning an action map. The full versioned result envelope renders through the same neutral JSON summary/tree/raw views as other tools, without a tool-specific output card.
|
||||
|
||||
@@ -1151,6 +1151,12 @@ html:not(.dark) .chat-scroll {
|
||||
color: var(--markdown-link-hover, var(--primary));
|
||||
}
|
||||
|
||||
[data-openchamber-finalized-assistant-images="true"] [data-openchamber-markdown-image-placeholder="true"],
|
||||
[data-openchamber-finalized-assistant-images="true"] p:has(> [data-openchamber-markdown-image-placeholder="true"]:only-child),
|
||||
[data-openchamber-finalized-assistant-images="true"] li:has(> [data-openchamber-markdown-image-placeholder="true"]:only-child) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.markdown-content [data-openchamber-file-link="true"] {
|
||||
color: var(--markdown-link, var(--primary));
|
||||
cursor: pointer;
|
||||
|
||||
@@ -37,6 +37,7 @@ Keep `bridge.ts` as a thin orchestration layer that delegates message handling t
|
||||
- directory listing
|
||||
- file search
|
||||
- file read path safety checks
|
||||
- active-directory selection across multi-root workspaces
|
||||
- dropped-file parsing and attachment reading
|
||||
- models metadata fetch helper
|
||||
|
||||
|
||||
@@ -538,7 +538,13 @@ export const fetchModelsMetadata = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const getFsAccessRoot = (): string => vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || os.homedir();
|
||||
const getFsAccessRoot = (requestedRoot?: string): string => {
|
||||
const workspaceRoots = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath) ?? [];
|
||||
const requested = requestedRoot ? path.resolve(requestedRoot) : '';
|
||||
return workspaceRoots.find((root) => path.resolve(root) === requested)
|
||||
|| workspaceRoots[0]
|
||||
|| os.homedir();
|
||||
};
|
||||
|
||||
export const getFsMimeType = (filePath: string): string => {
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
@@ -564,13 +570,13 @@ export type FsReadPathResolution =
|
||||
| { ok: true; resolvedPath: string }
|
||||
| { ok: false; status: number; error: string };
|
||||
|
||||
export const resolveFileReadPath = async (targetPath: string): Promise<FsReadPathResolution> => {
|
||||
export const resolveFileReadPath = async (targetPath: string, requestedRoot?: string): Promise<FsReadPathResolution> => {
|
||||
const trimmed = targetPath.trim();
|
||||
if (!trimmed) {
|
||||
return { ok: false, status: 400, error: 'Path is required' };
|
||||
}
|
||||
|
||||
const baseRoot = getFsAccessRoot();
|
||||
const baseRoot = getFsAccessRoot(requestedRoot);
|
||||
const resolved = resolveUserPath(trimmed, baseRoot);
|
||||
if (!resolved) {
|
||||
return { ok: false, status: 400, error: 'Path is required' };
|
||||
|
||||
@@ -1,32 +1,25 @@
|
||||
import { describe, expect, it, mock } from 'bun:test';
|
||||
|
||||
const existingFiles = new Set();
|
||||
const fsPromises = {
|
||||
realpath: mock(async (filePath) => {
|
||||
if (existingFiles.has(filePath)) return filePath;
|
||||
const error = new Error('missing');
|
||||
error.code = 'ENOENT';
|
||||
throw error;
|
||||
}),
|
||||
stat: mock(async (filePath) => {
|
||||
if (existingFiles.has(filePath)) return { isFile: () => true, size: 4, mtimeMs: 1 };
|
||||
const error = new Error('missing');
|
||||
error.code = 'ENOENT';
|
||||
throw error;
|
||||
}),
|
||||
readFile: mock(async () => Buffer.from('test')),
|
||||
};
|
||||
|
||||
mock.module('fs', () => ({
|
||||
promises: {
|
||||
realpath: mock(async () => {
|
||||
const error = new Error('missing');
|
||||
error.code = 'ENOENT';
|
||||
throw error;
|
||||
}),
|
||||
stat: mock(async () => {
|
||||
const error = new Error('missing');
|
||||
error.code = 'ENOENT';
|
||||
throw error;
|
||||
}),
|
||||
},
|
||||
default: {
|
||||
promises: {
|
||||
realpath: mock(async () => {
|
||||
const error = new Error('missing');
|
||||
error.code = 'ENOENT';
|
||||
throw error;
|
||||
}),
|
||||
stat: mock(async () => {
|
||||
const error = new Error('missing');
|
||||
error.code = 'ENOENT';
|
||||
throw error;
|
||||
}),
|
||||
},
|
||||
},
|
||||
promises: fsPromises,
|
||||
default: { promises: fsPromises },
|
||||
}));
|
||||
|
||||
mock.module('vscode', () => ({
|
||||
@@ -34,7 +27,10 @@ mock.module('vscode', () => ({
|
||||
file: (fsPath) => ({ fsPath }),
|
||||
},
|
||||
workspace: {
|
||||
workspaceFolders: [{ uri: { fsPath: '/workspace' } }],
|
||||
workspaceFolders: [
|
||||
{ uri: { fsPath: '/workspace' } },
|
||||
{ uri: { fsPath: '/workspace-two' } },
|
||||
],
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -56,4 +52,15 @@ describe('bridge local fs proxy', () => {
|
||||
|
||||
expect(response?.status).toBe(404);
|
||||
});
|
||||
|
||||
it('reads from the active directory when it is the second workspace root', async () => {
|
||||
existingFiles.add('/workspace-two/image.png');
|
||||
const response = await tryHandleLocalFsProxy(
|
||||
'GET',
|
||||
'/api/fs/raw?path=%2Fworkspace-two%2Fimage.png&directory=%2Fworkspace-two',
|
||||
);
|
||||
|
||||
expect(response?.status).toBe(200);
|
||||
expect(Buffer.from(response?.bodyBase64 ?? '', 'base64').toString()).toBe('test');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -66,7 +66,10 @@ export const tryHandleLocalFsProxy = async (method: string, requestPath: string)
|
||||
|
||||
const targetPath = parsed.searchParams.get('path') || '';
|
||||
const optional = parsed.searchParams.get('optional') === 'true';
|
||||
const resolution: FsReadPathResolution = await resolveFileReadPath(targetPath);
|
||||
const resolution: FsReadPathResolution = await resolveFileReadPath(
|
||||
targetPath,
|
||||
parsed.searchParams.get('directory') || undefined,
|
||||
);
|
||||
if (!resolution.ok) {
|
||||
if (fsProxyPath === '/api/fs/stat' && optional && resolution.status === 404) {
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user