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
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,19 @@ vi.mock('vscode', () => ({
},
}));

vi.mock('node:url', async () => {
const actual = await vi.importActual<typeof import('node:url')>('node:url');
return {
...actual,
pathToFileURL: (filePath: string) => {
if (process.platform !== 'win32' && /^[a-zA-Z]:\\/.test(filePath)) {
return actual.pathToFileURL(filePath, { windows: true });
}
return actual.pathToFileURL(filePath);
},
};
});

vi.mock('../utils/imageHandler.js', async (importOriginal) => {
const actual =
await importOriginal<typeof import('../utils/imageHandler.js')>();
Expand Down Expand Up @@ -1224,7 +1237,7 @@ describe('SessionMessageHandler', () => {
data: expect.objectContaining({
role: 'assistant',
content:
'Session exported to HTML: [export (#1).html](file:///workspace/export%20(%231).html)',
'Session exported to HTML: [export (#1).html](file:///workspace/export%20%28%231%29.html)',
}),
});
});
Expand Down Expand Up @@ -1316,4 +1329,48 @@ describe('SessionMessageHandler', () => {
expect(mockShowErrorMessage).not.toHaveBeenCalled();
});
});
it('preserves the drive-letter colon in Windows exported file links', async () => {
mockExportSessionToFile.mockResolvedValue({
filename: 'file.md',
uri: { fsPath: 'D:\\aplikacja\\file.md' },
});

const agentManager = {
isConnected: true,
currentSessionId: 'session-1',
getSessionList: vi
.fn()
.mockResolvedValue([{ sessionId: 'session-1', cwd: '/workspace' }]),
sendMessage: vi.fn(),
};
const conversationStore = {
createConversation: vi.fn(),
getConversation: vi.fn(),
addMessage: vi.fn(),
};
const sendToWebView = vi.fn();

const handler = new SessionMessageHandler(
agentManager as never,
conversationStore as never,
'session-1',
sendToWebView,
);

await handler.handle({
type: 'sendMessage',
data: {
text: '/export md',
},
});

expect(sendToWebView).toHaveBeenCalledWith({
type: 'message',
data: expect.objectContaining({
role: 'assistant',
content:
'Session exported to MD: [file.md](file:///D:/aplikacja/file.md)',
}),
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

import { logger } from '../../utils/logger.js';
import * as vscode from 'vscode';
import { pathToFileURL } from 'node:url';
import { BaseMessageHandler } from './BaseMessageHandler.js';
import type { ChatMessage } from '../../services/qwenAgentManager.js';
import type { Conversation } from '../../services/conversationStore.js';
Expand Down Expand Up @@ -36,7 +37,9 @@ function formatExportSuccessMessage(
filename: string,
filePath: string,
): string {
const markdownLinkPath = vscode.Uri.file(filePath).toString();
const markdownLinkPath = pathToFileURL(filePath)
.href.replace(/\(/g, '%28')
.replace(/\)/g, '%29');
return `Session exported to ${formatLabel}: [${filename}](${markdownLinkPath})`;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ let container: HTMLDivElement | null = null;

function renderMarkdown(
content: string,
onFileClick = vi.fn(),
enableFileLinks = false,
onFileClick: ((filePath: string) => void) | null = vi.fn(),
enableFileLinks = true,
) {
container = document.createElement('div');
document.body.appendChild(container);
Expand All @@ -26,7 +26,7 @@ function renderMarkdown(
root?.render(
<MarkdownRenderer
content={content}
onFileClick={onFileClick}
onFileClick={onFileClick ?? undefined}
enableFileLinks={enableFileLinks}
/>,
);
Expand All @@ -48,6 +48,8 @@ describe('MarkdownRenderer explicit file links', () => {
it('opens markdown file links with decoded absolute paths', () => {
const { onFileClick } = renderMarkdown(
'Saved: [export.html](/tmp/my%20dir/export.html)',
vi.fn(),
false,
);

const anchor = container?.querySelector('a');
Expand All @@ -60,6 +62,24 @@ describe('MarkdownRenderer explicit file links', () => {
expect(onFileClick).toHaveBeenCalledWith('/tmp/my dir/export.html');
});

it('opens Windows file URI links through the file click handler', () => {
const { onFileClick } = renderMarkdown(
'Saved: [export.md](file:///C:/Users/Me/My%20Exports/export.md)',
);

const anchor = container?.querySelector('a');
expect(anchor).toBeTruthy();

const event = new MouseEvent('click', { bubbles: true, cancelable: true });
anchor?.dispatchEvent(event);

expect(event.defaultPrevented).toBe(true);
expect(anchor?.classList.contains('file-path-link')).toBe(false);
expect(onFileClick).toHaveBeenCalledWith(
'C:/Users/Me/My Exports/export.md',
);
});

it('converts markdown file links with line fragments into vscode paths', () => {
const { onFileClick } = renderMarkdown('[app.ts](/tmp/src/app.ts#L12)');

Expand All @@ -72,4 +92,104 @@ describe('MarkdownRenderer explicit file links', () => {

expect(onFileClick).toHaveBeenCalledWith('/tmp/src/app.ts:12');
});

it('opens POSIX file URI links with absolute filesystem paths', () => {
const { onFileClick } = renderMarkdown(
'Saved: [export.md](file:///tmp/exports/export.md)',
);
const anchor = container?.querySelector('a');
expect(anchor).toBeTruthy();
const event = new MouseEvent('click', { bubbles: true, cancelable: true });
anchor?.dispatchEvent(event);
expect(event.defaultPrevented).toBe(true);
expect(onFileClick).toHaveBeenCalledWith('/tmp/exports/export.md');
});

it.each([
[
'localhost',
'file://localhost/tmp/exports/export.md',
'/tmp/exports/export.md',
],
[
'uppercase POSIX',
'FILE:///tmp/exports/export.md',
'/tmp/exports/export.md',
],
[
'uppercase localhost',
'FiLe://LOCALHOST/tmp/exports/export.md',
'/tmp/exports/export.md',
],
])('normalizes %s as a local file URI', (_label, href, expectedPath) => {
const { onFileClick } = renderMarkdown('Saved: [export.md](' + href + ')');
const anchor = container?.querySelector('a');
expect(anchor).toBeTruthy();
expect(anchor?.classList.contains('file-path-link')).toBe(false);

const event = new MouseEvent('click', { bubbles: true, cancelable: true });
anchor?.dispatchEvent(event);

expect(event.defaultPrevented).toBe(true);
expect(onFileClick).toHaveBeenCalledWith(expectedPath);
});
it.each([
['remote server', 'file://server/share/file.md'],
['attacker authority', 'file://attacker.example/share/file.md'],
['lookalike localhost authority', 'file://localhost.evil/share/file.md'],
['empty authority UNC', 'file:////server/share/file.md'],
['empty authority UNC with extra slash', 'file://///server/share/file.md'],
['encoded slash UNC', 'file:///%2F%2Fserver/share/file.md'],
['encoded backslash UNC', 'file:///%5C%5Cserver/share/file.md'],
])('rejects %s file URI links on the default path', (_label, href) => {
const { onFileClick } = renderMarkdown('[open](' + href + ')');

// Rejected file URIs must remain inert after both markdown rendering and
// the default processFilePaths pass.
expect(container?.querySelector('a.file-path-link')).toBeNull();
expect(container?.querySelector('a[href^="file:"]')).toBeNull();
expect(onFileClick).not.toHaveBeenCalled();
});
it('prevents local file URI navigation without a click handler', () => {
renderMarkdown('[export.md](file:///tmp/export.md)', null);
const anchor = container?.querySelector('a');
expect(anchor).toBeTruthy();
const event = new MouseEvent('click', {
bubbles: true,
cancelable: true,
});
anchor?.dispatchEvent(event);
expect(event.defaultPrevented).toBe(true);
});

it('converts file URI line fragments without decoding literal path hashes', () => {
const { onFileClick } = renderMarkdown(
'[app.ts](file:///tmp/src/app.ts#L12) [hash.md](file:///tmp/hash%23name.md)',
);
const anchors = container?.querySelectorAll('a');
expect(anchors).toHaveLength(2);
anchors?.[0]?.dispatchEvent(
new MouseEvent('click', { bubbles: true, cancelable: true }),
);
anchors?.[1]?.dispatchEvent(
new MouseEvent('click', { bubbles: true, cancelable: true }),
);
expect(onFileClick).toHaveBeenNthCalledWith(1, '/tmp/src/app.ts:12');
expect(onFileClick).toHaveBeenNthCalledWith(2, '/tmp/hash#name.md');
});

it('does not turn file URI images into live local images', () => {
renderMarkdown('![private](file:///tmp/private.png)');
expect(container?.querySelector('img[src^="file:"]')).toBeNull();
});

it('preserves safe links while rejecting javascript links', () => {
renderMarkdown(
'[docs](https://example.com/a.md) [bad](javascript:alert%281%29)',
);
expect(
container?.querySelector('a[href="https://example.com/a.md"]'),
).toBeTruthy();
expect(container?.querySelector('a[href^="javascript:"]')).toBeNull();
});
});
Loading
Loading