feat: update session workspace flow and web ui
This commit is contained in:
749
public/app.js
749
public/app.js
@@ -100,6 +100,8 @@
|
||||
let loginPasswordValue = ''; // store login password for force-change flow
|
||||
let currentCwd = null;
|
||||
let currentSessionRunning = false;
|
||||
let fileBrowserState = null;
|
||||
let directoryPickerState = null;
|
||||
let skipDeleteConfirm = localStorage.getItem('cc-web-skip-delete-confirm') === '1';
|
||||
let pendingInitialSessionLoad = false;
|
||||
|
||||
@@ -552,6 +554,495 @@
|
||||
return `${(size / (1024 * 1024)).toFixed(1)}MB`;
|
||||
}
|
||||
|
||||
function normalizeBrowserPath(input) {
|
||||
return String(input || '')
|
||||
.replace(/\\/g, '/')
|
||||
.split('/')
|
||||
.filter((part) => part && part !== '.')
|
||||
.join('/');
|
||||
}
|
||||
|
||||
function getPathLeaf(input) {
|
||||
const normalized = String(input || '').replace(/\\/g, '/').replace(/\/+$/, '');
|
||||
if (!normalized) return '';
|
||||
const parts = normalized.split('/');
|
||||
return parts[parts.length - 1] || normalized;
|
||||
}
|
||||
|
||||
function getBrowserParentPath(currentPath) {
|
||||
const normalized = normalizeBrowserPath(currentPath);
|
||||
if (!normalized) return '';
|
||||
const parts = normalized.split('/');
|
||||
parts.pop();
|
||||
return parts.join('/');
|
||||
}
|
||||
|
||||
function getBrowserDisplayPath(rootPath, currentPath) {
|
||||
const root = String(rootPath || '').replace(/\\/g, '/').replace(/\/+$/, '');
|
||||
const current = normalizeBrowserPath(currentPath);
|
||||
return current ? `${root}/${current}` : root;
|
||||
}
|
||||
|
||||
async function fetchAuthJson(url, options = {}) {
|
||||
await ensureAuthenticatedWs();
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
...(options.headers || {}),
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
},
|
||||
});
|
||||
const rawText = await response.text();
|
||||
let data = null;
|
||||
try {
|
||||
data = rawText ? JSON.parse(rawText) : null;
|
||||
} catch {
|
||||
data = null;
|
||||
}
|
||||
if (response.status === 401) {
|
||||
throw new Error('登录状态已失效,请刷新页面后重新登录。');
|
||||
}
|
||||
if (!response.ok || data?.ok === false) {
|
||||
throw new Error(data?.message || `请求失败 (${response.status})`);
|
||||
}
|
||||
return data || {};
|
||||
}
|
||||
|
||||
function closeDirectoryPicker() {
|
||||
if (!directoryPickerState) return;
|
||||
const { overlay, escapeHandler } = directoryPickerState;
|
||||
if (escapeHandler) document.removeEventListener('keydown', escapeHandler);
|
||||
if (overlay && overlay.parentNode) overlay.parentNode.removeChild(overlay);
|
||||
directoryPickerState = null;
|
||||
}
|
||||
|
||||
function setDirectoryPickerStatus(message, type = '') {
|
||||
if (!directoryPickerState?.statusEl) return;
|
||||
directoryPickerState.statusEl.textContent = message || '';
|
||||
directoryPickerState.statusEl.dataset.state = type || '';
|
||||
}
|
||||
|
||||
function updateDirectoryPickerPathBar() {
|
||||
if (!directoryPickerState?.pathEl) return;
|
||||
const displayPath = directoryPickerState.currentPath || directoryPickerState.defaultPath || '';
|
||||
directoryPickerState.pathEl.textContent = displayPath;
|
||||
directoryPickerState.pathEl.title = displayPath;
|
||||
directoryPickerState.upBtn.disabled = !directoryPickerState.parentPath;
|
||||
directoryPickerState.chooseBtn.disabled = !displayPath;
|
||||
}
|
||||
|
||||
function renderDirectoryPickerEntries(entries) {
|
||||
if (!directoryPickerState?.listEl) return;
|
||||
const safeEntries = Array.isArray(entries) ? entries : [];
|
||||
if (safeEntries.length === 0) {
|
||||
directoryPickerState.listEl.innerHTML = '<div class="modal-empty">这个目录里没有可进入的子目录,直接使用当前目录也可以。</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
directoryPickerState.listEl.innerHTML = safeEntries.map((entry) => {
|
||||
const metaParts = [entry.symlink ? '链接目录' : '目录'];
|
||||
if (entry.updatedAt) metaParts.push(timeAgo(entry.updatedAt));
|
||||
return `
|
||||
<button
|
||||
class="file-browser-item directory"
|
||||
type="button"
|
||||
data-path="${escapeHtml(entry.path || '')}"
|
||||
>
|
||||
<span class="file-browser-item-icon" aria-hidden="true">DIR</span>
|
||||
<span class="file-browser-item-copy">
|
||||
<span class="file-browser-item-name">${escapeHtml(entry.name || getPathLeaf(entry.path) || '')}</span>
|
||||
<span class="file-browser-item-meta">${escapeHtml(metaParts.join(' · '))}</span>
|
||||
</span>
|
||||
</button>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
directoryPickerState.listEl.querySelectorAll('.file-browser-item').forEach((button) => {
|
||||
button.addEventListener('click', () => {
|
||||
const targetPath = button.dataset.path || '';
|
||||
if (targetPath) loadDirectoryPickerDirectory(targetPath);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function loadDirectoryPickerDirectory(targetPath, options = {}) {
|
||||
if (!directoryPickerState) return;
|
||||
const state = directoryPickerState;
|
||||
const requestId = ++state.requestId;
|
||||
state.listEl.innerHTML = '<div class="modal-loading">正在读取目录…</div>';
|
||||
setDirectoryPickerStatus('正在读取目录…');
|
||||
|
||||
try {
|
||||
const data = await fetchAuthJson(`/api/fs/directories?path=${encodeURIComponent(targetPath || '')}`);
|
||||
if (!directoryPickerState || state !== directoryPickerState || requestId !== state.requestId) return;
|
||||
state.currentPath = data.currentPath || state.currentPath;
|
||||
state.parentPath = data.parentPath || '';
|
||||
state.defaultPath = data.defaultPath || state.defaultPath;
|
||||
updateDirectoryPickerPathBar();
|
||||
renderDirectoryPickerEntries(data.entries || []);
|
||||
const statusParts = [];
|
||||
if (data.truncated) statusParts.push(`目录较大,仅显示前 ${data.entryLimit} 项`);
|
||||
statusParts.push(`当前共 ${Array.isArray(data.entries) ? data.entries.length : 0} 个子目录`);
|
||||
setDirectoryPickerStatus(statusParts.join(' · '));
|
||||
} catch (err) {
|
||||
if (!directoryPickerState || state !== directoryPickerState || requestId !== state.requestId) return;
|
||||
if (options.allowFallback !== false && targetPath) {
|
||||
loadDirectoryPickerDirectory('', { allowFallback: false });
|
||||
return;
|
||||
}
|
||||
state.listEl.innerHTML = `<div class="modal-empty">${escapeHtml(err.message || '目录读取失败')}</div>`;
|
||||
setDirectoryPickerStatus(err.message || '目录读取失败', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function showDirectoryPicker(options = {}) {
|
||||
closeDirectoryPicker();
|
||||
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'modal-overlay';
|
||||
overlay.id = 'directory-picker-overlay';
|
||||
overlay.innerHTML = `
|
||||
<div class="modal-panel modal-panel-wide directory-picker-panel">
|
||||
<div class="modal-header">
|
||||
<span class="modal-title">${escapeHtml(options.title || '选择工作目录')}</span>
|
||||
<button class="modal-close-btn" type="button" data-picker-close>✕</button>
|
||||
</div>
|
||||
<div class="modal-body file-browser-body">
|
||||
<div class="file-browser-toolbar">
|
||||
<button class="file-browser-toolbar-btn" type="button" data-picker-up>上一级</button>
|
||||
<button class="file-browser-toolbar-btn" type="button" data-picker-refresh>刷新</button>
|
||||
<div class="file-browser-path" data-picker-path></div>
|
||||
</div>
|
||||
<div class="file-browser-status" data-picker-status>正在读取目录…</div>
|
||||
<div class="file-browser-list directory-picker-list" data-picker-list></div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="modal-btn-secondary" type="button" data-picker-cancel>取消</button>
|
||||
<button class="modal-btn-primary" type="button" data-picker-choose disabled>使用当前目录</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
directoryPickerState = {
|
||||
overlay,
|
||||
pathEl: overlay.querySelector('[data-picker-path]'),
|
||||
statusEl: overlay.querySelector('[data-picker-status]'),
|
||||
listEl: overlay.querySelector('[data-picker-list]'),
|
||||
upBtn: overlay.querySelector('[data-picker-up]'),
|
||||
refreshBtn: overlay.querySelector('[data-picker-refresh]'),
|
||||
chooseBtn: overlay.querySelector('[data-picker-choose]'),
|
||||
currentPath: '',
|
||||
parentPath: '',
|
||||
defaultPath: '',
|
||||
requestId: 0,
|
||||
onChoose: typeof options.onChoose === 'function' ? options.onChoose : null,
|
||||
escapeHandler: null,
|
||||
};
|
||||
|
||||
directoryPickerState.escapeHandler = (e) => {
|
||||
if (e.key === 'Escape') closeDirectoryPicker();
|
||||
};
|
||||
document.addEventListener('keydown', directoryPickerState.escapeHandler);
|
||||
|
||||
const closeButtons = overlay.querySelectorAll('[data-picker-close], [data-picker-cancel]');
|
||||
closeButtons.forEach((button) => button.addEventListener('click', closeDirectoryPicker));
|
||||
overlay.addEventListener('click', (e) => {
|
||||
if (e.target === overlay) closeDirectoryPicker();
|
||||
});
|
||||
directoryPickerState.upBtn.addEventListener('click', () => {
|
||||
if (directoryPickerState?.parentPath) loadDirectoryPickerDirectory(directoryPickerState.parentPath, { allowFallback: false });
|
||||
});
|
||||
directoryPickerState.refreshBtn.addEventListener('click', () => {
|
||||
loadDirectoryPickerDirectory(directoryPickerState?.currentPath || '', { allowFallback: false });
|
||||
});
|
||||
directoryPickerState.chooseBtn.addEventListener('click', () => {
|
||||
const selectedPath = directoryPickerState?.currentPath || directoryPickerState?.defaultPath || '';
|
||||
const onChoose = directoryPickerState?.onChoose;
|
||||
closeDirectoryPicker();
|
||||
if (selectedPath && typeof onChoose === 'function') onChoose(selectedPath);
|
||||
});
|
||||
|
||||
updateDirectoryPickerPathBar();
|
||||
loadDirectoryPickerDirectory(String(options.initialPath || '').trim(), { allowFallback: true });
|
||||
}
|
||||
|
||||
function closeFileBrowser() {
|
||||
if (!fileBrowserState) return;
|
||||
const { overlay, escapeHandler } = fileBrowserState;
|
||||
if (escapeHandler) document.removeEventListener('keydown', escapeHandler);
|
||||
if (overlay && overlay.parentNode) overlay.parentNode.removeChild(overlay);
|
||||
fileBrowserState = null;
|
||||
}
|
||||
|
||||
function setFileBrowserStatus(message, type = '') {
|
||||
if (!fileBrowserState?.statusEl) return;
|
||||
fileBrowserState.statusEl.textContent = message || '';
|
||||
fileBrowserState.statusEl.dataset.state = type || '';
|
||||
}
|
||||
|
||||
function setFileBrowserPreviewMode(active) {
|
||||
if (!fileBrowserState?.panel) return;
|
||||
fileBrowserState.panel.classList.toggle('preview-active', !!active);
|
||||
}
|
||||
|
||||
function syncFileBrowserSelection() {
|
||||
if (!fileBrowserState?.listEl) return;
|
||||
fileBrowserState.listEl.querySelectorAll('.file-browser-item').forEach((button) => {
|
||||
button.classList.toggle(
|
||||
'active',
|
||||
button.dataset.kind === 'file' && button.dataset.path === fileBrowserState.selectedFilePath
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function renderFileBrowserPreviewEmpty(title, message) {
|
||||
if (!fileBrowserState) return;
|
||||
fileBrowserState.previewTitleEl.textContent = title || '文件预览';
|
||||
fileBrowserState.previewMetaEl.textContent = message || '选择一个文本文件查看内容';
|
||||
fileBrowserState.previewEmptyEl.textContent = message || '选择一个文本文件查看内容';
|
||||
fileBrowserState.previewEmptyEl.hidden = false;
|
||||
fileBrowserState.previewCodeEl.hidden = true;
|
||||
fileBrowserState.previewCodeEl.textContent = '';
|
||||
}
|
||||
|
||||
function renderFileBrowserPreviewLoading(name) {
|
||||
if (!fileBrowserState) return;
|
||||
fileBrowserState.previewTitleEl.textContent = name || '文件预览';
|
||||
fileBrowserState.previewMetaEl.textContent = '正在读取文件内容…';
|
||||
fileBrowserState.previewEmptyEl.textContent = '正在读取文件内容…';
|
||||
fileBrowserState.previewEmptyEl.hidden = false;
|
||||
fileBrowserState.previewCodeEl.hidden = true;
|
||||
fileBrowserState.previewCodeEl.textContent = '';
|
||||
}
|
||||
|
||||
function updateFileBrowserPathBar() {
|
||||
if (!fileBrowserState) return;
|
||||
const displayPath = getBrowserDisplayPath(fileBrowserState.rootPath, fileBrowserState.currentPath);
|
||||
fileBrowserState.pathEl.textContent = displayPath;
|
||||
fileBrowserState.pathEl.title = displayPath;
|
||||
fileBrowserState.upBtn.disabled = !fileBrowserState.currentPath;
|
||||
}
|
||||
|
||||
function renderFileBrowserDirectory(entries) {
|
||||
if (!fileBrowserState) return;
|
||||
const state = fileBrowserState;
|
||||
const safeEntries = Array.isArray(entries) ? entries : [];
|
||||
|
||||
if (safeEntries.length === 0) {
|
||||
state.listEl.innerHTML = '<div class="modal-empty">这个目录里还没有可显示的文件</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
state.listEl.innerHTML = safeEntries.map((entry) => {
|
||||
const metaParts = [];
|
||||
if (entry.kind === 'directory') {
|
||||
metaParts.push(entry.symlink ? '链接目录' : '目录');
|
||||
} else {
|
||||
metaParts.push(entry.previewableHint ? '文本' : '文件');
|
||||
if (entry.size >= 0) metaParts.push(formatFileSize(entry.size));
|
||||
}
|
||||
if (entry.updatedAt) metaParts.push(timeAgo(entry.updatedAt));
|
||||
return `
|
||||
<button
|
||||
class="file-browser-item${entry.kind === 'directory' ? ' directory' : ''}"
|
||||
type="button"
|
||||
data-kind="${escapeHtml(entry.kind)}"
|
||||
data-path="${escapeHtml(entry.path || '')}"
|
||||
>
|
||||
<span class="file-browser-item-icon" aria-hidden="true">${entry.kind === 'directory' ? 'DIR' : (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>
|
||||
</span>
|
||||
</button>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
state.listEl.querySelectorAll('.file-browser-item').forEach((button) => {
|
||||
button.addEventListener('click', () => {
|
||||
const itemPath = normalizeBrowserPath(button.dataset.path || '');
|
||||
if (button.dataset.kind === 'directory') {
|
||||
loadFileBrowserDirectory(itemPath);
|
||||
return;
|
||||
}
|
||||
openFileBrowserFile(itemPath);
|
||||
});
|
||||
});
|
||||
|
||||
syncFileBrowserSelection();
|
||||
}
|
||||
|
||||
async function loadFileBrowserDirectory(targetPath, options = {}) {
|
||||
if (!fileBrowserState) return;
|
||||
const state = fileBrowserState;
|
||||
const normalizedPath = normalizeBrowserPath(targetPath);
|
||||
const previousPath = state.currentPath;
|
||||
const requestId = ++state.directoryRequestId;
|
||||
state.currentPath = normalizedPath;
|
||||
updateFileBrowserPathBar();
|
||||
state.listEl.innerHTML = '<div class="modal-loading">正在读取目录…</div>';
|
||||
setFileBrowserStatus('正在读取目录…');
|
||||
if (!options.preservePreview) {
|
||||
state.selectedFilePath = '';
|
||||
syncFileBrowserSelection();
|
||||
renderFileBrowserPreviewEmpty('文件预览', '选择一个文本文件查看内容');
|
||||
setFileBrowserPreviewMode(false);
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await fetchAuthJson(`/api/fs/list?sessionId=${encodeURIComponent(state.sessionId)}&path=${encodeURIComponent(normalizedPath)}`);
|
||||
if (!fileBrowserState || state !== fileBrowserState || requestId !== state.directoryRequestId) return;
|
||||
state.rootPath = data.rootPath || state.rootPath;
|
||||
state.currentPath = normalizeBrowserPath(data.currentPath || '');
|
||||
updateFileBrowserPathBar();
|
||||
renderFileBrowserDirectory(data.entries || []);
|
||||
const statusParts = [];
|
||||
if (data.truncated) statusParts.push(`目录较大,仅显示前 ${data.entryLimit} 项`);
|
||||
statusParts.push(`当前共 ${Array.isArray(data.entries) ? data.entries.length : 0} 项`);
|
||||
setFileBrowserStatus(statusParts.join(' · '));
|
||||
} catch (err) {
|
||||
if (!fileBrowserState || state !== fileBrowserState || requestId !== state.directoryRequestId) return;
|
||||
state.currentPath = previousPath;
|
||||
updateFileBrowserPathBar();
|
||||
state.listEl.innerHTML = `<div class="modal-empty">${escapeHtml(err.message || '目录读取失败')}</div>`;
|
||||
setFileBrowserStatus(err.message || '目录读取失败', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function openFileBrowserFile(targetPath) {
|
||||
if (!fileBrowserState) return;
|
||||
const state = fileBrowserState;
|
||||
const normalizedPath = normalizeBrowserPath(targetPath);
|
||||
const requestId = ++state.previewRequestId;
|
||||
state.selectedFilePath = normalizedPath;
|
||||
syncFileBrowserSelection();
|
||||
renderFileBrowserPreviewLoading(normalizedPath.split('/').pop() || '文件预览');
|
||||
setFileBrowserPreviewMode(true);
|
||||
|
||||
try {
|
||||
const data = await fetchAuthJson(`/api/fs/read?sessionId=${encodeURIComponent(state.sessionId)}&path=${encodeURIComponent(normalizedPath)}`);
|
||||
if (!fileBrowserState || state !== fileBrowserState || requestId !== state.previewRequestId) return;
|
||||
state.selectedFilePath = normalizeBrowserPath(data.path || normalizedPath);
|
||||
syncFileBrowserSelection();
|
||||
state.previewTitleEl.textContent = data.name || '文件预览';
|
||||
const metaParts = [formatFileSize(data.size || 0)];
|
||||
if (data.updatedAt) metaParts.push(timeAgo(data.updatedAt));
|
||||
if (data.truncated) metaParts.push(`仅显示前 ${formatFileSize(data.previewBytes || 0)}`);
|
||||
state.previewMetaEl.textContent = metaParts.join(' · ');
|
||||
state.previewEmptyEl.hidden = true;
|
||||
state.previewCodeEl.hidden = false;
|
||||
state.previewCodeEl.textContent = data.content || '';
|
||||
setFileBrowserStatus(`已打开 ${data.name || '文件'}`);
|
||||
} catch (err) {
|
||||
if (!fileBrowserState || state !== fileBrowserState || requestId !== state.previewRequestId) return;
|
||||
state.selectedFilePath = '';
|
||||
syncFileBrowserSelection();
|
||||
renderFileBrowserPreviewEmpty('无法预览', err.message || '当前文件无法打开');
|
||||
setFileBrowserStatus(err.message || '当前文件无法打开', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function showFileBrowser() {
|
||||
if (!currentSessionId) {
|
||||
showToast('请先打开一个会话');
|
||||
return;
|
||||
}
|
||||
if (!currentCwd) {
|
||||
showToast('当前会话没有可浏览的工作目录');
|
||||
return;
|
||||
}
|
||||
if (fileBrowserState && fileBrowserState.sessionId === currentSessionId) return;
|
||||
|
||||
closeFileBrowser();
|
||||
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'modal-overlay';
|
||||
overlay.id = 'file-browser-overlay';
|
||||
overlay.innerHTML = `
|
||||
<div class="modal-panel modal-panel-wide file-browser-panel">
|
||||
<div class="modal-header">
|
||||
<span class="modal-title">文件浏览器</span>
|
||||
<button class="modal-close-btn" type="button" data-browser-close>✕</button>
|
||||
</div>
|
||||
<div class="modal-body file-browser-body">
|
||||
<div class="file-browser-toolbar">
|
||||
<button class="file-browser-toolbar-btn" type="button" data-browser-up>上一级</button>
|
||||
<button class="file-browser-toolbar-btn" type="button" data-browser-refresh>刷新</button>
|
||||
<div class="file-browser-path" data-browser-path></div>
|
||||
</div>
|
||||
<div class="file-browser-status" data-browser-status>正在准备目录…</div>
|
||||
<div class="file-browser-layout">
|
||||
<section class="file-browser-pane file-browser-list-pane">
|
||||
<div class="file-browser-pane-title">目录与文件</div>
|
||||
<div class="file-browser-list" data-browser-list></div>
|
||||
</section>
|
||||
<section class="file-browser-pane file-browser-preview-pane">
|
||||
<div class="file-browser-preview-header">
|
||||
<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>
|
||||
</div>
|
||||
<div class="file-browser-preview-empty" data-browser-preview-empty>选择一个文本文件查看内容</div>
|
||||
<pre class="file-browser-preview-content" data-browser-preview-content hidden></pre>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
const state = {
|
||||
sessionId: currentSessionId,
|
||||
rootPath: currentCwd,
|
||||
currentPath: '',
|
||||
selectedFilePath: '',
|
||||
directoryRequestId: 0,
|
||||
previewRequestId: 0,
|
||||
overlay,
|
||||
panel: overlay.querySelector('.file-browser-panel'),
|
||||
pathEl: overlay.querySelector('[data-browser-path]'),
|
||||
statusEl: overlay.querySelector('[data-browser-status]'),
|
||||
listEl: overlay.querySelector('[data-browser-list]'),
|
||||
previewTitleEl: overlay.querySelector('[data-browser-preview-title]'),
|
||||
previewMetaEl: overlay.querySelector('[data-browser-preview-meta]'),
|
||||
previewEmptyEl: overlay.querySelector('[data-browser-preview-empty]'),
|
||||
previewCodeEl: overlay.querySelector('[data-browser-preview-content]'),
|
||||
upBtn: overlay.querySelector('[data-browser-up]'),
|
||||
refreshBtn: overlay.querySelector('[data-browser-refresh]'),
|
||||
mobileBackBtn: overlay.querySelector('[data-browser-back]'),
|
||||
escapeHandler: null,
|
||||
};
|
||||
fileBrowserState = state;
|
||||
|
||||
state.escapeHandler = (e) => {
|
||||
if (e.key === 'Escape') closeFileBrowser();
|
||||
};
|
||||
document.addEventListener('keydown', state.escapeHandler);
|
||||
|
||||
overlay.querySelector('[data-browser-close]').addEventListener('click', closeFileBrowser);
|
||||
overlay.addEventListener('click', (e) => {
|
||||
if (e.target === overlay) closeFileBrowser();
|
||||
});
|
||||
state.upBtn.addEventListener('click', () => {
|
||||
loadFileBrowserDirectory(getBrowserParentPath(state.currentPath));
|
||||
});
|
||||
state.refreshBtn.addEventListener('click', () => {
|
||||
loadFileBrowserDirectory(state.currentPath, { preservePreview: !!state.selectedFilePath });
|
||||
});
|
||||
state.mobileBackBtn.addEventListener('click', () => {
|
||||
setFileBrowserPreviewMode(false);
|
||||
});
|
||||
|
||||
updateFileBrowserPathBar();
|
||||
renderFileBrowserPreviewEmpty('文件预览', '选择一个文本文件查看内容');
|
||||
loadFileBrowserDirectory('');
|
||||
}
|
||||
|
||||
function syncAttachmentActions() {
|
||||
const uploading = uploadingAttachments.length > 0;
|
||||
if (attachBtn) attachBtn.disabled = uploading;
|
||||
@@ -794,22 +1285,21 @@
|
||||
return sessions.filter((s) => normalizeAgent(s.agent) === currentAgent);
|
||||
}
|
||||
|
||||
function shouldOverlayRuntimeBadge() {
|
||||
return window.matchMedia('(max-width: 768px), (pointer: coarse)').matches;
|
||||
}
|
||||
|
||||
function updateCwdBadge() {
|
||||
if (!chatCwd) return;
|
||||
if (currentCwd) {
|
||||
const parts = currentCwd.replace(/\/+$/, '').split('/');
|
||||
const short = parts.slice(-2).join('/') || currentCwd;
|
||||
chatCwd.textContent = '~/' + short;
|
||||
chatCwd.title = currentCwd;
|
||||
chatCwd.title = `${currentCwd}\n点击浏览目录和文件`;
|
||||
chatCwd.setAttribute('aria-label', `浏览工作目录 ${currentCwd}`);
|
||||
} else {
|
||||
chatCwd.textContent = '';
|
||||
chatCwd.title = '';
|
||||
chatCwd.removeAttribute('aria-label');
|
||||
}
|
||||
chatCwd.hidden = !currentCwd || (currentSessionRunning && shouldOverlayRuntimeBadge());
|
||||
chatCwd.disabled = !currentCwd;
|
||||
chatCwd.hidden = !currentCwd;
|
||||
}
|
||||
|
||||
function setCurrentSessionRunningState(isRunning) {
|
||||
@@ -862,6 +1352,7 @@
|
||||
|
||||
function resetChatView(agent) {
|
||||
setCurrentAgent(agent);
|
||||
closeFileBrowser();
|
||||
currentSessionId = null;
|
||||
loadedHistorySessionId = null;
|
||||
clearSessionLoading();
|
||||
@@ -885,6 +1376,9 @@
|
||||
|
||||
function applySessionSnapshot(snapshot, options = {}) {
|
||||
if (!snapshot) return;
|
||||
if (fileBrowserState && (fileBrowserState.sessionId !== snapshot.sessionId || (snapshot.cwd && fileBrowserState.rootPath !== snapshot.cwd))) {
|
||||
closeFileBrowser();
|
||||
}
|
||||
const preserveStreaming = !!(options.preserveStreaming && isGenerating && snapshot.sessionId === currentSessionId && snapshot.isRunning);
|
||||
if (isGenerating && !preserveStreaming) {
|
||||
isGenerating = false;
|
||||
@@ -1303,6 +1797,15 @@
|
||||
scheduleRender();
|
||||
break;
|
||||
|
||||
case 'content_blocks':
|
||||
if (!isGenerating) startGenerating();
|
||||
if (Array.isArray(msg.blocks)) {
|
||||
if (!window.pendingContentBlocks) window.pendingContentBlocks = [];
|
||||
window.pendingContentBlocks.push(...msg.blocks);
|
||||
scheduleRender();
|
||||
}
|
||||
break;
|
||||
|
||||
case 'tool_start':
|
||||
if (!isGenerating) startGenerating();
|
||||
activeToolCalls.set(msg.toolUseId, { name: msg.name, input: msg.input, kind: msg.kind || null, meta: msg.meta || null, done: false });
|
||||
@@ -1461,7 +1964,7 @@
|
||||
break;
|
||||
|
||||
case 'cwd_suggestions':
|
||||
if (typeof _onCwdSuggestions === 'function') _onCwdSuggestions(msg.paths || []);
|
||||
if (typeof _onCwdSuggestions === 'function') _onCwdSuggestions(msg);
|
||||
break;
|
||||
|
||||
case 'update_info':
|
||||
@@ -1475,6 +1978,7 @@
|
||||
isGenerating = true;
|
||||
setCurrentSessionRunningState(true);
|
||||
pendingText = '';
|
||||
window.pendingContentBlocks = [];
|
||||
activeToolCalls.clear();
|
||||
toolGroupCount = 0;
|
||||
hasGrouped = false;
|
||||
@@ -1508,7 +2012,11 @@
|
||||
setCurrentSessionRunningState(false);
|
||||
msgInput.focus();
|
||||
|
||||
if (pendingText) flushRender();
|
||||
if (pendingText || (window.pendingContentBlocks && window.pendingContentBlocks.length > 0)) {
|
||||
flushRender();
|
||||
}
|
||||
|
||||
window.pendingContentBlocks = [];
|
||||
|
||||
const typing = document.querySelector('.typing-indicator');
|
||||
if (typing) typing.remove();
|
||||
@@ -1563,9 +2071,15 @@
|
||||
if (!streamEl) return;
|
||||
const bubble = streamEl.querySelector('.msg-bubble');
|
||||
if (!bubble) return;
|
||||
let textDiv = bubble.querySelector('.msg-text');
|
||||
if (!textDiv) { textDiv = bubble; }
|
||||
textDiv.innerHTML = renderMarkdown(pendingText);
|
||||
|
||||
if (window.pendingContentBlocks && window.pendingContentBlocks.length > 0) {
|
||||
bubble.innerHTML = '';
|
||||
renderAssistantContent(bubble, window.pendingContentBlocks);
|
||||
} else if (pendingText) {
|
||||
let textDiv = bubble.querySelector('.msg-text');
|
||||
if (!textDiv) { textDiv = bubble; }
|
||||
textDiv.innerHTML = renderMarkdown(pendingText);
|
||||
}
|
||||
scrollToBottom();
|
||||
}
|
||||
|
||||
@@ -1612,7 +2126,7 @@
|
||||
bubble.insertAdjacentHTML('beforeend', renderAttachmentLabels(attachments));
|
||||
}
|
||||
} else {
|
||||
bubble.innerHTML = content ? renderMarkdown(content) : '';
|
||||
renderAssistantContent(bubble, content);
|
||||
if (attachments.length > 0) {
|
||||
bubble.insertAdjacentHTML('beforeend', renderAttachmentLabels(attachments));
|
||||
}
|
||||
@@ -1623,6 +2137,74 @@
|
||||
return div;
|
||||
}
|
||||
|
||||
function renderAssistantContent(bubble, content) {
|
||||
if (!content) return;
|
||||
|
||||
if (typeof content === 'string') {
|
||||
// 尝试检测是否是 JSON 格式的 todo_list
|
||||
const trimmed = content.trim();
|
||||
if (trimmed.startsWith('{') && trimmed.includes('"type"') && trimmed.includes('"todo_list"')) {
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
if (parsed.type === 'todo_list') {
|
||||
bubble.appendChild(createTodoListElement(parsed));
|
||||
return;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
bubble.innerHTML = renderMarkdown(content);
|
||||
return;
|
||||
}
|
||||
|
||||
if (Array.isArray(content)) {
|
||||
content.forEach(block => {
|
||||
if (block.type === 'text') {
|
||||
const textDiv = document.createElement('div');
|
||||
textDiv.innerHTML = renderMarkdown(block.text || '');
|
||||
bubble.appendChild(textDiv);
|
||||
} else if (block.type === 'todo_list') {
|
||||
bubble.appendChild(createTodoListElement(block));
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
bubble.innerHTML = renderMarkdown(String(content));
|
||||
}
|
||||
|
||||
function createTodoListElement(block) {
|
||||
const container = document.createElement('div');
|
||||
container.className = 'todo-list-container';
|
||||
container.dataset.todoId = block.id || '';
|
||||
|
||||
const list = document.createElement('ul');
|
||||
list.className = 'todo-list';
|
||||
|
||||
if (Array.isArray(block.items)) {
|
||||
block.items.forEach((item, index) => {
|
||||
const li = document.createElement('li');
|
||||
li.className = 'todo-item' + (item.completed ? ' completed' : '');
|
||||
|
||||
const checkbox = document.createElement('input');
|
||||
checkbox.type = 'checkbox';
|
||||
checkbox.className = 'todo-checkbox';
|
||||
checkbox.checked = item.completed || false;
|
||||
checkbox.dataset.index = index;
|
||||
|
||||
const label = document.createElement('span');
|
||||
label.className = 'todo-text';
|
||||
label.textContent = item.text || '';
|
||||
|
||||
li.appendChild(checkbox);
|
||||
li.appendChild(label);
|
||||
list.appendChild(li);
|
||||
});
|
||||
}
|
||||
|
||||
container.appendChild(list);
|
||||
return container;
|
||||
}
|
||||
|
||||
let renderEpoch = 0;
|
||||
|
||||
function toolKind(tool) {
|
||||
@@ -1631,6 +2213,11 @@
|
||||
|
||||
function toolTitle(tool) {
|
||||
if (tool?.meta?.title) return tool.meta.title;
|
||||
if (toolKind(tool) === 'file_change') {
|
||||
const filePath = tool?.meta?.subtitle || tool?.input?.file_path || '';
|
||||
const action = tool?.input?.new_string && tool?.input?.old_string ? '更新' : '创建';
|
||||
return filePath ? `${action} ${filePath}` : 'File Change';
|
||||
}
|
||||
return tool?.name || 'Tool';
|
||||
}
|
||||
|
||||
@@ -2419,6 +3006,13 @@
|
||||
});
|
||||
});
|
||||
|
||||
if (chatCwd) {
|
||||
chatCwd.addEventListener('click', () => {
|
||||
if (!currentCwd) return;
|
||||
showFileBrowser();
|
||||
});
|
||||
}
|
||||
|
||||
// --- Sidebar ---
|
||||
function openSidebar() {
|
||||
sidebar.classList.add('open');
|
||||
@@ -4048,6 +4642,12 @@
|
||||
function showNewSessionModal() {
|
||||
const targetAgent = currentAgent;
|
||||
const targetLabel = AGENT_LABELS[targetAgent] || AGENT_LABELS.claude;
|
||||
const recentCwds = getRecentCwds();
|
||||
let suggestionsRequested = false;
|
||||
let suggestionState = {
|
||||
defaultPath: '',
|
||||
paths: [],
|
||||
};
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'modal-overlay';
|
||||
overlay.id = 'new-session-overlay';
|
||||
@@ -4065,8 +4665,11 @@
|
||||
<label class="modal-field-label" for="ns-cwd-input">工作目录</label>
|
||||
<div class="modal-field-row">
|
||||
<input type="text" id="ns-cwd-input" class="modal-text-input" placeholder="例如 /home/user/project" list="ns-cwd-list" autocomplete="off">
|
||||
<datalist id="ns-cwd-list"></datalist>
|
||||
<button type="button" class="modal-btn-secondary modal-btn-inline" id="ns-pick-dir-btn">选择目录</button>
|
||||
</div>
|
||||
<div class="modal-field-hint" id="ns-cwd-tip">正在准备默认目录…</div>
|
||||
<div class="modal-quick-picks" id="ns-cwd-picks"></div>
|
||||
<datalist id="ns-cwd-list"></datalist>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -4081,33 +4684,111 @@
|
||||
|
||||
const cwdInput = overlay.querySelector('#ns-cwd-input');
|
||||
const cwdList = overlay.querySelector('#ns-cwd-list');
|
||||
const cwdTip = overlay.querySelector('#ns-cwd-tip');
|
||||
const cwdPicks = overlay.querySelector('#ns-cwd-picks');
|
||||
const createBtn = overlay.querySelector('#ns-create-btn');
|
||||
const pickDirBtn = overlay.querySelector('#ns-pick-dir-btn');
|
||||
|
||||
// Populate datalist with recent cwds + server suggestions
|
||||
function renderCwdOptions(serverPaths) {
|
||||
const recent = getRecentCwds();
|
||||
cwdInput.value = recentCwds[0] || '';
|
||||
|
||||
function getMergedCwdSuggestions() {
|
||||
const seen = new Set();
|
||||
let html = '';
|
||||
// Recent cwds first
|
||||
for (const p of recent) {
|
||||
if (!seen.has(p)) { seen.add(p); html += `<option value="${escapeHtml(p)}" label="最近"></option>`; }
|
||||
const merged = [];
|
||||
for (const candidate of [...recentCwds, suggestionState.defaultPath, ...(suggestionState.paths || [])]) {
|
||||
const pathValue = String(candidate || '').trim();
|
||||
if (!pathValue || seen.has(pathValue)) continue;
|
||||
seen.add(pathValue);
|
||||
merged.push(pathValue);
|
||||
}
|
||||
// Server suggestions
|
||||
for (const p of (serverPaths || [])) {
|
||||
if (!seen.has(p)) { seen.add(p); html += `<option value="${escapeHtml(p)}"></option>`; }
|
||||
}
|
||||
cwdList.innerHTML = html;
|
||||
return merged;
|
||||
}
|
||||
|
||||
// Pre-fill with local recent cwds immediately
|
||||
renderCwdOptions([]);
|
||||
function getEffectiveCwd() {
|
||||
return cwdInput.value.trim() || suggestionState.defaultPath || null;
|
||||
}
|
||||
|
||||
// Fetch server suggestions on focus
|
||||
cwdInput.addEventListener('focus', () => {
|
||||
_onCwdSuggestions = (paths) => { renderCwdOptions(paths); };
|
||||
function renderCwdOptions() {
|
||||
const merged = getMergedCwdSuggestions();
|
||||
cwdList.innerHTML = merged
|
||||
.map((pathValue, index) => `<option value="${escapeHtml(pathValue)}"${index < recentCwds.length ? ' label="最近"' : ''}></option>`)
|
||||
.join('');
|
||||
|
||||
const quickPickPaths = merged.slice(0, 6);
|
||||
cwdPicks.innerHTML = quickPickPaths.map((pathValue) => `
|
||||
<button
|
||||
type="button"
|
||||
class="modal-quick-pick"
|
||||
data-path="${escapeHtml(pathValue)}"
|
||||
title="${escapeHtml(pathValue)}"
|
||||
>${escapeHtml(pathValue)}</button>
|
||||
`).join('');
|
||||
|
||||
cwdPicks.querySelectorAll('.modal-quick-pick').forEach((button) => {
|
||||
button.addEventListener('click', () => {
|
||||
const pathValue = button.dataset.path || '';
|
||||
if (!pathValue) return;
|
||||
cwdInput.value = pathValue;
|
||||
cwdInput.focus();
|
||||
});
|
||||
});
|
||||
|
||||
const fallbackPath = suggestionState.defaultPath || '';
|
||||
cwdTip.textContent = fallbackPath
|
||||
? `留空时默认使用 ${fallbackPath}`
|
||||
: '可手动输入路径,也可以点按钮选择目录';
|
||||
}
|
||||
|
||||
function requestCwdSuggestions() {
|
||||
if (suggestionsRequested) return;
|
||||
suggestionsRequested = true;
|
||||
_onCwdSuggestions = (payload) => {
|
||||
suggestionState = {
|
||||
defaultPath: String(payload?.defaultPath || '').trim(),
|
||||
paths: Array.isArray(payload?.paths) ? payload.paths : [],
|
||||
};
|
||||
if (!cwdInput.value.trim()) {
|
||||
cwdInput.value = recentCwds[0] || suggestionState.defaultPath || '';
|
||||
}
|
||||
renderCwdOptions();
|
||||
};
|
||||
send({ type: 'list_cwd_suggestions' });
|
||||
}
|
||||
|
||||
renderCwdOptions();
|
||||
requestCwdSuggestions();
|
||||
|
||||
function createSession() {
|
||||
const cwd = getEffectiveCwd();
|
||||
close();
|
||||
if (cwd) saveRecentCwd(cwd);
|
||||
send({ type: 'new_session', cwd, agent: targetAgent, mode: currentMode });
|
||||
}
|
||||
|
||||
pickDirBtn.addEventListener('click', () => {
|
||||
showDirectoryPicker({
|
||||
title: '选择工作目录',
|
||||
initialPath: getEffectiveCwd() || '',
|
||||
onChoose: (selectedPath) => {
|
||||
cwdInput.value = selectedPath;
|
||||
cwdInput.focus();
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
cwdInput.addEventListener('focus', () => {
|
||||
if (!suggestionState.defaultPath && (!suggestionState.paths || suggestionState.paths.length === 0)) {
|
||||
requestCwdSuggestions();
|
||||
}
|
||||
});
|
||||
cwdInput.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
createSession();
|
||||
}
|
||||
});
|
||||
|
||||
function close() {
|
||||
closeDirectoryPicker();
|
||||
overlay.remove();
|
||||
_onCwdSuggestions = null;
|
||||
}
|
||||
@@ -4115,13 +4796,7 @@
|
||||
overlay.querySelector('#ns-close-btn').addEventListener('click', close);
|
||||
overlay.querySelector('#ns-cancel-btn').addEventListener('click', close);
|
||||
overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });
|
||||
|
||||
overlay.querySelector('#ns-create-btn').addEventListener('click', () => {
|
||||
const cwd = cwdInput.value.trim() || null;
|
||||
close();
|
||||
if (cwd) saveRecentCwd(cwd);
|
||||
send({ type: 'new_session', cwd, agent: targetAgent, mode: currentMode });
|
||||
});
|
||||
createBtn.addEventListener('click', createSession);
|
||||
|
||||
cwdInput.focus();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user