diff --git a/packages/cli/src/ui/commands/exportCommand.test.ts b/packages/cli/src/ui/commands/exportCommand.test.ts index 6550c225fb..bb59842136 100644 --- a/packages/cli/src/ui/commands/exportCommand.test.ts +++ b/packages/cli/src/ui/commands/exportCommand.test.ts @@ -103,7 +103,7 @@ describe('exportCommand', () => { it('should have correct name and description', () => { expect(exportCommand.name).toBe('export'); expect(exportCommand.description).toBe( - 'Export current session message history to a file', + 'Export current session to HTML by default. Other formats: md, json, jsonl.', ); }); diff --git a/packages/cli/src/ui/commands/exportCommand.ts b/packages/cli/src/ui/commands/exportCommand.ts index 755a7061e5..659b931893 100644 --- a/packages/cli/src/ui/commands/exportCommand.ts +++ b/packages/cli/src/ui/commands/exportCommand.ts @@ -322,7 +322,9 @@ async function exportJsonlAction( export const exportCommand: SlashCommand = { name: 'export', get description() { - return t('Export current session message history to a file'); + return t( + 'Export current session to HTML by default. Other formats: md, json, jsonl.', + ); }, kind: CommandKind.BUILT_IN, subCommands: [ diff --git a/packages/vscode-ide-companion/src/services/sessionExportService.test.ts b/packages/vscode-ide-companion/src/services/sessionExportService.test.ts new file mode 100644 index 0000000000..6e610ef676 --- /dev/null +++ b/packages/vscode-ide-companion/src/services/sessionExportService.test.ts @@ -0,0 +1,182 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { + mockLoadSession, + mockCollectSessionData, + mockNormalizeSessionData, + mockToHtml, + mockToMarkdown, + mockToJson, + mockToJsonl, + mockGenerateExportFilename, + mockShowSaveDialog, + mockWriteFile, +} = 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(), + mockShowSaveDialog: vi.fn(), + mockWriteFile: vi.fn(), +})); + +vi.mock('@qwen-code/qwen-code-core', () => { + class SessionService { + constructor(_cwd: string) {} + + async loadSession(_sessionId: string) { + return mockLoadSession(); + } + } + + return { + SessionService, + }; +}); + +vi.mock('../../../cli/src/ui/utils/export/index.js', () => ({ + 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('export'); + 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('defaults to html for bare /export', () => { + expect(parseExportSlashCommand('/export')).toBe('html'); + expect(parseExportSlashCommand('/export ')).toBe('html'); + }); + + it('returns the requested export format', () => { + 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', + ); + expect(() => parseExportSlashCommand('/export md extra')).toThrow( + 'Unsupported /export format', + ); + }); + }); + + describe('exportSessionToFile', () => { + it('writes the exported session to the user-selected file', async () => { + mockShowSaveDialog.mockResolvedValue({ + fsPath: '/workspace/custom-export.html', + }); + + 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(mockWriteFile).toHaveBeenCalledWith( + '/workspace/custom-export.html', + 'export', + 'utf-8', + ); + expect(result).toEqual({ + cancelled: false, + filename: 'custom-export.html', + uri: { fsPath: '/workspace/custom-export.html' }, + }); + }); + + it('returns cancelled when the save dialog is dismissed', async () => { + mockShowSaveDialog.mockResolvedValue(undefined); + + const result = await exportSessionToFile({ + sessionId: 'session-1', + cwd: '/workspace', + format: 'md', + }); + + expect(mockWriteFile).not.toHaveBeenCalled(); + expect(result).toEqual({ cancelled: true }); + }); + + 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.'); + }); + }); +}); diff --git a/packages/vscode-ide-companion/src/services/sessionExportService.ts b/packages/vscode-ide-companion/src/services/sessionExportService.ts new file mode 100644 index 0000000000..fa3dc9817b --- /dev/null +++ b/packages/vscode-ide-companion/src/services/sessionExportService.ts @@ -0,0 +1,140 @@ +/** + * @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 '../../../cli/src/ui/utils/export/index.js'; + +export const SESSION_EXPORT_FORMATS = ['html', 'md', 'json', 'jsonl'] as const; + +export type SessionExportFormat = (typeof SESSION_EXPORT_FORMATS)[number]; + +export interface SessionExportResult { + cancelled: boolean; + filename?: string; + uri?: vscode.Uri; +} + +const EXPORT_CONFIG = { + getChannel: () => 'vscode-companion', + getToolRegistry: () => undefined, +} as unknown as Config; + +const SAVE_DIALOG_FILTERS: Record< + SessionExportFormat, + Record +> = { + html: { HTML: ['html'] }, + md: { Markdown: ['md'] }, + json: { JSON: ['json'] }, + jsonl: { JSONL: ['jsonl'] }, +}; + +function isSessionExportFormat(value: string): value is SessionExportFormat { + return SESSION_EXPORT_FORMATS.includes(value as SessionExportFormat); +} + +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) { + return 'html'; + } + + const normalizedFormat = format.toLowerCase(); + if (rest.length === 0 && isSessionExportFormat(normalizedFormat)) { + return normalizedFormat; + } + + throw new Error( + 'Unsupported /export format. Use /export, /export html, /export md, /export json, or /export jsonl.', + ); +} + +function renderExportContent( + format: SessionExportFormat, + normalizedData: Awaited>, +): 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 async function exportSessionToFile(options: { + sessionId: string; + cwd: string; + format: SessionExportFormat; +}): Promise { + 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 filename = generateExportFilename(format); + const uri = await vscode.window.showSaveDialog({ + defaultUri: vscode.Uri.file(path.join(cwd, filename)), + filters: SAVE_DIALOG_FILTERS[format], + saveLabel: 'Export Session', + title: 'Export Session', + }); + + if (!uri) { + return { cancelled: true }; + } + + await fs.writeFile(uri.fsPath, content, 'utf-8'); + + return { + cancelled: false, + filename: path.basename(uri.fsPath), + uri, + }; +} diff --git a/packages/vscode-ide-companion/src/webview/handlers/SessionMessageHandler.test.ts b/packages/vscode-ide-companion/src/webview/handlers/SessionMessageHandler.test.ts index 32484c943e..3f9fed66b5 100644 --- a/packages/vscode-ide-companion/src/webview/handlers/SessionMessageHandler.test.ts +++ b/packages/vscode-ide-companion/src/webview/handlers/SessionMessageHandler.test.ts @@ -6,17 +6,23 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -const { mockProcessImageAttachments, mockShowErrorMessage } = vi.hoisted( - () => ({ - mockProcessImageAttachments: vi.fn(), - mockShowErrorMessage: vi.fn(), - }), -); +const { + mockProcessImageAttachments, + mockShowErrorMessage, + mockShowInformationMessage, + mockExportSessionToFile, +} = vi.hoisted(() => ({ + mockProcessImageAttachments: vi.fn(), + mockShowErrorMessage: vi.fn(), + mockShowInformationMessage: vi.fn(), + mockExportSessionToFile: vi.fn(), +})); vi.mock('vscode', () => ({ window: { showWarningMessage: vi.fn(), showErrorMessage: mockShowErrorMessage, + showInformationMessage: mockShowInformationMessage, }, commands: { executeCommand: vi.fn(), @@ -35,6 +41,20 @@ vi.mock('../utils/imageHandler.js', async (importOriginal) => { }; }); +vi.mock('../../services/sessionExportService.js', () => ({ + parseExportSlashCommand: (text: string) => { + const trimmed = text.trim(); + if (trimmed === '/export') { + return 'html'; + } + if (trimmed === '/export md') { + return 'md'; + } + return null; + }, + exportSessionToFile: mockExportSessionToFile, +})); + import { SessionMessageHandler } from './SessionMessageHandler.js'; describe('SessionMessageHandler', () => { @@ -46,6 +66,12 @@ describe('SessionMessageHandler', () => { savedImageCount: 0, promptImages: [], }); + mockShowInformationMessage.mockResolvedValue(undefined); + mockExportSessionToFile.mockResolvedValue({ + cancelled: false, + filename: 'export.html', + uri: { fsPath: '/workspace/export.html' }, + }); }); it('does not create conversation state or send an empty prompt when all pasted images fail to materialize', async () => { @@ -161,4 +187,89 @@ describe('SessionMessageHandler', () => { }, ]); }); + + it('intercepts /export 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', + }, + }); + + expect(mockExportSessionToFile).toHaveBeenCalledWith({ + sessionId: 'session-1', + cwd: '/workspace', + format: 'html', + }); + expect(conversationStore.addMessage).not.toHaveBeenCalled(); + expect(agentManager.sendMessage).not.toHaveBeenCalled(); + expect(mockShowInformationMessage).toHaveBeenCalledWith( + 'Session exported to HTML: export.html', + 'Open File', + ); + }); + + 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(mockShowErrorMessage).toHaveBeenCalledWith( + 'Failed to export session: disk full', + ); + expect(sendToWebView).toHaveBeenCalledWith({ + type: 'error', + data: { message: 'Failed to export session: disk full' }, + }); + expect(agentManager.sendMessage).not.toHaveBeenCalled(); + }); }); diff --git a/packages/vscode-ide-companion/src/webview/handlers/SessionMessageHandler.ts b/packages/vscode-ide-companion/src/webview/handlers/SessionMessageHandler.ts index 2ee1e6dd86..f81e0801c0 100644 --- a/packages/vscode-ide-companion/src/webview/handlers/SessionMessageHandler.ts +++ b/packages/vscode-ide-companion/src/webview/handlers/SessionMessageHandler.ts @@ -15,6 +15,11 @@ 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'; /** * Session message handler @@ -268,6 +273,69 @@ export class SessionMessageHandler extends BaseMessageHandler { return isAuthenticationRequiredError(error); } + private async resolveSessionWorkingDir(sessionId: string): Promise { + 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 { + const sessionId = + this.currentConversationId ?? this.agentManager.currentSessionId; + if (!sessionId) { + const errorMsg = 'No active session found to export.'; + vscode.window.showErrorMessage(errorMsg); + this.sendToWebView({ + type: 'error', + data: { message: errorMsg }, + }); + return; + } + + try { + const cwd = await this.resolveSessionWorkingDir(sessionId); + const result = await exportSessionToFile({ sessionId, cwd, format }); + if (result.cancelled || !result.filename || !result.uri) { + return; + } + + const formatLabel = format.toUpperCase(); + const selection = await vscode.window.showInformationMessage( + `Session exported to ${formatLabel}: ${result.filename}`, + 'Open File', + ); + + if (selection === 'Open File') { + await vscode.commands.executeCommand('vscode.open', result.uri); + } + } catch (error) { + const errorMsg = this.getErrorMessage(error); + console.error('[SessionMessageHandler] Failed to export session:', error); + vscode.window.showErrorMessage(`Failed to export session: ${errorMsg}`); + this.sendToWebView({ + type: 'error', + data: { message: `Failed to export session: ${errorMsg}` }, + }); + } + } + /** * Handle send message request */ @@ -299,6 +367,22 @@ 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); + vscode.window.showErrorMessage(errorMsg); + this.sendToWebView({ + type: 'error', + data: { message: errorMsg }, + }); + return; + } + let displayText = trimmedText ? text : ''; let promptText = text; if (context && context.length > 0) {