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

@@ -3063,6 +3063,26 @@
return data || {};
}
async function fetchAuthImage(url, signal) {
await ensureAuthenticatedWs();
const response = await fetch(url, {
signal,
headers: { Authorization: `Bearer ${authToken}` },
});
if (response.status === 401) {
throw new Error('登录状态已失效,请刷新页面后重新登录。');
}
if (!response.ok) {
const data = await response.json().catch(() => null);
throw new Error(data?.message || `请求失败 (${response.status})`);
}
const mimeType = (response.headers.get('Content-Type') || '').split(';')[0].trim().toLowerCase();
if (!FILE_BROWSER_IMAGE_MIME_TYPES.has(mimeType)) {
throw new Error('图片响应格式不受支持');
}
return { blob: await response.blob(), mimeType };
}
function updateReloadMcpButtonUI() {
if (!reloadMcpBtn) return;
const visible = !!currentSessionId && isCodexAppAgent(currentAgent);
@@ -4118,9 +4138,36 @@
loadDirectoryPickerDirectory(String(options.initialPath || '').trim(), { allowFallback: true });
}
const FILE_BROWSER_IMAGE_EXTENSIONS = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'svg', 'avif']);
const FILE_BROWSER_IMAGE_MIME_TYPES = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp', 'image/bmp', 'image/svg+xml', 'image/avif']);
function isFileBrowserImagePath(filePath) {
const extension = String(filePath || '').match(/\.([a-z0-9]+)$/i)?.[1];
return !!extension && FILE_BROWSER_IMAGE_EXTENSIONS.has(extension.toLowerCase());
}
function clearFileBrowserImagePreview(state) {
state.previewImageNode.onload = null;
state.previewImageNode.onerror = null;
state.previewImageNode.removeAttribute('src');
state.previewImageEl.hidden = true;
if (state.previewImageUrl) URL.revokeObjectURL(state.previewImageUrl);
state.previewImageUrl = '';
}
function cancelFileBrowserPreview(state) {
state.previewRequestId += 1;
state.previewAbortController?.abort();
state.previewAbortController = null;
clearFileBrowserImagePreview(state);
return state.previewRequestId;
}
function closeFileBrowser() {
if (!fileBrowserState) return;
const { overlay, escapeHandler } = fileBrowserState;
const state = fileBrowserState;
cancelFileBrowserPreview(state);
const { overlay, escapeHandler } = state;
if (escapeHandler) document.removeEventListener('keydown', escapeHandler);
if (overlay && overlay.parentNode) overlay.parentNode.removeChild(overlay);
fileBrowserState = null;
@@ -4149,19 +4196,22 @@
function renderFileBrowserPreviewEmpty(title, message) {
if (!fileBrowserState) return;
clearFileBrowserImagePreview(fileBrowserState);
fileBrowserState.previewTitleEl.textContent = title || '文件预览';
fileBrowserState.previewMetaEl.textContent = message || '选择一个文本文件查看内容';
fileBrowserState.previewEmptyEl.textContent = message || '选择一个文本文件查看内容';
fileBrowserState.previewMetaEl.textContent = message || '选择一个文件查看内容';
fileBrowserState.previewEmptyEl.textContent = message || '选择一个文件查看内容';
fileBrowserState.previewEmptyEl.hidden = false;
fileBrowserState.previewCodeEl.hidden = true;
fileBrowserState.previewCodeEl.textContent = '';
}
function renderFileBrowserPreviewLoading(name) {
function renderFileBrowserPreviewLoading(name, image = false) {
if (!fileBrowserState) return;
clearFileBrowserImagePreview(fileBrowserState);
const message = image ? '正在读取图片…' : '正在读取文件内容…';
fileBrowserState.previewTitleEl.textContent = name || '文件预览';
fileBrowserState.previewMetaEl.textContent = '正在读取文件内容…';
fileBrowserState.previewEmptyEl.textContent = '正在读取文件内容…';
fileBrowserState.previewMetaEl.textContent = message;
fileBrowserState.previewEmptyEl.textContent = message;
fileBrowserState.previewEmptyEl.hidden = false;
fileBrowserState.previewCodeEl.hidden = true;
fileBrowserState.previewCodeEl.textContent = '';
@@ -4190,7 +4240,7 @@
if (entry.kind === 'directory') {
metaParts.push(entry.symlink ? '链接目录' : '目录');
} else {
metaParts.push(entry.previewableHint ? '文本' : '文件');
metaParts.push(entry.imagePreviewableHint ? '图片' : (entry.previewableHint ? '文本' : '文件'));
if (entry.size >= 0) metaParts.push(formatFileSize(entry.size));
}
if (entry.updatedAt) metaParts.push(timeAgo(entry.updatedAt));
@@ -4200,8 +4250,9 @@
type="button"
data-kind="${escapeHtml(entry.kind)}"
data-path="${escapeHtml(entry.path || '')}"
data-image-previewable="${entry.imagePreviewableHint ? 'true' : 'false'}"
>
<span class="file-browser-item-icon" aria-hidden="true">${entry.kind === 'directory' ? 'DIR' : (entry.previewableHint ? 'TXT' : 'FILE')}</span>
<span class="file-browser-item-icon" aria-hidden="true">${entry.kind === 'directory' ? 'DIR' : (entry.imagePreviewableHint ? 'IMG' : (entry.previewableHint ? 'TXT' : 'FILE'))}</span>
<span class="file-browser-item-copy">
<span class="file-browser-item-name">${escapeHtml(entry.name || '')}</span>
<span class="file-browser-item-meta">${escapeHtml(metaParts.join(' · '))}</span>
@@ -4217,7 +4268,7 @@
loadFileBrowserDirectory(itemPath);
return;
}
openFileBrowserFile(itemPath);
openFileBrowserFile(itemPath, { imagePreviewable: button.dataset.imagePreviewable === 'true' });
});
});
@@ -4235,9 +4286,10 @@
state.listEl.innerHTML = '<div class="modal-loading">正在读取目录…</div>';
setFileBrowserStatus('正在读取目录…');
if (!options.preservePreview) {
cancelFileBrowserPreview(state);
state.selectedFilePath = '';
syncFileBrowserSelection();
renderFileBrowserPreviewEmpty('文件预览', '选择一个文本文件查看内容');
renderFileBrowserPreviewEmpty('文件预览', '选择一个文件查看内容');
setFileBrowserPreviewMode(false);
}
@@ -4276,15 +4328,61 @@
if (!fileBrowserState) return;
const state = fileBrowserState;
const normalizedPath = normalizeBrowserPath(targetPath);
const imagePreviewable = options.imagePreviewable === true
|| (options.imagePreviewable == null && isFileBrowserImagePath(normalizedPath));
const targetLine = Math.max(0, Number(options.line || 0) || 0);
const requestId = ++state.previewRequestId;
const requestId = cancelFileBrowserPreview(state);
const controller = new AbortController();
state.previewAbortController = controller;
state.selectedFilePath = normalizedPath;
syncFileBrowserSelection();
renderFileBrowserPreviewLoading(normalizedPath.split('/').pop() || '文件预览');
renderFileBrowserPreviewLoading(normalizedPath.split('/').pop() || '文件预览', imagePreviewable);
setFileBrowserPreviewMode(true);
try {
const data = await fetchAuthJson(`/api/fs/read?sessionId=${encodeURIComponent(state.sessionId)}&path=${encodeURIComponent(normalizedPath)}`);
if (imagePreviewable) {
const { blob, mimeType } = await fetchAuthImage(
`/api/fs/image?sessionId=${encodeURIComponent(state.sessionId)}&path=${encodeURIComponent(normalizedPath)}`,
controller.signal
);
if (state !== fileBrowserState || requestId !== state.previewRequestId) return;
const imageUrl = mimeType === 'image/svg+xml'
? await new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = () => reject(reader.error || new Error('图片读取失败'));
reader.readAsDataURL(blob);
})
: URL.createObjectURL(blob);
if (state !== fileBrowserState || requestId !== state.previewRequestId) {
if (mimeType !== 'image/svg+xml') URL.revokeObjectURL(imageUrl);
return;
}
if (mimeType !== 'image/svg+xml') state.previewImageUrl = imageUrl;
const name = normalizedPath.split('/').pop() || '图片预览';
state.previewImageNode.alt = name;
state.previewImageNode.onload = () => {
if (state !== fileBrowserState || requestId !== state.previewRequestId) return;
state.previewTitleEl.textContent = name;
state.previewMetaEl.textContent = `${formatFileSize(blob.size)} · ${mimeType}`;
state.previewEmptyEl.hidden = true;
state.previewImageEl.hidden = false;
setFileBrowserStatus(`已打开 ${name}`);
};
state.previewImageNode.onerror = () => {
if (state !== fileBrowserState || requestId !== state.previewRequestId) return;
state.selectedFilePath = '';
syncFileBrowserSelection();
renderFileBrowserPreviewEmpty('无法预览', '图片加载失败');
setFileBrowserStatus('图片加载失败', 'error');
};
state.previewImageNode.src = imageUrl;
return;
}
const data = await fetchAuthJson(
`/api/fs/read?sessionId=${encodeURIComponent(state.sessionId)}&path=${encodeURIComponent(normalizedPath)}`,
{ signal: controller.signal }
);
if (!fileBrowserState || state !== fileBrowserState || requestId !== state.previewRequestId) return;
state.selectedFilePath = normalizeBrowserPath(data.path || normalizedPath);
syncFileBrowserSelection();
@@ -4306,6 +4404,8 @@
syncFileBrowserSelection();
renderFileBrowserPreviewEmpty('无法预览', err.message || '当前文件无法打开');
setFileBrowserStatus(err.message || '当前文件无法打开', 'error');
} finally {
if (state.previewAbortController === controller) state.previewAbortController = null;
}
}
@@ -4348,11 +4448,14 @@
<button class="file-browser-mobile-back" type="button" data-browser-back>返回目录</button>
<div class="file-browser-preview-copy">
<div class="file-browser-preview-title" data-browser-preview-title>文件预览</div>
<div class="file-browser-preview-meta" data-browser-preview-meta>选择一个文本文件查看内容</div>
<div class="file-browser-preview-meta" data-browser-preview-meta>选择一个文件查看内容</div>
</div>
</div>
<div class="file-browser-preview-empty" data-browser-preview-empty>选择一个文本文件查看内容</div>
<div class="file-browser-preview-empty" data-browser-preview-empty>选择一个文件查看内容</div>
<pre class="file-browser-preview-content" data-browser-preview-content hidden></pre>
<div class="file-browser-preview-image" data-browser-preview-image hidden>
<img data-browser-preview-image-node alt="">
</div>
</section>
</div>
</div>
@@ -4368,6 +4471,8 @@
selectedFilePath: '',
directoryRequestId: 0,
previewRequestId: 0,
previewAbortController: null,
previewImageUrl: '',
overlay,
panel: overlay.querySelector('.file-browser-panel'),
pathEl: overlay.querySelector('[data-browser-path]'),
@@ -4377,6 +4482,8 @@
previewMetaEl: overlay.querySelector('[data-browser-preview-meta]'),
previewEmptyEl: overlay.querySelector('[data-browser-preview-empty]'),
previewCodeEl: overlay.querySelector('[data-browser-preview-content]'),
previewImageEl: overlay.querySelector('[data-browser-preview-image]'),
previewImageNode: overlay.querySelector('[data-browser-preview-image-node]'),
upBtn: overlay.querySelector('[data-browser-up]'),
refreshBtn: overlay.querySelector('[data-browser-refresh]'),
mobileBackBtn: overlay.querySelector('[data-browser-back]'),
@@ -4404,7 +4511,7 @@
});
updateFileBrowserPathBar();
renderFileBrowserPreviewEmpty('文件预览', '选择一个文本文件查看内容');
renderFileBrowserPreviewEmpty('文件预览', '选择一个文件查看内容');
loadFileBrowserDirectory('');
}