mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-07-09 17:19:02 +00:00
feat(vscode-companion): support /export session command (#2592)
* feat(vscode-companion): support /export session command * fix(vscode-ide-companion/webview): prefer ACP session id for export * feat(vscode-ide-companion): support /export slash command Add nested /export completion and ACP command availability for the VS Code companion. Reuse the shared export flow, write to the default path, and show clickable export results in chat. * fix(export): align slash command messaging Restore the CLI export description to the existing wording. Keep the VS Code companion error message consistent with the required /export subcommands. * fix(webui): support explicit markdown file links Handle local markdown file links in assistant messages even when automatic file-link detection is disabled. Normalize encoded paths and line fragments so exported files can be opened from the VS Code webview. * test(vscode-ide-companion): make export path assertion cross-platform * fix(vscode-ide-companion): use public session export entrypoint * fix(cli): replay standalone ESC after early capture * fix(vscode-ide-companion): resolve rebase artifacts and vitest export alias Remove duplicate AvailableCommand import caused by merge, and add vitest resolve alias for @qwen-code/qwen-code/export so the session export service tests can resolve the CLI export module from source. * fix(cli): fix getAvailableCommands test mock to use getCommandsForMode The test mock was only setting up getCommands but getAvailableCommands calls getCommandsForMode. Add getCommandsForMode to the mock and set up test data on it instead. * fix(vscode-ide-companion): fix export file link click and add save dialog - Fix file:/// URI handling in MarkdownRenderer: normalizeExplicitFileLink now strips the file:// scheme before checking isAbsolutePath, so exported file links are properly recognized and clickable - Replace direct cwd file write with vscode.window.showSaveDialog() so users can choose the export destination and filename - Handle cancelled save dialog gracefully (return null, skip success message) * fix(webui): scope file link handler to file:// URIs only, fix # in filenames - normalizeExplicitFileLink now returns early for file:// URIs without splitting on #, since vscode.Uri.file() encodes # as %23 in the path. This prevents filenames containing # from being truncated after decode. - Explicit-link click handler now only fires for file:// URI hrefs, not arbitrary relative paths. This prevents model-generated markdown links from bypassing enableFileLinks=false and opening arbitrary files. - Remove unused KNOWN_FILE_EXTENSIONS constant. * fix(vscode-ide-companion): update export tests for save dialog, fix stale JSDoc - Add showSaveDialog mock to sessionExportService.test.ts - Update existing test to verify save dialog is called with correct args - Add test for cancelled save dialog returning null - Fix JSDoc that incorrectly claimed fallback-to-cwd behavior
This commit is contained in:
parent
93cbad24b1
commit
5e1b8b0d59
19 changed files with 1150 additions and 48 deletions
|
|
@ -16,6 +16,10 @@
|
|||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
},
|
||||
"./export": {
|
||||
"types": "./dist/src/export/index.d.ts",
|
||||
"import": "./dist/src/export/index.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
|
|
|
|||
17
packages/cli/src/export/index.ts
Normal file
17
packages/cli/src/export/index.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
export {
|
||||
collectSessionData,
|
||||
generateExportFilename,
|
||||
normalizeSessionData,
|
||||
toHtml,
|
||||
toJson,
|
||||
toJsonl,
|
||||
toMarkdown,
|
||||
type ExportMessage,
|
||||
type ExportSessionData,
|
||||
} from '../ui/utils/export/index.js';
|
||||
|
|
@ -5,7 +5,10 @@
|
|||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { handleSlashCommand } from './nonInteractiveCliCommands.js';
|
||||
import {
|
||||
getAvailableCommands,
|
||||
handleSlashCommand,
|
||||
} from './nonInteractiveCliCommands.js';
|
||||
import type { Config } from '@qwen-code/qwen-code-core';
|
||||
import type { LoadedSettings } from './config/settings.js';
|
||||
import { CommandKind, type ExecutionMode } from './ui/commands/types.js';
|
||||
|
|
@ -340,3 +343,43 @@ describe('handleSlashCommand', () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAvailableCommands', () => {
|
||||
let mockConfig: Config;
|
||||
|
||||
beforeEach(() => {
|
||||
mockCommandServiceCreate.mockResolvedValue({
|
||||
getCommands: mockGetCommands,
|
||||
getCommandsForMode: mockGetCommandsForMode,
|
||||
});
|
||||
|
||||
mockConfig = {
|
||||
getExperimentalZedIntegration: vi.fn().mockReturnValue(false),
|
||||
isInteractive: vi.fn().mockReturnValue(false),
|
||||
getSessionId: vi.fn().mockReturnValue('test-session'),
|
||||
getFolderTrustFeature: vi.fn().mockReturnValue(false),
|
||||
getFolderTrust: vi.fn().mockReturnValue(false),
|
||||
getProjectRoot: vi.fn().mockReturnValue('/test/project'),
|
||||
getDisabledSlashCommands: vi.fn().mockReturnValue([]),
|
||||
storage: {},
|
||||
} as unknown as Config;
|
||||
});
|
||||
|
||||
it('includes /export in the default non-interactive command list', async () => {
|
||||
mockGetCommandsForMode.mockReturnValue([
|
||||
{
|
||||
name: 'export',
|
||||
description: 'Export current session',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: vi.fn(),
|
||||
},
|
||||
]);
|
||||
|
||||
const commands = await getAvailableCommands(
|
||||
mockConfig,
|
||||
new AbortController().signal,
|
||||
);
|
||||
|
||||
expect(commands.map((command) => command.name)).toContain('export');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -264,13 +264,13 @@ describe('earlyInputCapture', () => {
|
|||
expect(input.toString()).toBe('');
|
||||
});
|
||||
|
||||
it('should drop standalone ESC on capture end', () => {
|
||||
it('should replay standalone ESC on capture end', () => {
|
||||
startEarlyInputCapture();
|
||||
mockStdin.write(Buffer.from('\x1b'));
|
||||
stopEarlyInputCapture();
|
||||
|
||||
const input = getAndClearCapturedInput();
|
||||
expect(input.toString()).toBe('');
|
||||
expect(input.toString()).toBe('\x1b');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -223,6 +223,9 @@ function shouldReplayPendingAtStop(pending: Buffer): boolean {
|
|||
if (pending.length === 0) {
|
||||
return false;
|
||||
}
|
||||
if (pending.length === 1 && pending[0] === 0x1b) {
|
||||
return true;
|
||||
}
|
||||
return classifyEscapeSequence(pending, 0) === 'user';
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -81,6 +81,15 @@ const reactDedupPlugin = {
|
|||
},
|
||||
};
|
||||
|
||||
const publicCliExportPlugin = {
|
||||
name: 'public-cli-export',
|
||||
setup(build) {
|
||||
build.onResolve({ filter: /^@qwen-code\/qwen-code\/export$/ }, () => ({
|
||||
path: resolve(repoRoot, 'packages/cli/src/export/index.ts'),
|
||||
}));
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve `*.wasm?binary` imports to embedded Uint8Array content.
|
||||
* This keeps the companion bundle compatible with core's inline-WASM loader.
|
||||
|
|
@ -187,6 +196,7 @@ async function main() {
|
|||
'import.meta.url': 'import_meta.url',
|
||||
},
|
||||
plugins: [
|
||||
publicCliExportPlugin,
|
||||
wasmBinaryPlugin,
|
||||
wasmLoader({ mode: 'embedded' }),
|
||||
/* add to the end of plugins array */
|
||||
|
|
|
|||
|
|
@ -0,0 +1,191 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import * as path from 'node:path';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const {
|
||||
mockLoadSession,
|
||||
mockCollectSessionData,
|
||||
mockNormalizeSessionData,
|
||||
mockToHtml,
|
||||
mockToMarkdown,
|
||||
mockToJson,
|
||||
mockToJsonl,
|
||||
mockGenerateExportFilename,
|
||||
mockWriteFile,
|
||||
mockShowSaveDialog,
|
||||
} = vi.hoisted(() => ({
|
||||
mockLoadSession: vi.fn(),
|
||||
mockCollectSessionData: vi.fn(),
|
||||
mockNormalizeSessionData: vi.fn(),
|
||||
mockToHtml: vi.fn(),
|
||||
mockToMarkdown: vi.fn(),
|
||||
mockToJson: vi.fn(),
|
||||
mockToJsonl: vi.fn(),
|
||||
mockGenerateExportFilename: vi.fn(),
|
||||
mockWriteFile: vi.fn(),
|
||||
mockShowSaveDialog: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@qwen-code/qwen-code-core', () => {
|
||||
class SessionService {
|
||||
constructor(_cwd: string) {}
|
||||
|
||||
async loadSession(_sessionId: string) {
|
||||
return mockLoadSession();
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
SessionService,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@qwen-code/qwen-code/export', () => ({
|
||||
collectSessionData: mockCollectSessionData,
|
||||
normalizeSessionData: mockNormalizeSessionData,
|
||||
toHtml: mockToHtml,
|
||||
toMarkdown: mockToMarkdown,
|
||||
toJson: mockToJson,
|
||||
toJsonl: mockToJsonl,
|
||||
generateExportFilename: mockGenerateExportFilename,
|
||||
}));
|
||||
|
||||
vi.mock('node:fs/promises', () => ({
|
||||
writeFile: mockWriteFile,
|
||||
}));
|
||||
|
||||
vi.mock('vscode', () => ({
|
||||
Uri: {
|
||||
file: (fsPath: string) => ({ fsPath }),
|
||||
},
|
||||
window: {
|
||||
showSaveDialog: mockShowSaveDialog,
|
||||
},
|
||||
}));
|
||||
|
||||
import {
|
||||
exportSessionToFile,
|
||||
parseExportSlashCommand,
|
||||
} from './sessionExportService.js';
|
||||
|
||||
describe('sessionExportService', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
mockLoadSession.mockResolvedValue({
|
||||
conversation: {
|
||||
sessionId: 'session-1',
|
||||
startTime: '2025-01-01T00:00:00Z',
|
||||
messages: [],
|
||||
},
|
||||
});
|
||||
mockCollectSessionData.mockResolvedValue({
|
||||
sessionId: 'session-1',
|
||||
startTime: '2025-01-01T00:00:00Z',
|
||||
messages: [],
|
||||
});
|
||||
mockNormalizeSessionData.mockImplementation((data) => data);
|
||||
mockToHtml.mockReturnValue('<html>export</html>');
|
||||
mockToMarkdown.mockReturnValue('# export');
|
||||
mockToJson.mockReturnValue('{"ok":true}');
|
||||
mockToJsonl.mockReturnValue('{"ok":true}');
|
||||
mockGenerateExportFilename.mockImplementation(
|
||||
(format: string) => `qwen-export.${format}`,
|
||||
);
|
||||
});
|
||||
|
||||
describe('parseExportSlashCommand', () => {
|
||||
it('returns null for non-export input', () => {
|
||||
expect(parseExportSlashCommand('hello')).toBeNull();
|
||||
expect(parseExportSlashCommand('/model')).toBeNull();
|
||||
});
|
||||
|
||||
it('requires an explicit subcommand for bare /export', () => {
|
||||
expect(() => parseExportSlashCommand('/export')).toThrow(
|
||||
"Command '/export' requires a subcommand.",
|
||||
);
|
||||
expect(() => parseExportSlashCommand('/export ')).toThrow(
|
||||
"Command '/export' requires a subcommand.",
|
||||
);
|
||||
});
|
||||
|
||||
it('returns the requested export format', () => {
|
||||
expect(parseExportSlashCommand('/export html')).toBe('html');
|
||||
expect(parseExportSlashCommand('/export md')).toBe('md');
|
||||
expect(parseExportSlashCommand('/export JSON')).toBe('json');
|
||||
});
|
||||
|
||||
it('rejects unsupported export arguments', () => {
|
||||
expect(() => parseExportSlashCommand('/export csv')).toThrow(
|
||||
'Unsupported /export format. Use /export html, /export md, /export json, or /export jsonl.',
|
||||
);
|
||||
expect(() => parseExportSlashCommand('/export md extra')).toThrow(
|
||||
'Unsupported /export format. Use /export html, /export md, /export json, or /export jsonl.',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('exportSessionToFile', () => {
|
||||
it('writes the exported session to the user-chosen path', async () => {
|
||||
const chosenPath = path.join('/workspace', 'qwen-export.html');
|
||||
mockShowSaveDialog.mockResolvedValue({ fsPath: chosenPath });
|
||||
|
||||
const result = await exportSessionToFile({
|
||||
sessionId: 'session-1',
|
||||
cwd: '/workspace',
|
||||
format: 'html',
|
||||
});
|
||||
|
||||
expect(mockCollectSessionData).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ sessionId: 'session-1' }),
|
||||
expect.anything(),
|
||||
);
|
||||
expect(mockNormalizeSessionData).toHaveBeenCalled();
|
||||
expect(mockToHtml).toHaveBeenCalled();
|
||||
expect(mockShowSaveDialog).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
title: 'Export Session as HTML',
|
||||
}),
|
||||
);
|
||||
expect(mockWriteFile).toHaveBeenCalledWith(
|
||||
chosenPath,
|
||||
'<html>export</html>',
|
||||
'utf-8',
|
||||
);
|
||||
expect(result).toEqual({
|
||||
filename: 'qwen-export.html',
|
||||
uri: { fsPath: chosenPath },
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null when the user cancels the save dialog', async () => {
|
||||
mockShowSaveDialog.mockResolvedValue(undefined);
|
||||
|
||||
const result = await exportSessionToFile({
|
||||
sessionId: 'session-1',
|
||||
cwd: '/workspace',
|
||||
format: 'html',
|
||||
});
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(mockWriteFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws when the target session cannot be loaded', async () => {
|
||||
mockLoadSession.mockResolvedValue(undefined);
|
||||
|
||||
await expect(
|
||||
exportSessionToFile({
|
||||
sessionId: 'missing-session',
|
||||
cwd: '/workspace',
|
||||
format: 'json',
|
||||
}),
|
||||
).rejects.toThrow('No active session found to export.');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,147 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs/promises';
|
||||
import * as path from 'node:path';
|
||||
import * as vscode from 'vscode';
|
||||
import { SessionService, type Config } from '@qwen-code/qwen-code-core';
|
||||
import {
|
||||
collectSessionData,
|
||||
generateExportFilename,
|
||||
normalizeSessionData,
|
||||
toHtml,
|
||||
toJson,
|
||||
toJsonl,
|
||||
toMarkdown,
|
||||
} from '@qwen-code/qwen-code/export';
|
||||
import {
|
||||
EXPORT_SESSION_FORMATS,
|
||||
getExportSubcommandRequiredMessage,
|
||||
isSessionExportFormat,
|
||||
type SessionExportFormat,
|
||||
} from '../utils/exportSlashCommand.js';
|
||||
|
||||
export { EXPORT_SESSION_FORMATS as SESSION_EXPORT_FORMATS };
|
||||
export type { SessionExportFormat } from '../utils/exportSlashCommand.js';
|
||||
|
||||
export interface SessionExportResult {
|
||||
filename: string;
|
||||
uri: vscode.Uri;
|
||||
}
|
||||
|
||||
const EXPORT_CONFIG = {
|
||||
getChannel: () => 'vscode-companion',
|
||||
getToolRegistry: () => undefined,
|
||||
} as unknown as Config;
|
||||
|
||||
export function parseExportSlashCommand(
|
||||
text: string,
|
||||
): SessionExportFormat | null {
|
||||
const trimmed = text.replace(/\u200B/g, '').trim();
|
||||
if (!trimmed.startsWith('/')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parts = trimmed.split(/\s+/).filter(Boolean);
|
||||
const [command, format, ...rest] = parts;
|
||||
if (command !== '/export') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!format) {
|
||||
throw new Error(getExportSubcommandRequiredMessage());
|
||||
}
|
||||
|
||||
const normalizedFormat = format.toLowerCase();
|
||||
if (rest.length === 0 && isSessionExportFormat(normalizedFormat)) {
|
||||
return normalizedFormat;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
'Unsupported /export format. Use /export html, /export md, /export json, or /export jsonl.',
|
||||
);
|
||||
}
|
||||
|
||||
function renderExportContent(
|
||||
format: SessionExportFormat,
|
||||
normalizedData: Awaited<ReturnType<typeof normalizeSessionData>>,
|
||||
): string {
|
||||
switch (format) {
|
||||
case 'html':
|
||||
return toHtml(normalizedData);
|
||||
case 'md':
|
||||
return toMarkdown(normalizedData);
|
||||
case 'json':
|
||||
return toJson(normalizedData);
|
||||
case 'jsonl':
|
||||
return toJsonl(normalizedData);
|
||||
default: {
|
||||
const unreachableFormat: never = format;
|
||||
throw new Error(`Unsupported export format: ${unreachableFormat}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Export session to file via a native Save dialog.
|
||||
* Returns null if the user cancels the dialog.
|
||||
*
|
||||
* @param options.sessionId - The session to export
|
||||
* @param options.cwd - Working directory used as default save location
|
||||
* @param options.format - Target format (html, md, json, jsonl)
|
||||
* @returns Export result with filename and URI, or null if cancelled
|
||||
*/
|
||||
export async function exportSessionToFile(options: {
|
||||
sessionId: string;
|
||||
cwd: string;
|
||||
format: SessionExportFormat;
|
||||
}): Promise<SessionExportResult | null> {
|
||||
const { cwd, format, sessionId } = options;
|
||||
const sessionService = new SessionService(cwd);
|
||||
const sessionData = await sessionService.loadSession(sessionId);
|
||||
|
||||
if (!sessionData) {
|
||||
throw new Error('No active session found to export.');
|
||||
}
|
||||
|
||||
const exportData = await collectSessionData(
|
||||
sessionData.conversation,
|
||||
EXPORT_CONFIG,
|
||||
);
|
||||
const normalizedData = normalizeSessionData(
|
||||
exportData,
|
||||
sessionData.conversation.messages,
|
||||
EXPORT_CONFIG,
|
||||
);
|
||||
const content = renderExportContent(format, normalizedData);
|
||||
const defaultFilename = generateExportFilename(format);
|
||||
|
||||
// Show native Save dialog so users can choose destination and filename
|
||||
const filterMap: Record<SessionExportFormat, Record<string, string[]>> = {
|
||||
html: { HTML: ['html'] },
|
||||
md: { Markdown: ['md'] },
|
||||
json: { JSON: ['json'] },
|
||||
jsonl: { 'JSON Lines': ['jsonl'] },
|
||||
};
|
||||
|
||||
const saveUri = await vscode.window.showSaveDialog({
|
||||
defaultUri: vscode.Uri.file(path.join(cwd, defaultFilename)),
|
||||
filters: filterMap[format],
|
||||
title: `Export Session as ${format.toUpperCase()}`,
|
||||
});
|
||||
|
||||
if (!saveUri) {
|
||||
// User cancelled the save dialog
|
||||
return null;
|
||||
}
|
||||
|
||||
await fs.writeFile(saveUri.fsPath, content, 'utf-8');
|
||||
|
||||
return {
|
||||
filename: path.basename(saveUri.fsPath),
|
||||
uri: saveUri,
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
export const EXPORT_PARENT_COMMAND_NAME = 'export' as const;
|
||||
|
||||
export const EXPORT_PARENT_COMMAND_DESCRIPTION =
|
||||
'Export current session to a file. Available formats: html, md, json, jsonl.';
|
||||
|
||||
export const EXPORT_SESSION_FORMATS = ['html', 'md', 'json', 'jsonl'] as const;
|
||||
|
||||
export type SessionExportFormat = (typeof EXPORT_SESSION_FORMATS)[number];
|
||||
|
||||
export const EXPORT_SUBCOMMAND_SPECS: ReadonlyArray<{
|
||||
name: SessionExportFormat;
|
||||
description: string;
|
||||
}> = [
|
||||
{ name: 'html', description: 'Export session to HTML format' },
|
||||
{ name: 'md', description: 'Export session to markdown format' },
|
||||
{ name: 'json', description: 'Export session to JSON format' },
|
||||
{
|
||||
name: 'jsonl',
|
||||
description: 'Export session to JSONL format (one message per line)',
|
||||
},
|
||||
];
|
||||
|
||||
export function isSessionExportFormat(
|
||||
value: string,
|
||||
): value is SessionExportFormat {
|
||||
return EXPORT_SESSION_FORMATS.includes(value as SessionExportFormat);
|
||||
}
|
||||
|
||||
export function getExportSubcommandRequiredMessage(): string {
|
||||
const availableLines = EXPORT_SUBCOMMAND_SPECS.map(
|
||||
(subcommand) => ` - ${subcommand.name}: ${subcommand.description}`,
|
||||
);
|
||||
return `Command '/${EXPORT_PARENT_COMMAND_NAME}' requires a subcommand. Available:\n${availableLines.join('\n')}`;
|
||||
}
|
||||
|
|
@ -59,6 +59,10 @@ import type { ModelInfo, AvailableCommand } from '@agentclientprotocol/sdk';
|
|||
import type { Question } from '../types/acpTypes.js';
|
||||
import { useImagePaste, type WebViewImageMessage } from './hooks/useImage.js';
|
||||
import { computeContextUsage } from './utils/contextUsage.js';
|
||||
import {
|
||||
buildSlashCommandItems,
|
||||
isExpandableSlashCommand,
|
||||
} from './utils/slashCommandUtils.js';
|
||||
|
||||
/**
|
||||
* Memoized message list that only re-renders when messages or callbacks change,
|
||||
|
|
@ -296,16 +300,9 @@ export const App: React.FC = () => {
|
|||
},
|
||||
];
|
||||
|
||||
// Slash Commands group - commands from server (available_commands_update)
|
||||
const slashCommandItems: CompletionItem[] = availableCommands.map(
|
||||
(cmd) => ({
|
||||
id: cmd.name,
|
||||
label: `/${cmd.name}`,
|
||||
description: cmd.description,
|
||||
type: 'command' as const,
|
||||
group: 'Slash Commands',
|
||||
value: cmd.name,
|
||||
}),
|
||||
const slashCommandItems = buildSlashCommandItems(
|
||||
query,
|
||||
availableCommands,
|
||||
);
|
||||
|
||||
// Combine all commands
|
||||
|
|
@ -316,12 +313,11 @@ export const App: React.FC = () => {
|
|||
];
|
||||
|
||||
// Filter by query
|
||||
const lowerQuery = query.toLowerCase();
|
||||
return allCommands.filter(
|
||||
(cmd) =>
|
||||
cmd.label.toLowerCase().includes(lowerQuery) ||
|
||||
cmd.label.toLowerCase().includes(query.toLowerCase()) ||
|
||||
(cmd.description &&
|
||||
cmd.description.toLowerCase().includes(lowerQuery)),
|
||||
cmd.description.toLowerCase().includes(query.toLowerCase())),
|
||||
);
|
||||
}
|
||||
},
|
||||
|
|
@ -722,7 +718,11 @@ export const App: React.FC = () => {
|
|||
// Skip when fillOnly (Tab) — let the generic insertion path fill the
|
||||
// command text so the user can keep typing arguments.
|
||||
const serverCmd = availableCommands.find((c) => c.name === itemId);
|
||||
if (serverCmd && !fillOnly) {
|
||||
if (
|
||||
serverCmd &&
|
||||
!fillOnly &&
|
||||
!isExpandableSlashCommand(serverCmd.name)
|
||||
) {
|
||||
// Clear the trigger text since we're sending the command
|
||||
clearTriggerText();
|
||||
// Send the slash command as a user message
|
||||
|
|
@ -814,6 +814,17 @@ export const App: React.FC = () => {
|
|||
newRange.collapse(false);
|
||||
sel?.removeAllRanges();
|
||||
sel?.addRange(newRange);
|
||||
|
||||
if (
|
||||
completion.triggerChar === '/' &&
|
||||
isExpandableSlashCommand(insertValue.trim())
|
||||
) {
|
||||
completion.closeCompletion();
|
||||
requestAnimationFrame(() => {
|
||||
inputElement.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Close the completion menu
|
||||
|
|
|
|||
|
|
@ -6,12 +6,15 @@
|
|||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { mockProcessImageAttachments, mockShowErrorMessage } = vi.hoisted(
|
||||
() => ({
|
||||
mockProcessImageAttachments: vi.fn(),
|
||||
mockShowErrorMessage: vi.fn(),
|
||||
}),
|
||||
);
|
||||
const {
|
||||
mockProcessImageAttachments,
|
||||
mockShowErrorMessage,
|
||||
mockExportSessionToFile,
|
||||
} = vi.hoisted(() => ({
|
||||
mockProcessImageAttachments: vi.fn(),
|
||||
mockShowErrorMessage: vi.fn(),
|
||||
mockExportSessionToFile: vi.fn(),
|
||||
}));
|
||||
const { mockExecuteCommand } = vi.hoisted(() => ({
|
||||
mockExecuteCommand: vi.fn(),
|
||||
}));
|
||||
|
|
@ -27,6 +30,13 @@ vi.mock('vscode', () => ({
|
|||
workspace: {
|
||||
workspaceFolders: [{ uri: { fsPath: '/workspace' } }],
|
||||
},
|
||||
Uri: {
|
||||
file: (fsPath: string) => ({
|
||||
fsPath,
|
||||
toString: () =>
|
||||
`file://${encodeURI(fsPath.replace(/\\/g, '/')).replace(/#/g, '%23')}`,
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../utils/imageHandler.js', async (importOriginal) => {
|
||||
|
|
@ -38,6 +48,23 @@ vi.mock('../utils/imageHandler.js', async (importOriginal) => {
|
|||
};
|
||||
});
|
||||
|
||||
vi.mock('../../services/sessionExportService.js', () => ({
|
||||
parseExportSlashCommand: (text: string) => {
|
||||
const trimmed = text.trim();
|
||||
if (trimmed === '/export html') {
|
||||
return 'html';
|
||||
}
|
||||
if (trimmed === '/export md') {
|
||||
return 'md';
|
||||
}
|
||||
if (trimmed === '/export') {
|
||||
throw new Error("Command '/export' requires a subcommand.");
|
||||
}
|
||||
return null;
|
||||
},
|
||||
exportSessionToFile: mockExportSessionToFile,
|
||||
}));
|
||||
|
||||
import { SessionMessageHandler } from './SessionMessageHandler.js';
|
||||
|
||||
describe('SessionMessageHandler', () => {
|
||||
|
|
@ -49,6 +76,10 @@ describe('SessionMessageHandler', () => {
|
|||
savedImageCount: 0,
|
||||
promptImages: [],
|
||||
});
|
||||
mockExportSessionToFile.mockResolvedValue({
|
||||
filename: 'export.html',
|
||||
uri: { fsPath: '/workspace/export.html' },
|
||||
});
|
||||
});
|
||||
|
||||
it('forwards the active model when opening a new chat tab', async () => {
|
||||
|
|
@ -265,4 +296,209 @@ describe('SessionMessageHandler', () => {
|
|||
data: {},
|
||||
});
|
||||
});
|
||||
|
||||
it('intercepts /export html and uses the VSCode export flow instead of sending a prompt', async () => {
|
||||
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 html',
|
||||
},
|
||||
});
|
||||
|
||||
expect(mockExportSessionToFile).toHaveBeenCalledWith({
|
||||
sessionId: 'session-1',
|
||||
cwd: '/workspace',
|
||||
format: 'html',
|
||||
});
|
||||
expect(conversationStore.addMessage).not.toHaveBeenCalled();
|
||||
expect(agentManager.sendMessage).not.toHaveBeenCalled();
|
||||
expect(sendToWebView).toHaveBeenCalledWith({
|
||||
type: 'message',
|
||||
data: expect.objectContaining({
|
||||
role: 'assistant',
|
||||
content:
|
||||
'Session exported to HTML: [export.html](file:///workspace/export.html)',
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('prefers the active ACP session id over the local conversation id when exporting', async () => {
|
||||
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,
|
||||
'conv_local_123',
|
||||
sendToWebView,
|
||||
);
|
||||
|
||||
await handler.handle({
|
||||
type: 'sendMessage',
|
||||
data: {
|
||||
text: '/export html',
|
||||
},
|
||||
});
|
||||
|
||||
expect(mockExportSessionToFile).toHaveBeenCalledWith({
|
||||
sessionId: 'session-1',
|
||||
cwd: '/workspace',
|
||||
format: 'html',
|
||||
});
|
||||
expect(agentManager.sendMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reports bare /export as a missing subcommand instead of exporting', async () => {
|
||||
const agentManager = {
|
||||
isConnected: true,
|
||||
currentSessionId: 'session-1',
|
||||
getSessionList: vi.fn(),
|
||||
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',
|
||||
},
|
||||
});
|
||||
|
||||
expect(mockExportSessionToFile).not.toHaveBeenCalled();
|
||||
expect(sendToWebView).toHaveBeenCalledWith({
|
||||
type: 'error',
|
||||
data: { message: "Command '/export' requires a subcommand." },
|
||||
});
|
||||
expect(agentManager.sendMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reports export failures back to the user', async () => {
|
||||
mockExportSessionToFile.mockRejectedValue(new Error('disk full'));
|
||||
|
||||
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: 'error',
|
||||
data: { message: 'Failed to export session: disk full' },
|
||||
});
|
||||
expect(agentManager.sendMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('encodes exported file links before rendering markdown', async () => {
|
||||
mockExportSessionToFile.mockResolvedValue({
|
||||
filename: 'export (#1).html',
|
||||
uri: { fsPath: '/workspace/export (#1).html' },
|
||||
});
|
||||
|
||||
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 html',
|
||||
},
|
||||
});
|
||||
|
||||
expect(sendToWebView).toHaveBeenCalledWith({
|
||||
type: 'message',
|
||||
data: expect.objectContaining({
|
||||
role: 'assistant',
|
||||
content:
|
||||
'Session exported to HTML: [export (#1).html](file:///workspace/export%20(%231).html)',
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -15,6 +15,20 @@ import {
|
|||
} from '../utils/imageHandler.js';
|
||||
import { isAuthenticationRequiredError } from '../../utils/authErrors.js';
|
||||
import { getErrorMessage } from '../../utils/errorMessage.js';
|
||||
import {
|
||||
exportSessionToFile,
|
||||
parseExportSlashCommand,
|
||||
type SessionExportFormat,
|
||||
} from '../../services/sessionExportService.js';
|
||||
|
||||
function formatExportSuccessMessage(
|
||||
formatLabel: string,
|
||||
filename: string,
|
||||
filePath: string,
|
||||
): string {
|
||||
const markdownLinkPath = vscode.Uri.file(filePath).toString();
|
||||
return `Session exported to ${formatLabel}: [${filename}](${markdownLinkPath})`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Session message handler
|
||||
|
|
@ -287,6 +301,73 @@ export class SessionMessageHandler extends BaseMessageHandler {
|
|||
return isAuthenticationRequiredError(error);
|
||||
}
|
||||
|
||||
private async resolveSessionWorkingDir(sessionId: string): Promise<string> {
|
||||
try {
|
||||
const sessions = await this.agentManager.getSessionList();
|
||||
const match = sessions.find(
|
||||
(session) =>
|
||||
session.sessionId === sessionId || session.id === sessionId,
|
||||
);
|
||||
if (typeof match?.cwd === 'string' && match.cwd.length > 0) {
|
||||
return match.cwd;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
'[SessionMessageHandler] Failed to resolve export session cwd:',
|
||||
error,
|
||||
);
|
||||
}
|
||||
|
||||
const workspaceFolder = vscode.workspace.workspaceFolders?.[0];
|
||||
return workspaceFolder?.uri.fsPath || process.cwd();
|
||||
}
|
||||
|
||||
private async handleExportCommand(
|
||||
format: SessionExportFormat,
|
||||
): Promise<void> {
|
||||
// Prefer the active ACP session id. The local conversation id may still be
|
||||
// a webview-only `conv_*` placeholder after starting a fresh session.
|
||||
const sessionId =
|
||||
this.agentManager.currentSessionId ?? this.currentConversationId;
|
||||
if (!sessionId) {
|
||||
const errorMsg = 'No active session found to export.';
|
||||
this.sendToWebView({
|
||||
type: 'error',
|
||||
data: { message: errorMsg },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const cwd = await this.resolveSessionWorkingDir(sessionId);
|
||||
const result = await exportSessionToFile({ sessionId, cwd, format });
|
||||
if (!result) {
|
||||
// User cancelled the save dialog
|
||||
return;
|
||||
}
|
||||
const formatLabel = format.toUpperCase();
|
||||
this.sendToWebView({
|
||||
type: 'message',
|
||||
data: {
|
||||
role: 'assistant',
|
||||
content: formatExportSuccessMessage(
|
||||
formatLabel,
|
||||
result.filename,
|
||||
result.uri.fsPath,
|
||||
),
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
const errorMsg = this.getErrorMessage(error);
|
||||
console.error('[SessionMessageHandler] Failed to export session:', error);
|
||||
this.sendToWebView({
|
||||
type: 'error',
|
||||
data: { message: `Failed to export session: ${errorMsg}` },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle send message request
|
||||
*/
|
||||
|
|
@ -318,6 +399,21 @@ export class SessionMessageHandler extends BaseMessageHandler {
|
|||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const exportFormat = parseExportSlashCommand(trimmedText);
|
||||
if (exportFormat) {
|
||||
await this.handleExportCommand(exportFormat);
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMsg = this.getErrorMessage(error);
|
||||
this.sendToWebView({
|
||||
type: 'error',
|
||||
data: { message: errorMsg },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
let displayText = trimmedText ? text : '';
|
||||
let promptText = text;
|
||||
if (context && context.length > 0) {
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
import type { RefObject } from 'react';
|
||||
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
||||
import type { CompletionItem } from '../../types/completionItemTypes.js';
|
||||
import { shouldAllowCompletionQuery } from '../utils/slashCommandUtils.js';
|
||||
|
||||
interface CompletionTriggerState {
|
||||
isOpen: boolean;
|
||||
|
|
@ -325,8 +326,7 @@ export function useCompletionTrigger(
|
|||
if (isValidTrigger) {
|
||||
const query = text.substring(triggerPos + 1, effectiveCursorPosition);
|
||||
|
||||
// Only show if query doesn't contain spaces (still typing the reference)
|
||||
if (!query.includes(' ') && !query.includes('\n')) {
|
||||
if (shouldAllowCompletionQuery(triggerChar, query)) {
|
||||
// Get precise cursor position for menu
|
||||
const cursorPos = getCursorPosition();
|
||||
if (cursorPos) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,75 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { AvailableCommand } from '@agentclientprotocol/sdk';
|
||||
import {
|
||||
buildSlashCommandItems,
|
||||
isExpandableSlashCommand,
|
||||
shouldAllowCompletionQuery,
|
||||
} from './slashCommandUtils.js';
|
||||
|
||||
const availableCommands: AvailableCommand[] = [
|
||||
{
|
||||
name: 'export',
|
||||
description:
|
||||
'Export current session to a file. Available formats: html, md, json, jsonl.',
|
||||
input: null,
|
||||
},
|
||||
{
|
||||
name: 'help',
|
||||
description: 'Show help',
|
||||
input: null,
|
||||
},
|
||||
];
|
||||
|
||||
describe('slashCommandUtils', () => {
|
||||
describe('shouldAllowCompletionQuery', () => {
|
||||
it('keeps slash completion open when the query contains spaces', () => {
|
||||
expect(shouldAllowCompletionQuery('/', 'export ')).toBe(true);
|
||||
expect(shouldAllowCompletionQuery('/', 'export md')).toBe(true);
|
||||
});
|
||||
|
||||
it('still blocks @ completion when the query contains spaces', () => {
|
||||
expect(shouldAllowCompletionQuery('@', 'foo bar')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildSlashCommandItems', () => {
|
||||
it('returns top-level slash commands for prefix queries', () => {
|
||||
const items = buildSlashCommandItems('exp', availableCommands);
|
||||
|
||||
expect(items.map((item) => item.id)).toContain('export');
|
||||
});
|
||||
|
||||
it('returns export subcommands for an exact /export parent query', () => {
|
||||
const items = buildSlashCommandItems('export ', availableCommands);
|
||||
|
||||
expect(items.map((item) => item.value)).toEqual([
|
||||
'export html',
|
||||
'export md',
|
||||
'export json',
|
||||
'export jsonl',
|
||||
]);
|
||||
});
|
||||
|
||||
it('filters export subcommands by the typed child query', () => {
|
||||
const items = buildSlashCommandItems('export j', availableCommands);
|
||||
|
||||
expect(items.map((item) => item.value)).toEqual([
|
||||
'export json',
|
||||
'export jsonl',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isExpandableSlashCommand', () => {
|
||||
it('marks /export as an expandable command', () => {
|
||||
expect(isExpandableSlashCommand('export')).toBe(true);
|
||||
expect(isExpandableSlashCommand('help')).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,91 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { AvailableCommand } from '@agentclientprotocol/sdk';
|
||||
import type { CompletionItem } from '../../types/completionItemTypes.js';
|
||||
import {
|
||||
EXPORT_PARENT_COMMAND_NAME,
|
||||
EXPORT_SUBCOMMAND_SPECS,
|
||||
} from '../../utils/exportSlashCommand.js';
|
||||
|
||||
export function shouldAllowCompletionQuery(
|
||||
trigger: '@' | '/',
|
||||
query: string,
|
||||
): boolean {
|
||||
if (query.includes('\n')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (trigger === '/') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return !query.includes(' ');
|
||||
}
|
||||
|
||||
export function isExpandableSlashCommand(commandName: string): boolean {
|
||||
return commandName === EXPORT_PARENT_COMMAND_NAME;
|
||||
}
|
||||
|
||||
function matchesQuery(
|
||||
query: string,
|
||||
label: string,
|
||||
description?: string,
|
||||
): boolean {
|
||||
const normalizedQuery = query.toLowerCase();
|
||||
return (
|
||||
label.toLowerCase().includes(normalizedQuery) ||
|
||||
(description?.toLowerCase().includes(normalizedQuery) ?? false)
|
||||
);
|
||||
}
|
||||
|
||||
function buildExportSubcommandItems(childQuery: string): CompletionItem[] {
|
||||
return EXPORT_SUBCOMMAND_SPECS.filter((subcommand) =>
|
||||
matchesQuery(
|
||||
childQuery,
|
||||
`/export ${subcommand.name}`,
|
||||
subcommand.description,
|
||||
),
|
||||
).map((subcommand) => ({
|
||||
id: `export:${subcommand.name}`,
|
||||
label: `/${EXPORT_PARENT_COMMAND_NAME} ${subcommand.name}`,
|
||||
description: subcommand.description,
|
||||
type: 'command' as const,
|
||||
group: 'Slash Commands',
|
||||
value: `${EXPORT_PARENT_COMMAND_NAME} ${subcommand.name}`,
|
||||
}));
|
||||
}
|
||||
|
||||
export function buildSlashCommandItems(
|
||||
query: string,
|
||||
availableCommands: readonly AvailableCommand[],
|
||||
): CompletionItem[] {
|
||||
const normalizedQuery = query.trimStart().toLowerCase();
|
||||
|
||||
if (
|
||||
normalizedQuery === EXPORT_PARENT_COMMAND_NAME ||
|
||||
normalizedQuery.startsWith(`${EXPORT_PARENT_COMMAND_NAME} `)
|
||||
) {
|
||||
const childQuery =
|
||||
normalizedQuery === EXPORT_PARENT_COMMAND_NAME
|
||||
? ''
|
||||
: normalizedQuery
|
||||
.slice(EXPORT_PARENT_COMMAND_NAME.length + 1)
|
||||
.trimStart();
|
||||
return buildExportSubcommandItems(childQuery);
|
||||
}
|
||||
|
||||
return availableCommands
|
||||
.map((cmd) => ({
|
||||
id: cmd.name,
|
||||
label: `/${cmd.name}`,
|
||||
description: cmd.description,
|
||||
type: 'command' as const,
|
||||
group: 'Slash Commands',
|
||||
value: cmd.name,
|
||||
}))
|
||||
.filter((item) => matchesQuery(query, item.label, item.description));
|
||||
}
|
||||
|
|
@ -10,7 +10,8 @@
|
|||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"paths": {
|
||||
"@lydell/node-pty": ["../../node_modules/@lydell/node-pty/node-pty.d.ts"]
|
||||
"@lydell/node-pty": ["../../node_modules/@lydell/node-pty/node-pty.d.ts"],
|
||||
"@qwen-code/qwen-code/export": ["../cli/src/export/index.ts"]
|
||||
}
|
||||
/* Additional Checks */
|
||||
// "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */
|
||||
|
|
|
|||
|
|
@ -1,6 +1,15 @@
|
|||
import path from 'node:path';
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
'@qwen-code/qwen-code/export': path.resolve(
|
||||
__dirname,
|
||||
'../cli/src/export/index.ts',
|
||||
),
|
||||
},
|
||||
},
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'node',
|
||||
|
|
|
|||
|
|
@ -0,0 +1,75 @@
|
|||
// @vitest-environment jsdom
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { flushSync } from 'react-dom';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { MarkdownRenderer } from './MarkdownRenderer.js';
|
||||
|
||||
let root: Root | null = null;
|
||||
let container: HTMLDivElement | null = null;
|
||||
|
||||
function renderMarkdown(
|
||||
content: string,
|
||||
onFileClick = vi.fn(),
|
||||
enableFileLinks = false,
|
||||
) {
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
|
||||
flushSync(() => {
|
||||
root?.render(
|
||||
<MarkdownRenderer
|
||||
content={content}
|
||||
onFileClick={onFileClick}
|
||||
enableFileLinks={enableFileLinks}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
return { onFileClick };
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
flushSync(() => {
|
||||
root?.unmount();
|
||||
});
|
||||
container?.remove();
|
||||
root = null;
|
||||
container = null;
|
||||
});
|
||||
|
||||
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)',
|
||||
);
|
||||
|
||||
const anchor = container?.querySelector('a');
|
||||
expect(anchor).toBeTruthy();
|
||||
|
||||
anchor?.dispatchEvent(
|
||||
new MouseEvent('click', { bubbles: true, cancelable: true }),
|
||||
);
|
||||
|
||||
expect(onFileClick).toHaveBeenCalledWith('/tmp/my dir/export.html');
|
||||
});
|
||||
|
||||
it('converts markdown file links with line fragments into vscode paths', () => {
|
||||
const { onFileClick } = renderMarkdown('[app.ts](/tmp/src/app.ts#L12)');
|
||||
|
||||
const anchor = container?.querySelector('a');
|
||||
expect(anchor).toBeTruthy();
|
||||
|
||||
anchor?.dispatchEvent(
|
||||
new MouseEvent('click', { bubbles: true, cancelable: true }),
|
||||
);
|
||||
|
||||
expect(onFileClick).toHaveBeenCalledWith('/tmp/src/app.ts:12');
|
||||
});
|
||||
});
|
||||
|
|
@ -29,10 +29,50 @@ const FILE_PATH_REGEX =
|
|||
const FILE_PATH_WITH_LINES_REGEX =
|
||||
/(?:[a-zA-Z]:)?[/\\](?:[\w\-. ]+[/\\])+[\w\-. ]+\.(tsx?|jsx?|css|scss|json|md|py|java|go|rs|c|cpp|h|hpp|sh|yaml|yml|toml|xml|html|vue|svelte)#(\d+)(?:-(\d+))?/gi;
|
||||
|
||||
// Known file extensions for validation
|
||||
// Known file extensions for validation of explicit markdown links
|
||||
const KNOWN_FILE_EXTENSIONS =
|
||||
/\.(tsx?|jsx?|css|scss|json|md|py|java|go|rs|c|cpp|h|hpp|sh|ya?ml|toml|xml|html|vue|svelte)$/i;
|
||||
|
||||
const safeDecodePath = (value: string): string => {
|
||||
try {
|
||||
return decodeURIComponent(value);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
};
|
||||
|
||||
const normalizeExplicitFileLink = (raw: string): string => {
|
||||
const decoded = safeDecodePath(raw).replace(/\\/g, '/');
|
||||
|
||||
// file:// URIs (e.g. from vscode.Uri.file().toString()) encode special
|
||||
// characters like # as %23 in the path component. After decoding the
|
||||
// full URI we can strip the scheme and return the filesystem path
|
||||
// directly — no fragment splitting needed, because any # in the
|
||||
// decoded result is a literal filename character, not an anchor.
|
||||
if (/^file:\/\//i.test(decoded)) {
|
||||
let filePath = decoded.replace(/^file:\/\/\//i, '');
|
||||
// On Unix the path should start with /
|
||||
if (!/^[a-zA-Z]:/.test(filePath) && !filePath.startsWith('/')) {
|
||||
filePath = '/' + filePath;
|
||||
}
|
||||
return filePath;
|
||||
}
|
||||
|
||||
const hashIndex = decoded.indexOf('#');
|
||||
if (hashIndex < 0) {
|
||||
return decoded;
|
||||
}
|
||||
|
||||
const base = decoded.slice(0, hashIndex);
|
||||
const fragment = decoded.slice(hashIndex + 1);
|
||||
const lineMatch = fragment.match(/^L?(\d+)(?:-\d+)?$/i);
|
||||
if (lineMatch) {
|
||||
return `${base}:${parseInt(lineMatch[1], 10)}`;
|
||||
}
|
||||
|
||||
return base;
|
||||
};
|
||||
|
||||
/**
|
||||
* Escape HTML characters for security
|
||||
*/
|
||||
|
|
@ -98,7 +138,6 @@ export const MarkdownRenderer: FC<MarkdownRendererProps> = ({
|
|||
'gi',
|
||||
);
|
||||
|
||||
// Convert a "path#fragment" into VS Code friendly "path:line"
|
||||
const normalizePathAndLine = (
|
||||
raw: string,
|
||||
): { displayText: string; dataPath: string } => {
|
||||
|
|
@ -293,14 +332,12 @@ export const MarkdownRenderer: FC<MarkdownRendererProps> = ({
|
|||
// Event delegation: intercept clicks on generated file-path links
|
||||
const handleContainerClick = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
|
||||
if (!enableFileLinks) {
|
||||
return;
|
||||
}
|
||||
const target = e.target as HTMLElement | null;
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for file-path-link (created by processFilePaths when enableFileLinks=true)
|
||||
const anchor = (target.closest &&
|
||||
target.closest('a.file-path-link')) as HTMLAnchorElement | null;
|
||||
if (anchor) {
|
||||
|
|
@ -314,6 +351,11 @@ export const MarkdownRenderer: FC<MarkdownRendererProps> = ({
|
|||
return;
|
||||
}
|
||||
|
||||
// Handle explicit markdown links (file:// URIs and normal file-path hrefs).
|
||||
// file:// URIs come from trusted system-generated content (e.g. /export).
|
||||
// Normal file-path links (absolute or with known extensions) are also
|
||||
// supported so that intentional markdown links remain clickable even
|
||||
// when enableFileLinks is false.
|
||||
const anyAnchor = (target.closest &&
|
||||
target.closest('a')) as HTMLAnchorElement | null;
|
||||
if (!anyAnchor) {
|
||||
|
|
@ -321,28 +363,39 @@ export const MarkdownRenderer: FC<MarkdownRendererProps> = ({
|
|||
}
|
||||
|
||||
const href = anyAnchor.getAttribute('href') || '';
|
||||
if (!/^https?:\/\//i.test(href)) {
|
||||
|
||||
// Handle file:// URI links (e.g. from /export success messages).
|
||||
if (/^file:\/\//i.test(href) && onFileClick) {
|
||||
const candidate = normalizeExplicitFileLink(href);
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onFileClick(candidate);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const url = new URL(href);
|
||||
const host = url.hostname || '';
|
||||
const path = url.pathname || '';
|
||||
const noPath = path === '' || path === '/';
|
||||
|
||||
// Only treat as file if host has a known file extension
|
||||
if (noPath && KNOWN_FILE_EXTENSIONS.test(host)) {
|
||||
const text = (anyAnchor.textContent || '').trim();
|
||||
const candidate = KNOWN_FILE_EXTENSIONS.test(text) ? text : host;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onFileClick?.(candidate);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
// Skip external links — let browser handle them normally
|
||||
if (/^(https?|mailto|ftp|data):/i.test(href)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle explicit markdown file-path links (e.g. [filename](/path/to/file))
|
||||
// even when enableFileLinks=false, so intentional links like Export Session
|
||||
// output remain clickable.
|
||||
const text = (anyAnchor.textContent || '').trim();
|
||||
const candidate = normalizeExplicitFileLink(href || text);
|
||||
|
||||
const isAbsolutePath = /^(?:[a-zA-Z]:[/\\]|[/\\])/i.test(candidate);
|
||||
const isRelativeFile =
|
||||
!isAbsolutePath &&
|
||||
KNOWN_FILE_EXTENSIONS.test(candidate.replace(/:\d+(?::\d+)?$/, ''));
|
||||
|
||||
if ((isAbsolutePath || isRelativeFile) && onFileClick) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onFileClick(candidate);
|
||||
}
|
||||
},
|
||||
[enableFileLinks, onFileClick],
|
||||
[onFileClick],
|
||||
);
|
||||
|
||||
return (
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue