feat: 文件浏览器支持图片预览并更新发布包

This commit is contained in:
shiyue
2026-10-06 12:01:44 +08:00
parent 75989cb4de
commit caf8947cd9
16 changed files with 558 additions and 17 deletions

View File

@@ -1641,6 +1641,58 @@ function assertFrontendMarkdownLinkContract() {
assert(styleSource.includes('.msg-bubble a.local-file-link'), 'Local file links should have a distinct message style');
}
function assertFrontendFileBrowserImageContract() {
const source = fs.readFileSync(PUBLIC_APP_PATH, 'utf8');
const styles = fs.readFileSync(PUBLIC_STYLE_PATH, 'utf8');
const browser = source.slice(source.indexOf('const FILE_BROWSER_IMAGE_EXTENSIONS'), source.indexOf('function syncAttachmentActions'));
assert(browser.includes("new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'svg', 'avif'])"), 'File browser should recognize common image extensions');
assert(browser.includes('entry.imagePreviewableHint ? \'图片\''), 'Directory listing should prioritize the image hint, including SVG');
assert(browser.includes("imagePreviewable: button.dataset.imagePreviewable === 'true'"), 'Directory click should forward the server image hint');
assert(browser.includes('isFileBrowserImagePath(normalizedPath)'), 'Local file links should recognize image paths without directory hints');
assert(browser.includes('/api/fs/image?sessionId='), 'Image previews should use the authenticated image endpoint');
assert(browser.includes('/api/fs/read?sessionId='), 'Text previews should retain the text endpoint');
assert(source.includes('async function fetchAuthImage(url, signal)') && source.includes('Authorization: `Bearer ${authToken}`'), 'Image fetch should use Bearer authentication');
assert(browser.includes("mimeType === 'image/svg+xml'") && browser.includes('reader.readAsDataURL(blob);'), 'SVG previews should use isolated data URLs');
assert(browser.includes('URL.createObjectURL(blob)') && browser.includes('URL.revokeObjectURL(state.previewImageUrl)'), 'Raster previews should manage object URL lifetime');
assert(browser.includes('state.previewAbortController?.abort()') && browser.includes('requestId !== state.previewRequestId'), 'Switching previews should abort or reject stale image requests');
assert(browser.includes('cancelFileBrowserPreview(state)') && browser.includes('clearFileBrowserImagePreview(fileBrowserState)'), 'Closing and switching views should release image resources');
assert(browser.includes('state.previewImageNode.onerror = () =>'), 'Image decode failures should enter an error state');
assert(browser.includes('data-browser-preview-image-node') && styles.includes('.file-browser-preview-image[hidden]'), 'Image container should preserve preview layout on desktop and mobile');
const lifecycle = source.slice(source.indexOf('function clearFileBrowserImagePreview(state)'), source.indexOf('function updateFileBrowserPathBar()'));
const released = [];
let aborted = 0;
const state = {
previewRequestId: 3,
previewAbortController: { abort() { aborted += 1; } },
previewImageUrl: 'blob:first',
previewImageEl: { hidden: false },
previewImageNode: {
src: 'blob:first',
onload() {},
onerror() {},
removeAttribute(name) { if (name === 'src') this.src = ''; },
},
previewTitleEl: { textContent: '' },
previewMetaEl: { textContent: '' },
previewEmptyEl: { textContent: '', hidden: true },
previewCodeEl: { textContent: 'old text', hidden: false },
overlay: { parentNode: { removeChild() {} } },
escapeHandler() {},
};
const createLifecycle = new Function('state', 'URL', 'document', `let fileBrowserState = state;\n${lifecycle}\nreturn { cancelFileBrowserPreview, renderFileBrowserPreviewLoading, closeFileBrowser };`);
const actions = createLifecycle(state, { revokeObjectURL(url) { released.push(url); } }, { removeEventListener() {} });
actions.cancelFileBrowserPreview(state);
assert(aborted === 1 && state.previewRequestId === 4 && state.previewImageUrl === '', 'Switching previews should abort the old request and invalidate its response');
assert(released[0] === 'blob:first' && state.previewImageNode.src === '' && state.previewImageEl.hidden, 'Switching previews should clear and revoke the old image');
state.previewImageUrl = 'blob:second';
actions.renderFileBrowserPreviewLoading('notes.txt');
assert(released[1] === 'blob:second' && state.previewCodeEl.textContent === '' && state.previewCodeEl.hidden, 'Switching to text should remove the prior image and old text');
state.previewImageUrl = 'blob:third';
actions.closeFileBrowser();
assert(released[2] === 'blob:third' && state.previewRequestId === 5, 'Closing the browser should release its image and invalidate pending responses');
}
function assertMarkdownRenderingContract() {
const source = fs.readFileSync(PUBLIC_APP_PATH, 'utf8');
const indexSource = fs.readFileSync(PUBLIC_INDEX_PATH, 'utf8');
@@ -7128,6 +7180,96 @@ async function runFilePreviewEncodingRegression() {
});
}
async function runFileBrowserImageRegression() {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'cc-web-file-browser-image-'));
const configDir = path.join(tempRoot, 'config');
const sessionsDir = path.join(tempRoot, 'sessions');
const logsDir = path.join(tempRoot, 'logs');
const workspace = path.join(tempRoot, 'workspace');
mkdirp(configDir); mkdirp(sessionsDir); mkdirp(logsDir); mkdirp(workspace);
const svg = '<svg xmlns="http://www.w3.org/2000/svg" width="2" height="2"><script>alert(1)</script></svg>';
const png = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+nAf8AAAAASUVORK5CYII=', 'base64');
const fixtures = [
['tiny.PNG', 'image/png', png],
['portrait.JpEg', 'image/jpeg', Buffer.from([0xff, 0xd8, 0xff, 0xd9])],
['photo.jpg', 'image/jpeg', Buffer.from([0xff, 0xd8, 0xff, 0xd9])],
['motion.gif', 'image/gif', Buffer.from('GIF89a', 'ascii')],
['image.webp', 'image/webp', Buffer.from('RIFF0000WEBP', 'ascii')],
['image.bmp', 'image/bmp', Buffer.from('BM', 'ascii')],
['shape.SvG', 'image/svg+xml', Buffer.from(svg, 'utf8')],
['image.avif', 'image/avif', Buffer.from('ftypavif', 'ascii')],
['空 格&图?.png', 'image/png', png],
];
fixtures.forEach(([filename, , bytes]) => fs.writeFileSync(path.join(workspace, filename), bytes));
fs.writeFileSync(path.join(workspace, 'notes.txt'), '文本预览仍然可用', 'utf8');
fs.writeFileSync(path.join(workspace, 'binary.bin'), Buffer.from([0, 0xff, 1]));
fs.symlinkSync('tiny.PNG', path.join(workspace, 'linked-image.data'));
fs.symlinkSync('notes.txt', path.join(workspace, 'linked-text.png'));
const outsideImage = path.join(tempRoot, 'outside.png');
fs.writeFileSync(outsideImage, fixtures[0][2]);
fs.symlinkSync(outsideImage, path.join(workspace, 'outside-link.png'));
const maxImageBytes = 20 * 1024 * 1024;
fs.writeFileSync(path.join(workspace, 'limit.png'), Buffer.from([0x89]));
fs.truncateSync(path.join(workspace, 'limit.png'), maxImageBytes);
fs.writeFileSync(path.join(workspace, 'oversized.png'), Buffer.from([0x89]));
fs.truncateSync(path.join(workspace, 'oversized.png'), maxImageBytes + 1);
const sessionId = 'file-browser-image-session';
fs.writeFileSync(path.join(sessionsDir, `${sessionId}.json`), JSON.stringify({ id: sessionId, cwd: workspace, title: '图片预览回归', messages: [] }));
const port = await getFreePort();
const password = 'FileBrowserImage!234';
await withServer({ PORT: String(port), CC_WEB_PASSWORD: password, CC_WEB_CONFIG_DIR: configDir, CC_WEB_SESSIONS_DIR: sessionsDir, CC_WEB_LOGS_DIR: logsDir, HOME: tempRoot, CLAUDE_PATH: MOCK_CLAUDE, CODEX_PATH: MOCK_CODEX_APP_SERVER }, async () => {
const { ws, messages, token } = await connectWs(port, password);
await nextMessage(messages, ws, (msg) => msg.type === 'session_list');
const imageUrl = (targetPath) => `http://127.0.0.1:${port}/api/fs/image?sessionId=${sessionId}&path=${encodeURIComponent(targetPath)}`;
const authed = { Authorization: `Bearer ${token}` };
const list = await fetchAuthedJson(port, token, `/api/fs/list?sessionId=${sessionId}`);
for (const [filename, mimeType, bytes] of fixtures) {
const entry = list.entries.find((item) => item.name === filename);
assert(entry?.imagePreviewableHint === true, `${filename} should be listed as a previewable image`);
const response = await fetch(imageUrl(filename), { headers: authed });
assert(response.status === 200, `${filename} should be previewable, got ${response.status}`);
assert(response.headers.get('content-type') === mimeType, `${filename} should use ${mimeType}`);
assert(response.headers.get('x-content-type-options') === 'nosniff', `${filename} should disable MIME sniffing`);
assert(Buffer.from(await response.arrayBuffer()).equals(bytes), `${filename} should return original image bytes`);
}
assert(list.entries.find((item) => item.name === 'shape.SvG')?.previewableHint, 'SVG may also have a text hint; image must take precedence');
assert(list.entries.find((item) => item.name === 'linked-image.data')?.imagePreviewableHint, 'Image symlink should use the target format');
assert(list.entries.find((item) => item.name === 'linked-text.png')?.imagePreviewableHint === false, 'Text symlink should not be presented as an image');
assert(!list.entries.some((item) => item.name === 'outside-link.png'), 'Outside symlink should be hidden in directory listing');
const linkedResponse = await fetch(imageUrl('linked-image.data'), { headers: authed });
assert(linkedResponse.status === 200 && linkedResponse.headers.get('content-type') === 'image/png', 'Image symlink should use the target MIME type');
await linkedResponse.body.cancel();
const exactLimit = await fetch(imageUrl('limit.png'), { headers: authed });
assert(exactLimit.status === 200, 'Exactly 20 MiB should remain previewable');
await exactLimit.body.cancel();
const rejectionCases = [
['oversized.png', 413],
['notes.txt', 415],
['linked-text.png', 415],
['binary.bin', 415],
['.', 400],
['../outside.png', 403],
['outside-link.png', 403],
];
for (const [targetPath, status] of rejectionCases) {
const response = await fetch(imageUrl(targetPath), { headers: authed });
const payload = await response.json();
assert(response.status === status && payload.ok === false, `${targetPath} should reject with ${status}, got ${response.status}`);
if (targetPath === 'oversized.png') assert(/20 MiB/.test(payload.message), 'Oversized image should report the 20 MiB limit');
}
const anonymous = await fetch(imageUrl('tiny.PNG'));
assert(anonymous.status === 401, 'Unauthenticated image request should return 401');
const text = await fetchAuthedJson(port, token, `/api/fs/read?sessionId=${sessionId}&path=notes.txt`);
assert(text.content === '文本预览仍然可用', 'Text preview should remain usable');
const binary = await fetch(`http://127.0.0.1:${port}/api/fs/read?sessionId=${sessionId}&path=binary.bin`, { headers: authed });
assert(binary.status === 415, 'Existing binary text preview rejection should remain intact');
ws.close();
});
}
function assertWindowsStartupContract() {
const source = fs.readFileSync(WINDOWS_START_PATH, 'utf8').replace(/\r\n/g, '\n');
@@ -7363,6 +7505,12 @@ async function main() {
console.log('File preview encoding regression checks passed.');
return;
}
if (regressionTarget === 'file-browser-image') {
assertFrontendFileBrowserImageContract();
await runFileBrowserImageRegression();
console.log('File browser image regression checks passed.');
return;
}
if (regressionTarget === 'usage-statistics') {
assertUsageStatisticsUnitChecks();
assertUsageStatisticsContract();
@@ -7419,6 +7567,7 @@ async function main() {
assertComposerSlashRoutingContract();
assertFrontendCcwebPromptContract();
assertFrontendMarkdownLinkContract();
assertFrontendFileBrowserImageContract();
assertMarkdownRenderingContract();
assertMockCodexAppPromptUserNotTextTriggered();
assertFrontendMcpReloadContract();
@@ -7451,6 +7600,7 @@ async function main() {
assertCcwebDisplayImageContract();
assertCcwebListConversationsScopeContract();
await runSessionPreviewMetadataRegression();
await runFileBrowserImageRegression();
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'cc-web-regression-'));
const configDir = path.join(tempRoot, 'config');