mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-04-28 11:41:04 +00:00
* 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
250 lines
7.3 KiB
JavaScript
250 lines
7.3 KiB
JavaScript
/**
|
|
* @license
|
|
* Copyright 2025 Google LLC
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
import esbuild from 'esbuild';
|
|
import { createRequire } from 'node:module';
|
|
import { readFileSync } from 'node:fs';
|
|
import { dirname, resolve } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { wasmLoader } from 'esbuild-plugin-wasm';
|
|
|
|
const production = process.argv.includes('--production');
|
|
const watch = process.argv.includes('--watch');
|
|
|
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
const repoRoot = resolve(__dirname, '..', '..');
|
|
const rootRequire = createRequire(resolve(repoRoot, 'package.json'));
|
|
|
|
/**
|
|
* @type {import('esbuild').Plugin}
|
|
*/
|
|
const esbuildProblemMatcherPlugin = {
|
|
name: 'esbuild-problem-matcher',
|
|
|
|
setup(build) {
|
|
const isWatchMode = build.initialOptions.watch;
|
|
build.onStart(() => {
|
|
if (isWatchMode) {
|
|
console.log('[watch] build started');
|
|
}
|
|
});
|
|
build.onEnd((result) => {
|
|
result.errors.forEach(({ text, location }) => {
|
|
console.error(`✘ [ERROR] ${text}`);
|
|
console.error(
|
|
` ${location.file}:${location.line}:${location.column}:`,
|
|
);
|
|
});
|
|
if (isWatchMode) {
|
|
console.log('[watch] build finished');
|
|
}
|
|
});
|
|
},
|
|
};
|
|
|
|
/**
|
|
* Ensure a single React copy in the webview bundle by resolving from repo root.
|
|
* Prevents mixing React 18/19 element types when nested node_modules exist.
|
|
* @type {import('esbuild').Plugin}
|
|
*/
|
|
const resolveFromRoot = (moduleId) => {
|
|
try {
|
|
return rootRequire.resolve(moduleId);
|
|
} catch {
|
|
return null;
|
|
}
|
|
};
|
|
|
|
const reactDedupPlugin = {
|
|
name: 'react-dedup',
|
|
setup(build) {
|
|
const aliases = [
|
|
'react',
|
|
'react-dom',
|
|
'react-dom/client',
|
|
'react/jsx-runtime',
|
|
'react/jsx-dev-runtime',
|
|
];
|
|
|
|
for (const alias of aliases) {
|
|
build.onResolve({ filter: new RegExp(`^${alias}$`) }, () => {
|
|
const resolved = resolveFromRoot(alias);
|
|
if (!resolved) {
|
|
return undefined;
|
|
}
|
|
return { path: resolved };
|
|
});
|
|
}
|
|
},
|
|
};
|
|
|
|
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.
|
|
* @type {import('esbuild').Plugin}
|
|
*/
|
|
const wasmBinaryPlugin = {
|
|
name: 'wasm-binary',
|
|
setup(build) {
|
|
build.onResolve({ filter: /\.wasm\?binary$/ }, (args) => {
|
|
const specifier = args.path.replace(/\?binary$/, '');
|
|
const localRequire = createRequire(
|
|
resolve(args.resolveDir || repoRoot, '_dummy_.js'),
|
|
);
|
|
return {
|
|
path: localRequire.resolve(specifier),
|
|
namespace: 'wasm-binary',
|
|
};
|
|
});
|
|
|
|
build.onLoad({ filter: /.*/, namespace: 'wasm-binary' }, (args) => ({
|
|
contents: readFileSync(args.path),
|
|
loader: 'binary',
|
|
}));
|
|
},
|
|
};
|
|
|
|
/**
|
|
* @type {import('esbuild').Plugin}
|
|
*/
|
|
const cssInjectPlugin = {
|
|
name: 'css-inject',
|
|
setup(build) {
|
|
// Handle CSS files
|
|
build.onLoad({ filter: /\.css$/ }, async (args) => {
|
|
const fs = await import('fs');
|
|
const postcss = (await import('postcss')).default;
|
|
const tailwindcss = (await import('tailwindcss')).default;
|
|
const autoprefixer = (await import('autoprefixer')).default;
|
|
|
|
let css = await fs.promises.readFile(args.path, 'utf8');
|
|
|
|
// For styles.css, we need to resolve @import statements
|
|
if (args.path.endsWith('styles.css')) {
|
|
// Read all imported CSS files and inline them
|
|
const importRegex = /@import\s+'([^']+)';/g;
|
|
let match;
|
|
const basePath = args.path.substring(0, args.path.lastIndexOf('/'));
|
|
while ((match = importRegex.exec(css)) !== null) {
|
|
const importPath = match[1];
|
|
// Resolve relative paths correctly
|
|
let fullPath;
|
|
if (importPath.startsWith('./')) {
|
|
fullPath = basePath + importPath.substring(1);
|
|
} else if (importPath.startsWith('../')) {
|
|
fullPath = basePath + '/' + importPath;
|
|
} else {
|
|
fullPath = basePath + '/' + importPath;
|
|
}
|
|
|
|
try {
|
|
const importedCss = await fs.promises.readFile(fullPath, 'utf8');
|
|
css = css.replace(match[0], importedCss);
|
|
} catch (err) {
|
|
console.warn(`Could not import ${fullPath}: ${err.message}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Process with PostCSS (Tailwind + Autoprefixer)
|
|
const result = await postcss([tailwindcss, autoprefixer]).process(css, {
|
|
from: args.path,
|
|
to: args.path,
|
|
});
|
|
|
|
return {
|
|
contents: `
|
|
const style = document.createElement('style');
|
|
style.textContent = ${JSON.stringify(result.css)};
|
|
document.head.appendChild(style);
|
|
`,
|
|
loader: 'js',
|
|
};
|
|
});
|
|
},
|
|
};
|
|
|
|
async function main() {
|
|
// Build extension
|
|
const extensionCtx = await esbuild.context({
|
|
entryPoints: ['src/extension.ts'],
|
|
bundle: true,
|
|
format: 'cjs',
|
|
minify: production,
|
|
sourcemap: !production,
|
|
sourcesContent: false,
|
|
platform: 'node',
|
|
outfile: 'dist/extension.cjs',
|
|
external: ['vscode'],
|
|
logLevel: 'silent',
|
|
banner: {
|
|
js: `const import_meta = { url: require('url').pathToFileURL(__filename).href };`,
|
|
},
|
|
define: {
|
|
'import.meta.url': 'import_meta.url',
|
|
},
|
|
plugins: [
|
|
publicCliExportPlugin,
|
|
wasmBinaryPlugin,
|
|
wasmLoader({ mode: 'embedded' }),
|
|
/* add to the end of plugins array */
|
|
esbuildProblemMatcherPlugin,
|
|
],
|
|
loader: { '.node': 'file' },
|
|
});
|
|
|
|
// Build webview
|
|
const webviewCtx = await esbuild.context({
|
|
entryPoints: ['src/webview/index.tsx'],
|
|
bundle: true,
|
|
format: 'iife',
|
|
minify: production,
|
|
sourcemap: !production,
|
|
sourcesContent: false,
|
|
platform: 'browser',
|
|
outfile: 'dist/webview.js',
|
|
// @qwen-code/qwen-code-core is a peer dependency of @qwen-code/webui.
|
|
// Since @qwen-code/webui marks it as external in its own Vite build, the
|
|
// browser bundle must also mark it external to avoid bundling Node.js-only
|
|
// modules (undici, @grpc/grpc-js, fs, stream, etc.) into the webview.
|
|
// The wildcard ensures deep sub-path imports (e.g.
|
|
// '@qwen-code/qwen-code-core/src/core/tokenLimits.js') are also excluded;
|
|
// without it esbuild only matches the bare package name and attempts to
|
|
// bundle the sub-path, which triggers "Dynamic require is not supported"
|
|
// at runtime in the browser.
|
|
external: ['@qwen-code/qwen-code-core', '@qwen-code/qwen-code-core/*'],
|
|
logLevel: 'silent',
|
|
plugins: [reactDedupPlugin, cssInjectPlugin, esbuildProblemMatcherPlugin],
|
|
jsx: 'automatic', // Use new JSX transform (React 17+)
|
|
loader: {
|
|
'.png': 'dataurl',
|
|
},
|
|
define: {
|
|
'process.env.NODE_ENV': production ? '"production"' : '"development"',
|
|
},
|
|
});
|
|
|
|
if (watch) {
|
|
await Promise.all([extensionCtx.watch(), webviewCtx.watch()]);
|
|
} else {
|
|
await Promise.all([extensionCtx.rebuild(), webviewCtx.rebuild()]);
|
|
await Promise.all([extensionCtx.dispose(), webviewCtx.dispose()]);
|
|
}
|
|
}
|
|
|
|
main().catch((e) => {
|
|
console.error(e);
|
|
process.exit(1);
|
|
});
|