877 lines
32 KiB
JavaScript
877 lines
32 KiB
JavaScript
// edit-mode.js -- 文件管理模式(重构版)
|
||
// 保留: 文件树渲染、文档修改追踪、外部变更处理、快捷键、拖拽引用
|
||
// 移除: Monaco 编辑器、标签页、Agent 面板
|
||
|
||
import { $, escapeHtml, showToast, dedupePendingFiles, filterOutUnsupported, isUnsupportedFile } from './utils.js';
|
||
import { api } from './api.js';
|
||
import { state } from './state.js';
|
||
import {
|
||
readTextFile, readBinaryFile, writeTextFile, listDir, getFileUrl,
|
||
pickFolder, watchDir, onFileChanged, copyExternalFile, writeFromBuffer, deleteFile
|
||
} from './electron-api.js';
|
||
import {
|
||
renderPersistentToolCalls, renderToolCallRow, formatDuration, getToolCallsTotalDuration,
|
||
} from './toolcalls.js';
|
||
import { renderDocTree, setDocTreeDeps } from './doc-tree.js';
|
||
import { openFilePreview, closeFilePreview } from './file-preview.js';
|
||
|
||
// ===== DOM 引用 =====
|
||
const modeCronToggleBtn = $("modeCronToggleBtn");
|
||
const chatPanel = $("chatPanel");
|
||
const sessionListWrap = $("sessionListWrap");
|
||
const docTreeWrap = $("docTreeWrap");
|
||
const cronListWrap = $("cronListWrap");
|
||
const docTree = $("docTree");
|
||
const sidebarHint = document.querySelector(".sidebar-hint");
|
||
|
||
// ===== 外部文件变更 =====
|
||
let _externalChanges = {};
|
||
let _fsChangeTimer = null;
|
||
let _fsApplying = false;
|
||
|
||
// 预览相关
|
||
const IMG_EXTS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp', '.ico']);
|
||
const ALL_TEXT_EXTS = new Set(['.txt', '.json', '.md', '.py', '.js', '.ts', '.css', '.xml', '.yaml', '.yml',
|
||
'.toml', '.csv', '.log', '.env', '.ini', '.cfg', '.sh', '.bat', '.ps1', '.sql', '.r', '.go', '.rs',
|
||
'.rb', '.php', '.swift', '.kt', '.scala', '.cpp', '.c', '.h', '.java', '.m', '.html', '.htm', '.svg']);
|
||
|
||
function isSupportedFile(name) {
|
||
const ext = (name || '').toLowerCase();
|
||
const dot = ext.lastIndexOf('.');
|
||
if (dot < 0) return false;
|
||
const e = ext.substring(dot);
|
||
return IMG_EXTS.has(e) || e === '.svg' || ALL_TEXT_EXTS.has(e) || e === '.pdf' || e === '.pptx'
|
||
|| e === '.xlsx' || e === '.xls' || e === '.docx' || e === '.doc' || e === '.ppt';
|
||
}
|
||
|
||
function isPreviewableImage(name) {
|
||
const ext = (name || '').toLowerCase();
|
||
const dot = ext.lastIndexOf('.');
|
||
return dot >= 0 && IMG_EXTS.has(ext.substring(dot));
|
||
}
|
||
|
||
function isPreviewableHtml(name) {
|
||
return (name || '').toLowerCase().endsWith('.html') || (name || '').toLowerCase().endsWith('.htm');
|
||
}
|
||
|
||
/** 二进制表格(XLSX/XLS):由后端 /api/table/text 提取为 TSV 后按列着色预览 */
|
||
function isPreviewableTable(name) {
|
||
const ext = (name || '').toLowerCase();
|
||
return ext.endsWith('.xlsx') || ext.endsWith('.xls');
|
||
}
|
||
|
||
const BINARY_EXTS = new Set([
|
||
'.exe','.dll','.so','.dylib','.bin','.dat','.zip','.rar','.7z','.tar','.gz','.bz2','.xz',
|
||
'.png','.jpg','.jpeg','.gif','.bmp','.ico','.webp','.tiff','.tif','.psd','.ai','.eps',
|
||
'.mp3','.wav','.flac','.ogg','.m4a','.aac','.wma','.aiff',
|
||
'.mp4','.avi','.mkv','.mov','.wmv','.flv','.webm','.m4v',
|
||
'.pdf','.doc','.docx','.xls','.xlsx','.ppt','.pptx',
|
||
'.ttf','.otf','.woff','.woff2','.eot',
|
||
'.db','.sqlite','.sqlite3','.mdb',
|
||
'.iso','.dmg','.img','.vmdk',
|
||
'.class','.jar','.war','.pyc','.pyo','.pyd',
|
||
'.o','.obj','.lib','.a','.wasm',
|
||
]);
|
||
function isBinaryFile(name) {
|
||
const lower = (name || '').toLowerCase();
|
||
const dot = lower.lastIndexOf('.');
|
||
return dot >= 0 && BINARY_EXTS.has(lower.substring(dot));
|
||
}
|
||
|
||
// ===== 模式切换 =====
|
||
let _modeSwitchCooldown = false;
|
||
|
||
/** 同步顶栏定时任务按钮的按下状态(cron 模式按下高亮,其余复位) */
|
||
function syncModeToggleBtn() {
|
||
if (!modeCronToggleBtn) return;
|
||
const isCron = state.mode === "cron";
|
||
modeCronToggleBtn.classList.toggle("is-active", isCron);
|
||
modeCronToggleBtn.setAttribute("aria-pressed", isCron ? "true" : "false");
|
||
modeCronToggleBtn.title = isCron ? "返回对话" : "定时任务";
|
||
}
|
||
|
||
export function switchToMode(mode) {
|
||
// 防连点:短时间内禁止重复切换
|
||
if (_modeSwitchCooldown) return;
|
||
_modeSwitchCooldown = true;
|
||
setTimeout(() => { _modeSwitchCooldown = false; }, 300);
|
||
|
||
// Edit 已迁入右栏:模式切换只影响左侧栏(会话列表/定时任务)与右栏显隐,
|
||
// 聊天区保持不变,Agent 运行中也可自由切换
|
||
state.mode = mode;
|
||
syncModeToggleBtn();
|
||
if (mode === "edit") {
|
||
state.editMode = true;
|
||
// 左栏始终显示会话列表;文件树在右栏
|
||
sessionListWrap?.classList.remove("mode-hidden");
|
||
cronListWrap?.classList.add("mode-hidden");
|
||
if (sidebarHint) sidebarHint.classList.remove("mode-hidden");
|
||
setRightPanel(true);
|
||
_onLeaveCron();
|
||
} else if (mode === "cron") {
|
||
state.editMode = false;
|
||
chatPanel?.classList.remove("mode-hidden");
|
||
sessionListWrap?.classList.add("mode-hidden");
|
||
if (sidebarHint) sidebarHint.classList.add("mode-hidden");
|
||
cronListWrap?.classList.remove("mode-hidden");
|
||
setRightPanel(false);
|
||
closeFilePreview();
|
||
_onEnterCron();
|
||
} else {
|
||
// chat 模式
|
||
state.editMode = false;
|
||
chatPanel?.classList.remove("mode-hidden");
|
||
sessionListWrap?.classList.remove("mode-hidden");
|
||
if (sidebarHint) sidebarHint.classList.remove("mode-hidden");
|
||
cronListWrap?.classList.add("mode-hidden");
|
||
setRightPanel(false);
|
||
closeFilePreview();
|
||
_onLeaveCron();
|
||
}
|
||
// 同步回合导航条显隐(cron 模式复用聊天容器,需隐藏导航条)
|
||
import('./turn-nav.js').then((m) => m.syncTurnNavVisibility()).catch(() => {});
|
||
}
|
||
|
||
// ===== git 状态(文件树着色:新增绿 / 修改橙 / 删除橙+删除线) =====
|
||
let _gitStatusTimer = null;
|
||
|
||
export function refreshGitStatus() {
|
||
if (!state._docRootPath) return;
|
||
if (_gitStatusTimer) clearTimeout(_gitStatusTimer);
|
||
_gitStatusTimer = setTimeout(async () => {
|
||
try {
|
||
const rootPath = state._docRootPath;
|
||
const data = await api(`/api/workspace/git-status?path=${encodeURIComponent(rootPath)}`);
|
||
const map = {};
|
||
for (const s of data.status || []) map[s.path] = s.state;
|
||
state.gitStatus = map;
|
||
renderDocTree();
|
||
} catch { /* git 不可用/非仓库时静默,保持无着色 */ }
|
||
}, 150);
|
||
}
|
||
|
||
// ===== 右栏(Edit 文件树)控制 =====
|
||
export function setRightPanel(open) {
|
||
const appRoot = document.getElementById("app");
|
||
if (!appRoot) return;
|
||
appRoot.classList.toggle("right-panel-open", !!open);
|
||
const rp = document.getElementById("rightPanel");
|
||
if (rp) rp.setAttribute("aria-hidden", open ? "false" : "true");
|
||
// 打开时刷新 git 状态着色
|
||
if (open) {
|
||
try {
|
||
window.dispatchEvent(new CustomEvent("git-status-changed"));
|
||
} catch { /* ignore */ }
|
||
}
|
||
}
|
||
|
||
// ===== Cron 模式进入/离开钩子(由 app.js 注入,避免循环依赖) =====
|
||
let _onEnterCronFn = null;
|
||
let _onLeaveCronFn = null;
|
||
export function setCronModeHooks({ onEnter, onLeave } = {}) {
|
||
_onEnterCronFn = onEnter || null;
|
||
_onLeaveCronFn = onLeave || null;
|
||
}
|
||
function _onEnterCron() {
|
||
try { _onEnterCronFn && _onEnterCronFn(); } catch { /* ignore */ }
|
||
}
|
||
function _onLeaveCron() {
|
||
try { _onLeaveCronFn && _onLeaveCronFn(); } catch { /* ignore */ }
|
||
}
|
||
|
||
// ===== 打开文件夹 =====
|
||
export async function autoOpenWorkspace() {
|
||
if (!window.electronAPI) return;
|
||
try {
|
||
const data = await api("/api/workspace");
|
||
const current = data.current || "";
|
||
const defaultPath = data.default || "";
|
||
const rootPath = current || defaultPath;
|
||
if (rootPath) {
|
||
await openFolder(rootPath);
|
||
}
|
||
} catch { /* ignore */ }
|
||
}
|
||
|
||
export async function openFolder(folderPath) {
|
||
try {
|
||
let rootPath = (typeof folderPath === "string" && folderPath) ? folderPath : null;
|
||
if (!rootPath) {
|
||
rootPath = await pickFolder();
|
||
}
|
||
if (!rootPath) return;
|
||
|
||
// 先校验目录存在并列出文件;文件夹被删除/不可访问时给出明确提示,避免把失效路径写入状态
|
||
let listRes;
|
||
try {
|
||
listRes = await listDir(rootPath, 10);
|
||
} catch (e) {
|
||
showToast("打开文件夹失败: " + (e.message || e));
|
||
return;
|
||
}
|
||
|
||
state._docRootPath = rootPath;
|
||
state._docRootName = rootPath.split(/[/\\]/).pop() || rootPath;
|
||
|
||
try {
|
||
const { addRecentFolder } = await import('../app.js');
|
||
await addRecentFolder(rootPath);
|
||
} catch {}
|
||
|
||
if (!state._docTreeFolders) state._docTreeFolders = {};
|
||
state._docTreeFolders[state._docRootName] = true;
|
||
|
||
const files = listRes.files || [];
|
||
const supportedFiles = files.filter((f) => !f.is_dir && isSupportedFile(f.name));
|
||
// 去重:按 path 去重
|
||
const seen = new Set();
|
||
const uniqueFiles = [];
|
||
for (const f of supportedFiles) {
|
||
if (seen.has(f.path)) continue;
|
||
seen.add(f.path);
|
||
// 规范化路径:去掉 rootPath 前缀,确保相对路径,统一用 /
|
||
let relPath = f.path.replace(/\\/g, "/");
|
||
const normalizedRoot = rootPath.replace(/\\/g, "/").replace(/\/$/, "");
|
||
if (relPath.startsWith(normalizedRoot + "/")) {
|
||
relPath = relPath.substring(normalizedRoot.length + 1);
|
||
}
|
||
uniqueFiles.push({ ...f, path: relPath });
|
||
}
|
||
state.docFiles = [];
|
||
state.docOpenTabs = [];
|
||
_externalChanges = {};
|
||
|
||
for (const f of uniqueFiles) {
|
||
state.docFiles.push({ path: f.path, name: f.name, status: "original", size: f.size });
|
||
}
|
||
|
||
renderDocTree();
|
||
renderDocTabs && renderDocTabs();
|
||
// 打开文件夹后刷新 git 状态着色
|
||
refreshGitStatus();
|
||
|
||
if (state.docFiles.length > 0) {
|
||
state.activeDocFile = state.docFiles[0].path;
|
||
state.activeDocTab = state.docFiles[0].path;
|
||
state.docOpenTabs = [{ path: state.docFiles[0].path, name: state.docFiles[0].name, modified: false }];
|
||
renderDocTabs && renderDocTabs();
|
||
}
|
||
|
||
if (window.electronAPI) {
|
||
watchDir(rootPath).then(() => {
|
||
onFileChanged(handleFsChange);
|
||
}).catch(() => {});
|
||
}
|
||
} catch (e) {
|
||
showToast("打开文件夹失败: " + (e.message || e));
|
||
}
|
||
}
|
||
|
||
export function showOpenButtons() {
|
||
state._docRootPath = null;
|
||
state._docRootName = null;
|
||
state.docFiles = [];
|
||
state.docOpenTabs = [];
|
||
_externalChanges = {};
|
||
renderDocTree();
|
||
renderDocTabs && renderDocTabs();
|
||
}
|
||
|
||
// ===== 标签页(保留用于打开状态追踪) =====
|
||
export function renderDocTabs() {
|
||
// 简化为无标签栏 - 文件点击直接预览
|
||
}
|
||
|
||
// ===== 文件点击预览 =====
|
||
export async function loadDocContent(filePath) {
|
||
if (!state._docRootPath) return;
|
||
const fileName = filePath.split(/[/\\]/).pop() || filePath;
|
||
|
||
try {
|
||
if (isPreviewableImage(fileName)) {
|
||
const res = await readBinaryFile(filePath, state._docRootPath);
|
||
if (res && res.status === 'ok' && res.data) {
|
||
const dataUrl = `data:${res.mime || 'image/png'};base64,${res.data}`;
|
||
const { showImagePreview } = await import('./toolcalls.js');
|
||
showImagePreview(dataUrl);
|
||
}
|
||
} else if (isPreviewableHtml(fileName)) {
|
||
const res = await readTextFile(filePath, state._docRootPath);
|
||
if (res && res.status === 'ok') {
|
||
openFilePreview(filePath, fileName, res.content || '',
|
||
{ onDownload: () => downloadFile(filePath, fileName) });
|
||
}
|
||
} else if (isPreviewableTable(fileName)) {
|
||
// XLSX/XLS:后端提取为 TSV 文本,走与 CSV/TSV 一致的按列着色预览
|
||
const absPath = (/^[A-Za-z]:[\\/]/.test(filePath) || filePath.startsWith('/') || filePath.startsWith('\\'))
|
||
? filePath
|
||
: state._docRootPath.replace(/[\\/]+$/, '') + '/' + filePath.replace(/^[\\/]+/, '');
|
||
try {
|
||
const tsv = await api('/api/table/text?path=' + encodeURIComponent(absPath));
|
||
openFilePreview(absPath, fileName, (tsv && tsv.content) || '',
|
||
{ onDownload: () => downloadFile(absPath, fileName) });
|
||
} catch (e) {
|
||
openFilePreview(absPath, fileName, '(加载失败: ' + (e.message || e) + ')',
|
||
{ onDownload: () => downloadFile(absPath, fileName) });
|
||
}
|
||
} else if (isBinaryFile(fileName)) {
|
||
showToast('二进制文件不支持在线查看', 'warning');
|
||
} else {
|
||
const res = await readTextFile(filePath, state._docRootPath);
|
||
if (res && res.status === 'ok') {
|
||
openFilePreview(filePath, fileName, res.content || '',
|
||
{ onDownload: () => downloadFile(filePath, fileName) });
|
||
} else {
|
||
openFilePreview(filePath, fileName, '(无法读取文件)',
|
||
{ onDownload: () => downloadFile(filePath, fileName) });
|
||
}
|
||
}
|
||
} catch {
|
||
openFilePreview(filePath, fileName, '(加载失败)',
|
||
{ onDownload: () => downloadFile(filePath, fileName) });
|
||
}
|
||
}
|
||
|
||
/** 以纯文本模式打开文件(供右键"文本编辑"使用) */
|
||
export async function openFileAsText(filePath) {
|
||
if (!state._docRootPath) return;
|
||
const fileName = filePath.split(/[/\\]/).pop() || filePath;
|
||
if (isBinaryFile(fileName)) {
|
||
showToast('二进制文件不支持在线查看', 'warning');
|
||
return;
|
||
}
|
||
try {
|
||
const res = await readTextFile(filePath, state._docRootPath);
|
||
if (res && res.status === 'ok') {
|
||
openFilePreview(filePath, fileName, res.content || '',
|
||
{ onDownload: () => downloadFile(filePath, fileName), asText: true });
|
||
} else {
|
||
openFilePreview(filePath, fileName, '(无法读取文件)',
|
||
{ onDownload: () => downloadFile(filePath, fileName) });
|
||
}
|
||
} catch {
|
||
openFilePreview(filePath, fileName, '(加载失败)',
|
||
{ onDownload: () => downloadFile(filePath, fileName) });
|
||
}
|
||
}
|
||
|
||
async function downloadFile(filePath, fileName) {
|
||
try {
|
||
if (isPreviewableImage(fileName)) {
|
||
const res = await readBinaryFile(filePath, state._docRootPath);
|
||
if (res && res.status === 'ok' && res.data) {
|
||
const url = `data:${res.mime || 'image/png'};base64,${res.data}`;
|
||
const a = document.createElement('a');
|
||
a.href = url; a.download = fileName;
|
||
document.body.appendChild(a); a.click(); document.body.removeChild(a);
|
||
}
|
||
} else if (isBinaryFile(fileName)) {
|
||
// 二进制文件(xlsx/docx/pdf 等):按 base64 还原字节后下载
|
||
const res = await readBinaryFile(filePath, state._docRootPath);
|
||
if (res && res.status === 'ok' && res.data) {
|
||
const bin = atob(res.data);
|
||
const bytes = new Uint8Array(bin.length);
|
||
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
|
||
const blob = new Blob([bytes], { type: res.mime || 'application/octet-stream' });
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url; a.download = fileName;
|
||
document.body.appendChild(a); a.click(); document.body.removeChild(a);
|
||
URL.revokeObjectURL(url);
|
||
}
|
||
} else {
|
||
const res = await readTextFile(filePath, state._docRootPath);
|
||
if (res && res.status === 'ok') {
|
||
const blob = new Blob([res.content || ''], { type: 'text/plain;charset=utf-8' });
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url; a.download = fileName;
|
||
document.body.appendChild(a); a.click(); document.body.removeChild(a);
|
||
URL.revokeObjectURL(url);
|
||
}
|
||
}
|
||
} catch { /* ignore */ }
|
||
}
|
||
|
||
// ===== 外部文件变更(实时自动刷新,类似 VS Code) =====
|
||
function handleFsChange(events) {
|
||
if (_fsChangeTimer) clearTimeout(_fsChangeTimer);
|
||
_fsChangeTimer = setTimeout(() => applyFsChanges(events), 300);
|
||
}
|
||
|
||
async function applyFsChanges(events) {
|
||
if (_fsApplying || !state._docRootPath) return;
|
||
_fsApplying = true;
|
||
try {
|
||
const rootPath = state._docRootPath;
|
||
const listRes = await listDir(rootPath, 10);
|
||
const currentFiles = (listRes.files || []).filter((f) => !f.is_dir && isSupportedFile(f.name));
|
||
const currentMap = new Map(currentFiles.map((f) => [f.path, f]));
|
||
|
||
const existingMap = new Map(state.docFiles.map((f) => [f.path, f]));
|
||
const existingNames = new Set(existingMap.keys());
|
||
const currentNames = new Set(currentMap.keys());
|
||
|
||
let changed = false;
|
||
|
||
// 检测新增和修改的文件
|
||
for (const [fp, f] of currentMap) {
|
||
if (!existingNames.has(fp)) {
|
||
_externalChanges[fp] = { type: 'externally_added', name: f.name, size: f.size };
|
||
changed = true;
|
||
} else {
|
||
const old = existingMap.get(fp);
|
||
// 检测文件大小变化(即内容被外部修改)
|
||
if (old.size !== f.size) {
|
||
_externalChanges[fp] = { type: 'externally_modified', name: f.name, size: f.size, oldSize: old.size };
|
||
changed = true;
|
||
}
|
||
}
|
||
}
|
||
|
||
// 检测删除的文件
|
||
for (const fp of existingNames) {
|
||
if (!currentNames.has(fp)) {
|
||
_externalChanges[fp] = { type: 'externally_deleted', name: existingMap.get(fp).name };
|
||
changed = true;
|
||
}
|
||
}
|
||
|
||
if (changed) {
|
||
state.docFiles = currentFiles;
|
||
renderDocTree();
|
||
}
|
||
} finally {
|
||
_fsApplying = false;
|
||
}
|
||
}
|
||
|
||
export function clearExternalChange(filePath) {
|
||
delete _externalChanges[filePath];
|
||
renderDocTree();
|
||
}
|
||
|
||
// ===== 拖拽引用 =====
|
||
export function initDocReferences() {
|
||
// 文件树元素拖拽到输入框
|
||
if (docTree) {
|
||
docTree.addEventListener('dragstart', (e) => {
|
||
const item = e.target.closest('[data-file-path]');
|
||
if (!item) return;
|
||
const filePath = item.dataset.filePath;
|
||
// 用专用 mime type 传递路径,避免和 text/plain 冲突
|
||
e.dataTransfer.setData('application/doc-path', filePath);
|
||
e.dataTransfer.effectAllowed = 'copy';
|
||
});
|
||
}
|
||
|
||
// 输入区接收拖入
|
||
setupInputDropHandler();
|
||
}
|
||
|
||
function setupInputDropHandler() {
|
||
// 拖放目标设为整个 composer 输入区域,不只是 textarea
|
||
const composerInput = document.querySelector('.composer-input-shell');
|
||
const target = composerInput || $('textInput');
|
||
if (!target) return;
|
||
|
||
// 子元素间移动会成对触发 dragenter/dragleave,用计数器避免状态误清除
|
||
let dragDepth = 0;
|
||
const setDragState = (on) => target.classList.toggle('is-drag-attach', on);
|
||
const isAcceptedDrag = (types) => types.includes('application/doc-path') || types.includes('Files');
|
||
|
||
target.addEventListener('dragenter', (e) => {
|
||
if (isAcceptedDrag(Array.from(e.dataTransfer.types))) {
|
||
dragDepth++;
|
||
setDragState(true);
|
||
}
|
||
});
|
||
|
||
target.addEventListener('dragover', (e) => {
|
||
const types = Array.from(e.dataTransfer.types);
|
||
// 接受应用内文件/文件夹引用拖拽 或 外部文件拖拽
|
||
if (types.includes('application/doc-path') || types.includes('Files')) {
|
||
e.preventDefault();
|
||
e.dataTransfer.dropEffect = 'copy';
|
||
setDragState(true);
|
||
}
|
||
});
|
||
|
||
target.addEventListener('dragleave', () => {
|
||
dragDepth = Math.max(0, dragDepth - 1);
|
||
if (dragDepth === 0) setDragState(false);
|
||
});
|
||
|
||
target.addEventListener('drop', async (e) => {
|
||
e.preventDefault();
|
||
dragDepth = 0;
|
||
setDragState(false);
|
||
|
||
// 1. 内部拖拽(doc-tree 文件/文件夹引用)
|
||
// app.js 检测到 application/doc-path 也会 return,无需 stopPropagation
|
||
if (Array.from(e.dataTransfer.types).includes('application/doc-path')) {
|
||
const docPath = e.dataTransfer.getData('application/doc-path');
|
||
const isFolder = e.dataTransfer.getData('application/doc-folder') === '1';
|
||
if (isFolder) {
|
||
addFolderRef(docPath);
|
||
} else {
|
||
addDocRef(docPath);
|
||
}
|
||
return;
|
||
}
|
||
|
||
// 2. 聊天区图片/文件拖拽 — 交给 app.js 处理(不 stopPropagation,让事件冒泡)
|
||
if (e.dataTransfer.types.includes('application/x-chat-image') ||
|
||
e.dataTransfer.types.includes('application/x-chat-file')) {
|
||
return;
|
||
}
|
||
|
||
// 3. 外部拖拽(文件/文件夹)
|
||
// stopPropagation 防止 app.js 递归展开文件夹内所有文件
|
||
if (e.dataTransfer.items && e.dataTransfer.items.length > 0) {
|
||
e.stopPropagation();
|
||
const items = Array.from(e.dataTransfer.items);
|
||
// 先收集所有 entry,判断是否包含文件夹(视频暂不支持,直接过滤)
|
||
const allEntries = [];
|
||
for (const item of items) {
|
||
if (item.kind !== 'file') continue;
|
||
const entry = item.webkitGetAsEntry ? item.webkitGetAsEntry() : null;
|
||
const file = item.getAsFile();
|
||
allEntries.push({ entry, file });
|
||
}
|
||
const acceptedFiles = filterOutUnsupported(allEntries.map(({ file }) => file).filter(Boolean));
|
||
const filteredEntries = allEntries.filter(({ file }) => !file || acceptedFiles.includes(file));
|
||
const hasDirectory = filteredEntries.some(({ entry }) => entry && entry.isDirectory);
|
||
|
||
if (hasDirectory) {
|
||
// 有文件夹时:只处理文件夹项,跳过文件项(避免展开文件夹内容)
|
||
for (const { entry, file } of filteredEntries) {
|
||
if (entry && entry.isDirectory) {
|
||
const folderPath = (file && file.path) ? file.path : '';
|
||
const folderName = entry.name || (file ? file.name : '文件夹');
|
||
addExternalFolderRef(folderPath, folderName);
|
||
}
|
||
}
|
||
} else {
|
||
// 无文件夹:图片作为普通 File 上传(HumanMessage image_url),非图片作为 @file: 引用
|
||
// Electron 拖入的 File 带 path(绝对路径)且已包含二进制内容;无 path 时回退为普通上传
|
||
let needFallback = false;
|
||
for (const { file } of filteredEntries) {
|
||
if (!file) continue;
|
||
if (_isImageName(file.name)) {
|
||
// 图片:直接作为普通文件上传(File 已含二进制内容)
|
||
state.pendingFiles = dedupePendingFiles(state.pendingFiles.concat([file]));
|
||
needFallback = true;
|
||
} else if (file.path) {
|
||
// 非图片:作为 @file: 绝对路径引用
|
||
addExternalDocRef(file.path, file.name);
|
||
} else {
|
||
state.pendingFiles = dedupePendingFiles(state.pendingFiles.concat([file]));
|
||
needFallback = true;
|
||
}
|
||
}
|
||
if (needFallback) {
|
||
import('./chat-render.js').then(m => { if (m.renderAttachmentChips) m.renderAttachmentChips(); });
|
||
}
|
||
}
|
||
|
||
if (filteredEntries.length > 0) {
|
||
import('./chat-render.js').then(m => { if (m.renderAttachmentChips) m.renderAttachmentChips(); });
|
||
}
|
||
return;
|
||
}
|
||
|
||
// 4. 回退:dataTransfer.files(无 items 时)— 图片作为普通 File 上传,非图片作为 @file: 引用
|
||
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
|
||
e.stopPropagation();
|
||
const files = filterOutUnsupported(Array.from(e.dataTransfer.files));
|
||
let needFallback = false;
|
||
for (const file of files) {
|
||
if (_isImageName(file.name)) {
|
||
// 图片:直接作为普通文件上传
|
||
state.pendingFiles = dedupePendingFiles(state.pendingFiles.concat([file]));
|
||
needFallback = true;
|
||
} else if (file.path) {
|
||
// 非图片:作为 @file: 绝对路径引用
|
||
addExternalDocRef(file.path, file.name);
|
||
} else {
|
||
state.pendingFiles = dedupePendingFiles(state.pendingFiles.concat([file]));
|
||
needFallback = true;
|
||
}
|
||
}
|
||
if (files.length > 0) {
|
||
import('./chat-render.js').then(m => { if (m.renderAttachmentChips) m.renderAttachmentChips(); });
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
/** 判断文件名是否为图片(与后端 IMAGE_FILE_EXTENSIONS 对齐:png/jpg/jpeg/bmp/gif/webp/ico) */
|
||
function _isImageName(name) {
|
||
const ext = (name || '').toLowerCase();
|
||
return /\.(png|jpe?g|gif|webp|bmp|ico)$/.test(ext);
|
||
}
|
||
|
||
/** 读取工作区内图片文件二进制,构造为 File 并加入 pendingFiles(作为普通图片上传,HumanMessage image_url) */
|
||
async function _addImageFileAsUpload(relPath, fileName) {
|
||
const rootPath = state._docRootPath;
|
||
if (!rootPath) {
|
||
showToast('请先打开工作区');
|
||
return;
|
||
}
|
||
const name = fileName || relPath.split(/[/\\]/).pop() || relPath;
|
||
try {
|
||
const res = await readBinaryFile(relPath, rootPath);
|
||
if (!res || res.status !== 'ok' || !res.data) {
|
||
showToast('图片读取失败:' + name);
|
||
return;
|
||
}
|
||
const bytes = Uint8Array.from(atob(res.data), c => c.charCodeAt(0));
|
||
const blob = new Blob([bytes], { type: res.mime || 'image/png' });
|
||
const file = new File([blob], name, { type: blob.type });
|
||
state.pendingFiles = dedupePendingFiles(state.pendingFiles.concat([file]));
|
||
import('./chat-render.js').then(m => { if (m.renderAttachmentChips) m.renderAttachmentChips(); });
|
||
} catch (e) {
|
||
showToast('图片读取失败:' + (e.message || e));
|
||
}
|
||
}
|
||
|
||
/** 添加一个文档引用 chip
|
||
* 图片(无行号引用):读取二进制作为普通图片 File 上传(HumanMessage image_url),不生成 @file: 文本。
|
||
* 非图片或带行号引用:作为 @file:path [Lx-Ly] 文本引用,只给 agent 绝对路径。
|
||
*/
|
||
export async function addDocRef(filePath, lineStart, lineEnd) {
|
||
if (!filePath) return;
|
||
const fileName = filePath.split(/[/\\]/).pop() || filePath;
|
||
|
||
// 不支持的类型(视频 / 旧版 .doc / .rtf):拒绝引用
|
||
if (isUnsupportedFile(fileName)) {
|
||
showToast(`暂不支持 ${fileName}(视频 / 旧版 .doc / .rtf)`);
|
||
return;
|
||
}
|
||
|
||
// 图片且无行号引用 → 作为普通图片上传
|
||
if (_isImageName(fileName) && !lineStart) {
|
||
// 防重复:若已作为普通文件或引用存在则跳过
|
||
const exists = state.pendingFiles.some((f) =>
|
||
(f.__isRef && f.refPath === filePath) || (!f.__isRef && f.name === fileName)
|
||
);
|
||
if (exists) return;
|
||
await _addImageFileAsUpload(filePath, fileName);
|
||
return;
|
||
}
|
||
|
||
// 非图片或带行号引用 → 作为 @file: 文本引用
|
||
if (state.pendingRefs.some((r) => r.path === filePath)) return;
|
||
const ref = {
|
||
path: filePath,
|
||
name: fileName,
|
||
startLine: lineStart || null,
|
||
endLine: lineEnd || null,
|
||
};
|
||
state.pendingRefs.push(ref);
|
||
// 同步到 pendingFiles(统一渲染),用 __isRef 标记区分
|
||
state.pendingFiles.push({
|
||
__isRef: true,
|
||
refPath: filePath,
|
||
refName: fileName + (lineStart ? ` L${lineStart}-L${lineEnd}` : ''),
|
||
type: 'ref/file',
|
||
name: fileName,
|
||
});
|
||
import('./chat-render.js').then(m => { if (m.renderAttachmentChips) m.renderAttachmentChips(); });
|
||
}
|
||
|
||
/** 添加工作区文件夹引用 chip(路径相对于 workspace root) */
|
||
export function addFolderRef(folderPath) {
|
||
// 防重复
|
||
if (state.pendingRefs.some((r) => r.isFolder && r.path === folderPath)) return;
|
||
const folderName = folderPath
|
||
? folderPath.split(/[/\\]/).pop()
|
||
: (state._docRootName || 'workspace');
|
||
const ref = {
|
||
path: folderPath,
|
||
name: folderName,
|
||
isFolder: true,
|
||
};
|
||
state.pendingRefs.push(ref);
|
||
state.pendingFiles.push({
|
||
__isRef: true,
|
||
refPath: folderPath,
|
||
refName: folderName,
|
||
type: 'ref/folder',
|
||
name: folderName,
|
||
isFolder: true,
|
||
});
|
||
import('./chat-render.js').then(m => { if (m.renderAttachmentChips) m.renderAttachmentChips(); });
|
||
}
|
||
|
||
/** 添加外部文件夹引用 chip(路径为绝对路径) */
|
||
export function addExternalFolderRef(absolutePath, folderName) {
|
||
if (!absolutePath && !folderName) return;
|
||
// 防重复
|
||
if (state.pendingRefs.some((r) => r.isFolder && r.path === absolutePath)) return;
|
||
const ref = {
|
||
path: absolutePath || '',
|
||
name: folderName || '文件夹',
|
||
isFolder: true,
|
||
isExternal: true,
|
||
};
|
||
state.pendingRefs.push(ref);
|
||
state.pendingFiles.push({
|
||
__isRef: true,
|
||
refPath: absolutePath || '',
|
||
refName: folderName || '文件夹',
|
||
type: 'ref/folder',
|
||
name: folderName || '文件夹',
|
||
isFolder: true,
|
||
isExternal: true,
|
||
});
|
||
import('./chat-render.js').then(m => { if (m.renderAttachmentChips) m.renderAttachmentChips(); });
|
||
}
|
||
|
||
/** 添加外部文件引用 chip(绝对路径)
|
||
* 图片:读取二进制作为普通图片 File 上传(HumanMessage image_url)。
|
||
* 非图片:作为 @file:abspath 文本引用,只给 agent 绝对路径不读内容。
|
||
*/
|
||
export async function addExternalDocRef(absolutePath, fileName) {
|
||
if (!absolutePath) return;
|
||
const name = fileName || absolutePath.split(/[/\\]/).pop() || absolutePath;
|
||
|
||
// 不支持的类型(视频 / 旧版 .doc / .rtf):拒绝引用
|
||
if (isUnsupportedFile(name)) {
|
||
showToast(`暂不支持 ${name}(视频 / 旧版 .doc / .rtf)`);
|
||
return;
|
||
}
|
||
|
||
// 图片 → 读取二进制作为普通图片上传
|
||
if (_isImageName(name)) {
|
||
const exists = state.pendingFiles.some((f) =>
|
||
(f.__isRef && f.refPath === absolutePath) || (!f.__isRef && f.name === name)
|
||
);
|
||
if (exists) return;
|
||
await _addExternalImageAsUpload(absolutePath, name);
|
||
return;
|
||
}
|
||
|
||
// 非图片 → 作为 @file: 文本引用
|
||
if (state.pendingRefs.some((r) => r.path === absolutePath)) return;
|
||
const ref = {
|
||
path: absolutePath,
|
||
name: name,
|
||
isExternal: true,
|
||
};
|
||
state.pendingRefs.push(ref);
|
||
state.pendingFiles.push({
|
||
__isRef: true,
|
||
refPath: absolutePath,
|
||
refName: name,
|
||
type: 'ref/file',
|
||
name: name,
|
||
isExternal: true,
|
||
});
|
||
import('./chat-render.js').then(m => { if (m.renderAttachmentChips) m.renderAttachmentChips(); });
|
||
}
|
||
|
||
/** 读取外部(绝对路径)图片二进制,构造为 File 并加入 pendingFiles */
|
||
async function _addExternalImageAsUpload(absolutePath, fileName) {
|
||
try {
|
||
const { readBinaryFile: _readBinary } = await import('./electron-api.js');
|
||
const res = await _readBinary(absolutePath, null);
|
||
if (!res || res.status !== 'ok' || !res.data) {
|
||
showToast('图片读取失败:' + fileName);
|
||
return;
|
||
}
|
||
const bytes = Uint8Array.from(atob(res.data), c => c.charCodeAt(0));
|
||
const blob = new Blob([bytes], { type: res.mime || 'image/png' });
|
||
const file = new File([blob], fileName, { type: blob.type });
|
||
state.pendingFiles = dedupePendingFiles(state.pendingFiles.concat([file]));
|
||
import('./chat-render.js').then(m => { if (m.renderAttachmentChips) m.renderAttachmentChips(); });
|
||
} catch (e) {
|
||
showToast('图片读取失败:' + (e.message || e));
|
||
}
|
||
}
|
||
|
||
/** 清除所有引用 chip */
|
||
export function clearRefChips() {
|
||
state.pendingRefs = [];
|
||
state.pendingFiles = state.pendingFiles.filter((f) => !f.__isRef);
|
||
import('./chat-render.js').then(m => { if (m.renderAttachmentChips) m.renderAttachmentChips(); });
|
||
}
|
||
|
||
// ===== 清除状态 =====
|
||
export function clearEditState() {
|
||
_externalChanges = {};
|
||
closeFilePreview();
|
||
clearRefChips();
|
||
state.docFiles = [];
|
||
state.docOpenTabs = [];
|
||
state._docRootPath = null;
|
||
state._docRootName = null;
|
||
renderDocTree();
|
||
}
|
||
|
||
// ===== 发送 Edit 消息 =====
|
||
export async function sendEditChat() {
|
||
if (!state.sessionId || state.sending) return;
|
||
const textInput = $('textInput');
|
||
const trimmed = textInput ? textInput.value.trim() : "";
|
||
const hasInput = trimmed || state.pendingFiles.length > 0 || state.pendingRefs.length > 0;
|
||
if (!hasInput) return;
|
||
|
||
// 文档/文件夹引用(@file:/@folder:)由 sendChat 统一拼接为文本发送,
|
||
// 此处无需再注入 textInput,避免 chat/edit 双份引用
|
||
try {
|
||
const { sendChat } = await import('./send.js');
|
||
await sendChat();
|
||
} catch (err) {
|
||
throw err;
|
||
} finally {
|
||
// 无论成功失败都清除引用 chip,防止重复注入
|
||
clearRefChips();
|
||
}
|
||
}
|
||
|
||
// ===== 初始化 =====
|
||
export function initEditMode() {
|
||
// git 状态刷新事件(回撤/右栏打开/文件操作后触发)
|
||
window.addEventListener("git-status-changed", () => refreshGitStatus());
|
||
|
||
// 顶栏"定时任务"按钮:cron ↔ chat 切换(Edit 模式点击同样进入 cron)
|
||
modeCronToggleBtn?.addEventListener("click", () => {
|
||
if (state.mode !== "cron") switchToMode("cron");
|
||
else switchToMode("chat");
|
||
});
|
||
|
||
// 打开文件夹 - 移除按钮,改用工作空间选择器触发
|
||
|
||
// 文件树点击预览
|
||
docTree?.addEventListener("click", async (e) => {
|
||
const item = e.target.closest('[data-file-path]');
|
||
if (!item) return;
|
||
const filePath = item.dataset.filePath;
|
||
if (filePath) {
|
||
await loadDocContent(filePath);
|
||
}
|
||
});
|
||
|
||
// 文件树拖拽引用
|
||
initDocReferences();
|
||
|
||
// 全局快捷键(Ctrl+C/X/V/F2/Delete 文件操作)
|
||
document.addEventListener("keydown", (e) => {
|
||
if (!state.editMode) return;
|
||
const tag = document.activeElement?.tagName?.toLowerCase();
|
||
if (tag === 'input' || tag === 'textarea' || tag === 'select') return;
|
||
|
||
// 这些操作委托给 doc-tree.js
|
||
// doc-tree.js 中已有自己的事件绑定
|
||
}, { capture: true });
|
||
|
||
// 文件树依赖注入
|
||
setDocTreeDeps({
|
||
loadDocContent,
|
||
openFileAsText,
|
||
externalChanges: () => _externalChanges,
|
||
});
|
||
}
|