优化前缀缓存机制和UI,并引入一些新工具.preview
This commit is contained in:
1 parent
863abe7c76
commit
6131c2563e
102 files changed
+9087
-4351
No files matched your search
+94
-408
@@ -2,7 +2,7 @@
|
||||
// 保留: 文件树渲染、文档修改追踪、外部变更处理、快捷键、拖拽引用
|
||||
// 移除: Monaco 编辑器、标签页、Agent 面板
|
||||
|
||||
import { $, escapeHtml, showToast, dedupePendingFiles } from './utils.js';
|
||||
import { $, escapeHtml, showToast, dedupePendingFiles, filterOutUnsupported, isUnsupportedFile } from './utils.js';
|
||||
import { api } from './api.js';
|
||||
import { state } from './state.js';
|
||||
import {
|
||||
@@ -14,12 +14,9 @@ import {
|
||||
} from './toolcalls.js';
|
||||
import { renderDocTree, setDocTreeDeps } from './doc-tree.js';
|
||||
import { openFilePreview, closeFilePreview } from './file-preview.js';
|
||||
import { setDocModifications } from './doc-mod-panel.js';
|
||||
|
||||
// ===== DOM 引用 =====
|
||||
const modeEditBtn = $("modeEditBtn");
|
||||
const modeChatBtn = $("modeChatBtn");
|
||||
const modeCronBtn = $("modeCronBtn");
|
||||
const modeCronToggleBtn = $("modeCronToggleBtn");
|
||||
const chatPanel = $("chatPanel");
|
||||
const sessionListWrap = $("sessionListWrap");
|
||||
const docTreeWrap = $("docTreeWrap");
|
||||
@@ -27,9 +24,7 @@ const cronListWrap = $("cronListWrap");
|
||||
const docTree = $("docTree");
|
||||
const sidebarHint = document.querySelector(".sidebar-hint");
|
||||
|
||||
// ===== 文档修改状态 =====
|
||||
let docModifications = {};
|
||||
let currentTaskModifications = [];
|
||||
// ===== 外部文件变更 =====
|
||||
let _externalChanges = {};
|
||||
let _fsChangeTimer = null;
|
||||
let _fsApplying = false;
|
||||
@@ -86,52 +81,88 @@ function isBinaryFile(name) {
|
||||
// ===== 模式切换 =====
|
||||
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/chat/cron 模式均只影响左侧栏(文件树/会话列表/定时任务),
|
||||
// 右侧聊天区保持不变,因此 Agent 运行中也可以自由切换,无需等待
|
||||
// Edit 已迁入右栏:模式切换只影响左侧栏(会话列表/定时任务)与右栏显隐,
|
||||
// 聊天区保持不变,Agent 运行中也可自由切换
|
||||
state.mode = mode;
|
||||
syncModeToggleBtn();
|
||||
if (mode === "edit") {
|
||||
state.editMode = true;
|
||||
modeChatBtn?.classList.remove("active");
|
||||
modeEditBtn?.classList.add("active");
|
||||
modeCronBtn?.classList.remove("active");
|
||||
// 编辑模式只影响左侧栏:显示文件树,隐藏会话列表与定时任务列表;聊天区域保持不变
|
||||
sessionListWrap?.classList.add("mode-hidden");
|
||||
// 左栏始终显示会话列表;文件树在右栏
|
||||
sessionListWrap?.classList.remove("mode-hidden");
|
||||
cronListWrap?.classList.add("mode-hidden");
|
||||
if (sidebarHint) sidebarHint.classList.add("mode-hidden");
|
||||
docTreeWrap?.classList.remove("mode-hidden");
|
||||
if (sidebarHint) sidebarHint.classList.remove("mode-hidden");
|
||||
setRightPanel(true);
|
||||
_onLeaveCron();
|
||||
} else if (mode === "cron") {
|
||||
state.editMode = false;
|
||||
modeChatBtn?.classList.remove("active");
|
||||
modeEditBtn?.classList.remove("active");
|
||||
modeCronBtn?.classList.add("active");
|
||||
chatPanel?.classList.remove("mode-hidden");
|
||||
sessionListWrap?.classList.add("mode-hidden");
|
||||
docTreeWrap?.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;
|
||||
modeChatBtn?.classList.add("active");
|
||||
modeEditBtn?.classList.remove("active");
|
||||
modeCronBtn?.classList.remove("active");
|
||||
chatPanel?.classList.remove("mode-hidden");
|
||||
sessionListWrap?.classList.remove("mode-hidden");
|
||||
if (sidebarHint) sidebarHint.classList.remove("mode-hidden");
|
||||
docTreeWrap?.classList.add("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 注入,避免循环依赖) =====
|
||||
@@ -208,8 +239,6 @@ export async function openFolder(folderPath) {
|
||||
}
|
||||
state.docFiles = [];
|
||||
state.docOpenTabs = [];
|
||||
docModifications = {};
|
||||
currentTaskModifications = [];
|
||||
_externalChanges = {};
|
||||
|
||||
for (const f of uniqueFiles) {
|
||||
@@ -218,6 +247,8 @@ export async function openFolder(folderPath) {
|
||||
|
||||
renderDocTree();
|
||||
renderDocTabs && renderDocTabs();
|
||||
// 打开文件夹后刷新 git 状态着色
|
||||
refreshGitStatus();
|
||||
|
||||
if (state.docFiles.length > 0) {
|
||||
state.activeDocFile = state.docFiles[0].path;
|
||||
@@ -237,20 +268,11 @@ export async function openFolder(folderPath) {
|
||||
}
|
||||
|
||||
export function showOpenButtons() {
|
||||
const snapDir = _snapDir();
|
||||
if (snapDir) {
|
||||
try { deleteFile(snapDir, ''); } catch {}
|
||||
}
|
||||
state._docRootPath = null;
|
||||
state._docRootName = null;
|
||||
state.docFiles = [];
|
||||
state.docOpenTabs = [];
|
||||
docModifications = {};
|
||||
currentTaskModifications = [];
|
||||
_externalChanges = {};
|
||||
_snapshots = [];
|
||||
_turnCounter = 0;
|
||||
_acceptedBaselines = {};
|
||||
renderDocTree();
|
||||
renderDocTabs && renderDocTabs();
|
||||
}
|
||||
@@ -264,7 +286,6 @@ export function renderDocTabs() {
|
||||
export async function loadDocContent(filePath) {
|
||||
if (!state._docRootPath) return;
|
||||
const fileName = filePath.split(/[/\\]/).pop() || filePath;
|
||||
const status = docModifications[filePath]?.type || "";
|
||||
|
||||
try {
|
||||
if (isPreviewableImage(fileName)) {
|
||||
@@ -278,7 +299,7 @@ export async function loadDocContent(filePath) {
|
||||
const res = await readTextFile(filePath, state._docRootPath);
|
||||
if (res && res.status === 'ok') {
|
||||
openFilePreview(filePath, fileName, res.content || '',
|
||||
{ status, onDownload: () => downloadFile(filePath, fileName) });
|
||||
{ onDownload: () => downloadFile(filePath, fileName) });
|
||||
}
|
||||
} else if (isPreviewableTable(fileName)) {
|
||||
// XLSX/XLS:后端提取为 TSV 文本,走与 CSV/TSV 一致的按列着色预览
|
||||
@@ -288,10 +309,10 @@ export async function loadDocContent(filePath) {
|
||||
try {
|
||||
const tsv = await api('/api/table/text?path=' + encodeURIComponent(absPath));
|
||||
openFilePreview(absPath, fileName, (tsv && tsv.content) || '',
|
||||
{ status, onDownload: () => downloadFile(absPath, fileName) });
|
||||
{ onDownload: () => downloadFile(absPath, fileName) });
|
||||
} catch (e) {
|
||||
openFilePreview(absPath, fileName, '(加载失败: ' + (e.message || e) + ')',
|
||||
{ status, onDownload: () => downloadFile(absPath, fileName) });
|
||||
{ onDownload: () => downloadFile(absPath, fileName) });
|
||||
}
|
||||
} else if (isBinaryFile(fileName)) {
|
||||
showToast('二进制文件不支持在线查看', 'warning');
|
||||
@@ -299,15 +320,15 @@ export async function loadDocContent(filePath) {
|
||||
const res = await readTextFile(filePath, state._docRootPath);
|
||||
if (res && res.status === 'ok') {
|
||||
openFilePreview(filePath, fileName, res.content || '',
|
||||
{ status, onDownload: () => downloadFile(filePath, fileName) });
|
||||
{ onDownload: () => downloadFile(filePath, fileName) });
|
||||
} else {
|
||||
openFilePreview(filePath, fileName, '(无法读取文件)',
|
||||
{ status, onDownload: () => downloadFile(filePath, fileName) });
|
||||
{ onDownload: () => downloadFile(filePath, fileName) });
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
openFilePreview(filePath, fileName, '(加载失败)',
|
||||
{ status, onDownload: () => downloadFile(filePath, fileName) });
|
||||
{ onDownload: () => downloadFile(filePath, fileName) });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -319,19 +340,18 @@ export async function openFileAsText(filePath) {
|
||||
showToast('二进制文件不支持在线查看', 'warning');
|
||||
return;
|
||||
}
|
||||
const status = docModifications[filePath]?.type || "";
|
||||
try {
|
||||
const res = await readTextFile(filePath, state._docRootPath);
|
||||
if (res && res.status === 'ok') {
|
||||
openFilePreview(filePath, fileName, res.content || '',
|
||||
{ status, onDownload: () => downloadFile(filePath, fileName), asText: true });
|
||||
{ onDownload: () => downloadFile(filePath, fileName), asText: true });
|
||||
} else {
|
||||
openFilePreview(filePath, fileName, '(无法读取文件)',
|
||||
{ status, onDownload: () => downloadFile(filePath, fileName) });
|
||||
{ onDownload: () => downloadFile(filePath, fileName) });
|
||||
}
|
||||
} catch {
|
||||
openFilePreview(filePath, fileName, '(加载失败)',
|
||||
{ status, onDownload: () => downloadFile(filePath, fileName) });
|
||||
{ onDownload: () => downloadFile(filePath, fileName) });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -373,329 +393,6 @@ async function downloadFile(filePath, fileName) {
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
// ===== 快照系统(类 Git,持久化到磁盘)=====
|
||||
// 目录结构: {snapshots_base}/{workspace_key}/
|
||||
// ├── _accepted/ # 接受基线
|
||||
// ├── turn_1/ # 第一轮快照
|
||||
// └── turn_2/ # 第二轮快照
|
||||
// snapshots_base 由后端 /api/workspace 返回,默认为 workspace/.minor_snapshots
|
||||
let _snapshots = []; // [{ turn: N, rootPath, timestamp, files: Map<path, {content, size, isBinary}> }]
|
||||
let _turnCounter = 0;
|
||||
let _acceptedBaselines = {}; // { path: { content, snapshotIdx } }
|
||||
|
||||
function _workspaceKey() {
|
||||
if (!state._docRootPath) return null;
|
||||
return state._docRootPath.replace(/[/\\]$/, '')
|
||||
.replace(/^[A-Za-z]:/, '')
|
||||
.replace(/[/\\:]/g, '_')
|
||||
.replace(/^_+/, '');
|
||||
}
|
||||
|
||||
function _snapDir() {
|
||||
if (!state._docRootPath) return null;
|
||||
const wsKey = _workspaceKey();
|
||||
if (!wsKey) return null;
|
||||
const base = window.__minorSnapBase
|
||||
|| (state._docRootPath.replace(/[/\\]$/, '') + '/.minor_snapshots');
|
||||
return base + '/' + wsKey;
|
||||
}
|
||||
|
||||
function _turnDir(turn) { return _snapDir() + '/turn_' + turn; }
|
||||
function _acceptDir() { return _snapDir() + '/_accepted'; }
|
||||
|
||||
/** 由外部设置快照基础目录(在 initWorkspace 中调用) */
|
||||
export function setSnapBaseDir(baseDir) {
|
||||
window.__minorSnapBase = baseDir.replace(/\\/g, '/').replace(/\/$/, '');
|
||||
}
|
||||
|
||||
/** 删除快照目录中的文件 */
|
||||
async function _deleteSnapFile(relPath, srcDirRel) {
|
||||
const rootPath = state._docRootPath;
|
||||
const srcRel = srcDirRel.replace(rootPath.replace(/[/\\]$/, ''), '').replace(/^[/\\]+/, '') + '/' + relPath.replace(/\\/g, '/');
|
||||
try { await deleteFile(srcRel, rootPath); } catch {}
|
||||
}
|
||||
|
||||
/** 复制文件(文本或二进制)到目标路径,目标路径相对于 workspace */
|
||||
async function _copyFileTo(relPath, targetDirRel) {
|
||||
const rootPath = state._docRootPath;
|
||||
const tgtRel = targetDirRel.replace(rootPath.replace(/[/\\]$/, ''), '').replace(/^[/\\]+/, '') + '/' + relPath.replace(/\\/g, '/');
|
||||
try {
|
||||
// Try text first
|
||||
const text = await readTextFile(relPath, rootPath);
|
||||
await writeTextFile(tgtRel, text, rootPath);
|
||||
return { content: text, isBinary: false };
|
||||
} catch {
|
||||
// Binary
|
||||
try {
|
||||
const bin = await readBinaryFile(relPath, rootPath);
|
||||
if (bin && bin.base64) {
|
||||
// writeFromBuffer takes (parentDir, fileName, base64)
|
||||
const parentDir = tgtRel.substring(0, tgtRel.lastIndexOf('/'));
|
||||
const fileName = relPath.replace(/\\/g, '/').split('/').pop();
|
||||
await writeFromBuffer(parentDir, fileName, bin.base64);
|
||||
return { content: bin, isBinary: true };
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 恢复文件(从源目录复制到 workspace)*/
|
||||
async function _restoreFile(relPath, srcDirRel) {
|
||||
const rootPath = state._docRootPath;
|
||||
const srcRel = srcDirRel.replace(rootPath.replace(/[/\\]$/, ''), '').replace(/^[/\\]+/, '') + '/' + relPath.replace(/\\/g, '/');
|
||||
try {
|
||||
// Try text
|
||||
const text = await readTextFile(srcRel, rootPath);
|
||||
await writeTextFile(relPath, text, rootPath);
|
||||
return true;
|
||||
} catch {
|
||||
// Try binary
|
||||
try {
|
||||
const bin = await readBinaryFile(srcRel, rootPath);
|
||||
if (bin && bin.base64) {
|
||||
const parentDir = relPath.replace(/\\/g, '/').split('/').slice(0, -1).join('/') || '.';
|
||||
const fileName = relPath.replace(/\\/g, '/').split('/').pop();
|
||||
await writeFromBuffer(parentDir, fileName, bin.base64);
|
||||
return true;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** 删除某文件在所有快照中的备份(接受或撤销时清理) */
|
||||
async function _purgeFileFromSnapshots(relPath) {
|
||||
for (const s of _snapshots) {
|
||||
s.files.delete(relPath);
|
||||
await _deleteSnapFile(relPath, _turnDir(s.turn));
|
||||
}
|
||||
// Also delete from accepted baselines
|
||||
await _deleteSnapFile(relPath, _acceptDir());
|
||||
}
|
||||
|
||||
/** 删除某个 turn 目录 */
|
||||
async function _deleteTurnDir(turn) {
|
||||
const dir = _turnDir(turn);
|
||||
try { await deleteFile(dir, ''); } catch {}
|
||||
}
|
||||
|
||||
/** 保存任务前快照(含完整文件内容,持久化到磁盘) */
|
||||
export async function snapshotFilesForTask() {
|
||||
if (!state._docRootPath) return;
|
||||
const rootPath = state._docRootPath;
|
||||
const files = new Map();
|
||||
const turn = ++_turnCounter;
|
||||
const tDir = _turnDir(turn);
|
||||
|
||||
for (const f of state.docFiles) {
|
||||
const result = await _copyFileTo(f.path, tDir);
|
||||
if (result) {
|
||||
files.set(f.path, { content: result.content, size: f.size, isBinary: result.isBinary });
|
||||
} else {
|
||||
files.set(f.path, { content: null, size: f.size, isBinary: false });
|
||||
}
|
||||
}
|
||||
|
||||
_snapshots.push({
|
||||
turn,
|
||||
rootPath,
|
||||
timestamp: Date.now(),
|
||||
files,
|
||||
});
|
||||
|
||||
// 首次快照时初始化接受基线(保存到磁盘)
|
||||
if (_snapshots.length === 1) {
|
||||
for (const [fp, info] of files) {
|
||||
await _copyFileTo(fp, _acceptDir());
|
||||
_acceptedBaselines[fp] = { content: info.content, snapshotIdx: 0, isBinary: info.isBinary };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 任务结束后对比快照,将Agent产生的变更记录到docModifications */
|
||||
export function detectTaskFileChanges() {
|
||||
if (_snapshots.length === 0 || !state._docRootPath) return;
|
||||
|
||||
const preSnap = _snapshots[_snapshots.length - 1];
|
||||
const preMap = preSnap.files;
|
||||
const curMap = new Map(state.docFiles.map(f => [f.path, f]));
|
||||
let changed = false;
|
||||
|
||||
for (const [fp, f] of curMap) {
|
||||
const pre = preMap.get(fp);
|
||||
if (!pre) {
|
||||
// 新文件
|
||||
docModifications[fp] = { original: "", current: f.path, type: "new", snapshotIdx: preSnap.turn };
|
||||
if (!currentTaskModifications.includes(fp)) currentTaskModifications.push(fp);
|
||||
changed = true;
|
||||
} else if (pre.size !== f.size) {
|
||||
// 内容被修改
|
||||
docModifications[fp] = { original: pre.content || f.path, current: f.path, type: "modified", snapshotIdx: preSnap.turn, oldContent: pre.content };
|
||||
if (!currentTaskModifications.includes(fp)) currentTaskModifications.push(fp);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
for (const [fp] of preMap) {
|
||||
if (!curMap.has(fp)) {
|
||||
// 被删除
|
||||
const preInfo = preMap.get(fp);
|
||||
docModifications[fp] = { original: preInfo.content || fp, current: "", type: "deleted", snapshotIdx: preSnap.turn, oldContent: preInfo.content };
|
||||
if (!currentTaskModifications.includes(fp)) currentTaskModifications.push(fp);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
setDocModifications(docModifications);
|
||||
renderDocTree();
|
||||
try { import('./doc-mod-panel.js').then(m => { m.setDocModifications(docModifications); m.renderDocModPanel(); }); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
/** 计算两个文本内容的行级 diff(返回简化差异列表) */
|
||||
export function computeLineDiff(oldText, newText) {
|
||||
if (oldText == null || newText == null) return null;
|
||||
const oldLines = (oldText || '').split('\n');
|
||||
const newLines = (newText || '').split('\n');
|
||||
return _lineDiff(oldLines, newLines);
|
||||
}
|
||||
|
||||
function _lineDiff(oldLines, newLines) {
|
||||
// 回溯 LCS
|
||||
const m = oldLines.length, n = newLines.length;
|
||||
// 对短文本使用简单 diff
|
||||
if (m === 0 && n === 0) return [];
|
||||
if (m === 0) return [{ type: 'add', startLine: 1, endLine: n, lines: newLines }];
|
||||
if (n === 0) return [{ type: 'remove', startLine: 1, endLine: m, lines: oldLines }];
|
||||
|
||||
// LCS 矩阵
|
||||
const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
|
||||
for (let i = 1; i <= m; i++) {
|
||||
for (let j = 1; j <= n; j++) {
|
||||
if (oldLines[i - 1] === newLines[j - 1]) {
|
||||
dp[i][j] = dp[i - 1][j - 1] + 1;
|
||||
} else {
|
||||
dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 回溯
|
||||
const diffs = [];
|
||||
let i = m, j = n;
|
||||
const tempOps = [];
|
||||
|
||||
while (i > 0 || j > 0) {
|
||||
if (i > 0 && j > 0 && oldLines[i - 1] === newLines[j - 1]) {
|
||||
tempOps.unshift({ type: 'keep', oldLine: i, newLine: j });
|
||||
i--; j--;
|
||||
} else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) {
|
||||
tempOps.unshift({ type: 'add', newLine: j, line: newLines[j - 1] });
|
||||
j--;
|
||||
} else {
|
||||
tempOps.unshift({ type: 'remove', oldLine: i, line: oldLines[i - 1] });
|
||||
i--;
|
||||
}
|
||||
}
|
||||
|
||||
// 合并相邻的同类型差异
|
||||
for (const op of tempOps) {
|
||||
if (op.type === 'keep') continue;
|
||||
const last = diffs[diffs.length - 1];
|
||||
if (last && last.type === op.type) {
|
||||
if (op.type === 'add') {
|
||||
last.endLine = op.newLine;
|
||||
last.lines.push(op.line);
|
||||
} else {
|
||||
last.endLine = op.oldLine;
|
||||
last.lines.push(op.line);
|
||||
}
|
||||
} else {
|
||||
if (op.type === 'add') {
|
||||
diffs.push({ type: 'add', startLine: op.newLine, endLine: op.newLine, lines: [op.line] });
|
||||
} else {
|
||||
diffs.push({ type: 'remove', startLine: op.oldLine, endLine: op.oldLine, lines: [op.line] });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return diffs;
|
||||
}
|
||||
|
||||
/** 接受单个文件的修改(更新接受基线,持久化,清理快照)*/
|
||||
export async function acceptFileModification(filePath) {
|
||||
const rootPath = state._docRootPath;
|
||||
if (!rootPath) return;
|
||||
|
||||
const mod = docModifications[filePath];
|
||||
if (!mod) return;
|
||||
|
||||
if (mod.type === 'deleted') {
|
||||
// 接受删除 → 从修改列表和接受基线中移除
|
||||
delete docModifications[filePath];
|
||||
delete _acceptedBaselines[filePath];
|
||||
await _deleteSnapFile(filePath, _acceptDir());
|
||||
} else {
|
||||
// 接受新增/修改 → 更新磁盘上的接受基线
|
||||
const result = await _copyFileTo(filePath, _acceptDir());
|
||||
_acceptedBaselines[filePath] = {
|
||||
content: result ? result.content : null,
|
||||
snapshotIdx: _snapshots.length,
|
||||
isBinary: result ? result.isBinary : false
|
||||
};
|
||||
delete docModifications[filePath];
|
||||
}
|
||||
|
||||
// 清理该文件在所有快照中的备份
|
||||
await _purgeFileFromSnapshots(filePath);
|
||||
|
||||
currentTaskModifications = currentTaskModifications.filter(f => f !== filePath);
|
||||
setDocModifications(docModifications);
|
||||
renderDocTree();
|
||||
}
|
||||
|
||||
/** 撤销单个文件的修改(回到接受基线,从磁盘恢复,支持二进制)*/
|
||||
export async function rejectFileModification(filePath) {
|
||||
const rootPath = state._docRootPath;
|
||||
if (!rootPath) return;
|
||||
|
||||
const mod = docModifications[filePath];
|
||||
if (!mod) return;
|
||||
|
||||
if (mod.type === 'deleted') {
|
||||
// 撤销删除 → 从接受基线恢复文件
|
||||
await _restoreFile(filePath, _acceptDir());
|
||||
} else if (mod.type === 'new') {
|
||||
// 撤销新增 → 删除文件
|
||||
try { await deleteFile(filePath, rootPath); } catch {}
|
||||
} else {
|
||||
// 撤销修改 → 从接受基线恢复文件
|
||||
await _restoreFile(filePath, _acceptDir());
|
||||
}
|
||||
|
||||
// 清理该文件在所有快照中的备份
|
||||
await _purgeFileFromSnapshots(filePath);
|
||||
|
||||
delete docModifications[filePath];
|
||||
currentTaskModifications = currentTaskModifications.filter(f => f !== filePath);
|
||||
setDocModifications(docModifications);
|
||||
renderDocTree();
|
||||
|
||||
// 刷新文件列表
|
||||
setTimeout(() => openFolder(rootPath), 500);
|
||||
}
|
||||
|
||||
// 向后兼容导出
|
||||
export function getDocModifications() { return docModifications; }
|
||||
export function getCurrentTaskModifications() { return currentTaskModifications; }
|
||||
export async function acceptAllModifications() {
|
||||
for (const fp of Object.keys(docModifications)) await acceptFileModification(fp);
|
||||
}
|
||||
export async function rejectAllModifications() {
|
||||
for (const fp of Object.keys(docModifications)) await rejectFileModification(fp);
|
||||
}
|
||||
|
||||
// ===== 外部文件变更(实时自动刷新,类似 VS Code) =====
|
||||
function handleFsChange(events) {
|
||||
if (_fsChangeTimer) clearTimeout(_fsChangeTimer);
|
||||
@@ -743,13 +440,6 @@ async function applyFsChanges(events) {
|
||||
if (changed) {
|
||||
state.docFiles = currentFiles;
|
||||
renderDocTree();
|
||||
// 同步外部变更到文档修改面板
|
||||
const extMods = {};
|
||||
for (const [fp, ec] of Object.entries(_externalChanges)) {
|
||||
extMods[fp] = { original: fp, current: fp, type: ec.type === 'externally_added' ? 'new' : ec.type === 'externally_modified' ? 'modified' : 'deleted' };
|
||||
}
|
||||
setDocModifications({ ...docModifications, ...extMods });
|
||||
try { import('./doc-mod-panel.js').then(m => m.renderDocModPanel()); } catch {}
|
||||
}
|
||||
} finally {
|
||||
_fsApplying = false;
|
||||
@@ -759,8 +449,6 @@ async function applyFsChanges(events) {
|
||||
export function clearExternalChange(filePath) {
|
||||
delete _externalChanges[filePath];
|
||||
renderDocTree();
|
||||
setDocModifications(docModifications);
|
||||
try { import('./doc-mod-panel.js').then(m => m.renderDocModPanel()); } catch {}
|
||||
}
|
||||
|
||||
// ===== 拖拽引用 =====
|
||||
@@ -843,7 +531,7 @@ function setupInputDropHandler() {
|
||||
if (e.dataTransfer.items && e.dataTransfer.items.length > 0) {
|
||||
e.stopPropagation();
|
||||
const items = Array.from(e.dataTransfer.items);
|
||||
// 先收集所有 entry,判断是否包含文件夹
|
||||
// 先收集所有 entry,判断是否包含文件夹(视频暂不支持,直接过滤)
|
||||
const allEntries = [];
|
||||
for (const item of items) {
|
||||
if (item.kind !== 'file') continue;
|
||||
@@ -851,11 +539,13 @@ function setupInputDropHandler() {
|
||||
const file = item.getAsFile();
|
||||
allEntries.push({ entry, file });
|
||||
}
|
||||
const hasDirectory = allEntries.some(({ entry }) => entry && entry.isDirectory);
|
||||
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 allEntries) {
|
||||
for (const { entry, file } of filteredEntries) {
|
||||
if (entry && entry.isDirectory) {
|
||||
const folderPath = (file && file.path) ? file.path : '';
|
||||
const folderName = entry.name || (file ? file.name : '文件夹');
|
||||
@@ -866,7 +556,7 @@ function setupInputDropHandler() {
|
||||
// 无文件夹:图片作为普通 File 上传(HumanMessage image_url),非图片作为 @file: 引用
|
||||
// Electron 拖入的 File 带 path(绝对路径)且已包含二进制内容;无 path 时回退为普通上传
|
||||
let needFallback = false;
|
||||
for (const { file } of allEntries) {
|
||||
for (const { file } of filteredEntries) {
|
||||
if (!file) continue;
|
||||
if (_isImageName(file.name)) {
|
||||
// 图片:直接作为普通文件上传(File 已含二进制内容)
|
||||
@@ -885,7 +575,7 @@ function setupInputDropHandler() {
|
||||
}
|
||||
}
|
||||
|
||||
if (allEntries.length > 0) {
|
||||
if (filteredEntries.length > 0) {
|
||||
import('./chat-render.js').then(m => { if (m.renderAttachmentChips) m.renderAttachmentChips(); });
|
||||
}
|
||||
return;
|
||||
@@ -894,7 +584,7 @@ function setupInputDropHandler() {
|
||||
// 4. 回退:dataTransfer.files(无 items 时)— 图片作为普通 File 上传,非图片作为 @file: 引用
|
||||
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
|
||||
e.stopPropagation();
|
||||
const files = Array.from(e.dataTransfer.files);
|
||||
const files = filterOutUnsupported(Array.from(e.dataTransfer.files));
|
||||
let needFallback = false;
|
||||
for (const file of files) {
|
||||
if (_isImageName(file.name)) {
|
||||
@@ -954,6 +644,12 @@ 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) {
|
||||
// 防重复:若已作为普通文件或引用存在则跳过
|
||||
@@ -1041,6 +737,12 @@ 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) =>
|
||||
@@ -1098,18 +800,7 @@ export function clearRefChips() {
|
||||
|
||||
// ===== 清除状态 =====
|
||||
export function clearEditState() {
|
||||
// 清理该工作空间对应的快照子目录
|
||||
const snapDir = _snapDir();
|
||||
if (snapDir) {
|
||||
try { deleteFile(snapDir, ''); } catch {}
|
||||
}
|
||||
docModifications = {};
|
||||
currentTaskModifications = [];
|
||||
_externalChanges = {};
|
||||
_snapshots = [];
|
||||
_turnCounter = 0;
|
||||
_acceptedBaselines = {};
|
||||
setDocModifications({});
|
||||
closeFilePreview();
|
||||
clearRefChips();
|
||||
state.docFiles = [];
|
||||
@@ -1142,13 +833,13 @@ export async function sendEditChat() {
|
||||
|
||||
// ===== 初始化 =====
|
||||
export function initEditMode() {
|
||||
// 模式切换按钮
|
||||
modeEditBtn?.addEventListener("click", () => {
|
||||
if (!state.editMode) switchToMode("edit");
|
||||
});
|
||||
modeChatBtn?.addEventListener("click", () => {
|
||||
// 从 edit 或 cron 模式均可切回 chat
|
||||
if (state.mode !== "chat") switchToMode("chat");
|
||||
// git 状态刷新事件(回撤/右栏打开/文件操作后触发)
|
||||
window.addEventListener("git-status-changed", () => refreshGitStatus());
|
||||
|
||||
// 顶栏"定时任务"按钮:cron ↔ chat 切换(Edit 模式点击同样进入 cron)
|
||||
modeCronToggleBtn?.addEventListener("click", () => {
|
||||
if (state.mode !== "cron") switchToMode("cron");
|
||||
else switchToMode("chat");
|
||||
});
|
||||
|
||||
// 打开文件夹 - 移除按钮,改用工作空间选择器触发
|
||||
@@ -1182,9 +873,4 @@ export function initEditMode() {
|
||||
openFileAsText,
|
||||
externalChanges: () => _externalChanges,
|
||||
});
|
||||
|
||||
// 初始化后同步到 doc-mod-panel
|
||||
try {
|
||||
import('./doc-mod-panel.js').then(m => m.bindDocModEvents());
|
||||
} catch {}
|
||||
}
|
||||
Reference in new issue
Block a user