feat: 文件浏览器支持图片预览并更新发布包
This commit is contained in:
139
public/app.js
139
public/app.js
@@ -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('');
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user