update
This commit is contained in:
453
src/file-ops.js
453
src/file-ops.js
@@ -21,7 +21,7 @@
|
||||
import { showToast } from './feedback.js';
|
||||
import { showPromptDialog } from './prompt-dialog.js';
|
||||
import { showConfirmDialog } from './confirm-dialog.js';
|
||||
import { isMarkdownFile } from './utils.js';
|
||||
import { isMarkdownFile, basenameOf } from './utils.js';
|
||||
// 救援工具:与 src/app.js 共享同一份命名 / toast / friendlyFsError 逻辑,
|
||||
// 这里只关心「我有 content、想救回」。详见 src/rescue.js 注释。
|
||||
import { rescueDirtyContent } from './rescue.js';
|
||||
@@ -72,10 +72,46 @@ function validateFileName(raw) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验输入是否可作为文件夹名。镜像 main resolveFolderName 的输入合法性检查。
|
||||
* 不校验「重名」—— 那是 IPC 端的事,folder:create 也走主进程协商,不在 renderer 重复实现。
|
||||
*
|
||||
* 与 validateFileName 保持一致:文件夹名**也做扩展名切割**,按基名判定保留设备名。
|
||||
* Windows 对目录同样按基名保留设备名("CON.txt" / "AUX.abc" 都算保留,扩展名被忽略),
|
||||
* 不切割会漏掉 "CON.txt" 这类非法名(客户端校验通过却最终在主进程 / 文件系统被拒)。
|
||||
*
|
||||
* 共享注释见上方 WIN_RESERVED_DEVICE_NAMES:
|
||||
* main 是 CJS(require('fs')),renderer 是 ESM 不能 require Node 模块;
|
||||
* 退而求其次把规则文档化在 main + renderer 两处注释,改动频次极低(Windows 保留设备名
|
||||
* 清单基本不变),双拷贝维护成本可控。
|
||||
*
|
||||
* @param {string} raw
|
||||
* @returns {string|null}
|
||||
*/
|
||||
function validateFolderName(raw) {
|
||||
if (typeof raw !== 'string' || !raw.trim()) return '文件夹名不能为空';
|
||||
const name = raw.trim();
|
||||
if (/[/\\]/.test(name) || name.includes('..')) return '文件夹名不能包含路径分隔符或 ..';
|
||||
if (/[<>:"|?*]/.test(name)) return '文件夹名包含 Windows 保留字符(< > : " | ? *)';
|
||||
// eslint-disable-next-line no-control-regex
|
||||
if (/[\x00-\x1f]/.test(name)) return '文件夹名包含非法字符';
|
||||
// Windows 对文件夹同样按「基名」判定保留设备名("CON.txt" / "AUX.abc" 都算保留),
|
||||
// 与文件一致:取最后一个 . 之前的部分判断。不切割会漏掉 "CON.txt" 这类非法名。
|
||||
const lastDotF = name.lastIndexOf('.');
|
||||
const baseForReservedF = (lastDotF > 0 ? name.slice(0, lastDotF) : name).toUpperCase();
|
||||
if (WIN_RESERVED_DEVICE_NAMES.has(baseForReservedF)) {
|
||||
return `"${baseForReservedF}" 是 Windows 保留设备名,不允许作为文件夹名`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {object} FileOpsCtx
|
||||
* @property {object} state - 共享状态对象(含 currentFile / isDirty / lastSavedMtimeMs)
|
||||
* @property {object} controls - { fileList, viewer, editor, contextMenu }
|
||||
* 注意(P2-9):editor 由 app.js bootstrap 延迟实例化,controls.editor 必须
|
||||
* 经 getter 运行时现取(app.js 用对象字面量 getter 提供),本模块内部
|
||||
* 一律走 getEditor(),不在构造时解构缓存。
|
||||
* @property {object} api - preload contextBridge 暴露的 window.api
|
||||
* @property {object} callbacks - { openFile, updateWindowTitle, updateStatusStats, syncSaveButton, clearCurrentFile, applyEditorMode, onFileChanged? }
|
||||
*/
|
||||
@@ -88,9 +124,23 @@ export function createFileOps(ctx) {
|
||||
if (!state || !controls || !api || !callbacks) {
|
||||
throw new Error('[file-ops] ctx 必须包含 state / controls / api / callbacks');
|
||||
}
|
||||
const { contextMenu, viewer, fileList, editor } = controls;
|
||||
const { contextMenu, viewer, fileList } = controls;
|
||||
// P2-9(audit C-4):app.js 顶层不再 new MarkdownEditor(CM6 dynamic import,
|
||||
// bootstrap 里才实例化),createFileOps 在模块 eval 时 controls.editor 还是
|
||||
// null —— 不能构造时解构缓存,必须运行时现取。app.js 侧 controls 用对象
|
||||
// 字面量 getter 提供活视图(tests/unit/file-ops-lazy-editor.test.js 锁死)。
|
||||
const getEditor = () => controls.editor;
|
||||
const { openFile, updateWindowTitle, clearCurrentFile } = callbacks;
|
||||
|
||||
/**
|
||||
* editor.getContent() 安全读取:editor 未实例化(null)或无该方法时回 ''。
|
||||
* 仅 bootstrap 完成前的事件路径才可能命中 null 分支,正常恒非空。
|
||||
*/
|
||||
function _editorContentOrEmpty() {
|
||||
const ed = getEditor();
|
||||
return (ed && typeof ed.getContent === 'function') ? ed.getContent() : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 时间戳格式化(用于「未命名-20260824-1530」这种默认文件名)。
|
||||
*/
|
||||
@@ -141,13 +191,13 @@ export function createFileOps(ctx) {
|
||||
// 审计修复 (Round 11 deep-fix P1-1):syncDirtyFromEditor 把 editor.isDirty()
|
||||
// 真相同步到 state.isDirty,避免 IME 合成期删除当前文件时跳过 dirty 确认
|
||||
// 静默销毁内容。详见 src/dirty-sync.js + app.js#syncDirtyFromEditor。
|
||||
syncDirtyFromEditor(state, editor);
|
||||
syncDirtyFromEditor(state, getEditor());
|
||||
const isCurrent = state.currentFile && state.currentFile.path === file.path;
|
||||
// 删除当前文件 + 有未保存改动 → 二次确认
|
||||
if (isCurrent && state.isDirty) {
|
||||
const choice = await showConfirmDialog({
|
||||
title: '删除未保存的文件',
|
||||
message: `「${file.name}」有未保存改动,确定要删除(移至回收站)吗?`,
|
||||
message: `「${file.name}」有未保存改动。删除(移至回收站)后无法恢复,确定继续?`,
|
||||
icon: 'danger',
|
||||
buttons: [
|
||||
{ value: 'cancel', label: '取消', variant: 'secondary' },
|
||||
@@ -162,26 +212,27 @@ export function createFileOps(ctx) {
|
||||
const result = await api.deleteFile(file.path);
|
||||
if (!result || !result.ok) {
|
||||
if (result && result.error === 'FILE_NOT_FOUND') {
|
||||
// 文件已不在了(用户手动删过、外部工具删过)—— 仿照 renameCurrentOrFile
|
||||
// 的 FILE_NOT_FOUND 路径:dirty 状态下不能直接 clearCurrentFile 丢内容。
|
||||
// 先取 editor.getContent() 救到 <base>-rescued-<stamp>-<suffix>.md,再清视图。
|
||||
// audit fix (Round 4 F1):之前 deleteFile 的 FILE_NOT_FOUND 直接 clearCurrentFile
|
||||
// → 正在编辑的 dirty 内容静默丢失。state.deletingPath 已设,挡住 onFilesChanged
|
||||
// rescue 分支的并发救回,但 IPC 返回后由本函数自己救一次,保证不漏。
|
||||
// 文件/文件夹已不在了(用户手动删过、外部工具删过)。dirty 状态下不能直接
|
||||
// clearCurrentFile 丢内容 —— 先救到 <base>-rescued-<stamp>.md,再清视图
|
||||
// (与 renameCurrentOrFile 的 FILE_NOT_FOUND 路径对齐)。state.deletingPath
|
||||
// 已设,挡住 onFilesChanged rescue 分支的并发救回,由本函数自己救一次。
|
||||
if (state.isDirty && state.currentFile && state.currentFile.path === file.path) {
|
||||
// 复用 ctx.api 保持 IPC 源一致(与文件其它调用方对齐),不走 window.api。
|
||||
// 命名 / toast 文案 / friendlyFsError 错误码 vs message 的判断
|
||||
// 全部收口到 src/rescue.js(之前这里手动传 r?.message || r?.error
|
||||
// 是 audit Round 16 R2 修过的关键 bug —— 业务码不能给用户看)。
|
||||
// 命名 / toast 文案 / 错误码 vs message 判断全部收口到 src/rescue.js。
|
||||
rescueDirtyContent({
|
||||
api,
|
||||
originalName: state.currentFile.name,
|
||||
content: (typeof editor.getContent === 'function') ? editor.getContent() : '',
|
||||
content: _editorContentOrEmpty(),
|
||||
event: '文件已不在',
|
||||
});
|
||||
} else {
|
||||
showToast('文件已不存在', 'warning');
|
||||
}
|
||||
// 与 renameCurrentOrFile 对齐:dirty 救回 / 非脏提示后都清掉已删除文件的视图,
|
||||
// 否则会留下一个指向已删除文件的空壳视图(之前漏了 clearCurrentFile)。
|
||||
if (state.currentFile && state.currentFile.path === file.path) {
|
||||
clearCurrentFile();
|
||||
}
|
||||
return;
|
||||
} else {
|
||||
showToast(`删除失败:${window.api.friendlyFsError(result && result.code, result && result.message)}`, 'error');
|
||||
return;
|
||||
@@ -214,10 +265,10 @@ export function createFileOps(ctx) {
|
||||
if (!file || !file.path) return;
|
||||
// 审计修复 (Round 11 deep-fix P1-1):sync editor.isDirty() → state.isDirty。
|
||||
// rename 的 dirty 检查在 FILE_NOT_FOUND 路径下出现,IME 期间跳过会丢 IME buffer。
|
||||
syncDirtyFromEditor(state, editor);
|
||||
syncDirtyFromEditor(state, getEditor());
|
||||
const raw = await showPromptDialog({
|
||||
title: '重命名',
|
||||
message: '重命名为',
|
||||
title: '重命名文件',
|
||||
message: '新文件名(可改为其他扩展名)',
|
||||
defaultValue: file.name,
|
||||
confirmLabel: '重命名',
|
||||
validate: validateFileName,
|
||||
@@ -259,7 +310,7 @@ export function createFileOps(ctx) {
|
||||
rescueDirtyContent({
|
||||
api,
|
||||
originalName: state.currentFile.name,
|
||||
content: (typeof editor.getContent === 'function') ? editor.getContent() : '',
|
||||
content: _editorContentOrEmpty(),
|
||||
event: '原文件已被删除',
|
||||
});
|
||||
} else {
|
||||
@@ -309,9 +360,7 @@ export function createFileOps(ctx) {
|
||||
// 再调 applyEditorMode 让工具栏按钮可用性 跟着对。
|
||||
if (wasMd !== isMd) {
|
||||
try {
|
||||
const currentContent = editor && typeof editor.getContent === 'function'
|
||||
? editor.getContent()
|
||||
: '';
|
||||
const currentContent = _editorContentOrEmpty();
|
||||
if (isMd) {
|
||||
viewer.loadContent(state.currentFile, currentContent);
|
||||
} else {
|
||||
@@ -353,31 +402,40 @@ export function createFileOps(ctx) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 顶部「+」新建按钮:弹 prompt 取名 → 走 createFile → 自动打开新文件。
|
||||
* 「新建笔记」入口:弹 prompt 取名 → 走 createFile → 自动打开新文件。
|
||||
*
|
||||
* Stage 8:把 state.currentDir 透传给 api.createFile,让新文件落在当前浏览的
|
||||
* 子目录里(而不是永远落到数据根)。
|
||||
* 2026-09 设计优化后,调用入口只剩两个:
|
||||
* - 侧栏空白区域右键菜单的「新建文件」(src/app.js#showSidebarEmptyContextMenu)
|
||||
* - 文件夹条目右键菜单的「在文件夹中新建文件」(showFileListContextMenu 的
|
||||
* `new_file_in` 项,传 entry.path 作 targetDir)
|
||||
* 侧栏顶部原「+」按钮已移除;旧的「顶部 + 按钮」注释保留会让后续维护者
|
||||
* grep 不到调用点。Stage 8 把 state.currentDir 透传给 api.createFile,让新
|
||||
* 文件落在当前浏览的子目录里(而不是永远落到数据根)。
|
||||
*
|
||||
* 用户反馈(2026-08-28):「新建笔记时直接把后缀放在文件名后面,而不是自动
|
||||
* 加上」—— 默认值已带 .md,用户可见 / 可改(直接改 .txt 等)。主进程
|
||||
* resolveFileName 也对齐 resolveRenameName:用户输入什么就用什么,
|
||||
* 不再强制补 .md / 自动加后缀。这样想建一个 .txt 纯文本笔记 / .json
|
||||
* 数据笔记时不再需要「先建 .md → 重命名成 .txt」两步。
|
||||
*
|
||||
* @param {string} [targetDir] 可选:在某个文件夹内新建时传入该文件夹绝对路径
|
||||
* (侧栏右键文件夹 → 「在文件夹中新建文件」)。省略则落在当前浏览目录
|
||||
* state.currentDir。prompt 文案据 targetDir 显示「当前文件夹:xxx」。
|
||||
*/
|
||||
async function createNewFile() {
|
||||
async function createNewFile(targetDir) {
|
||||
// 审计修复 (Round 11 deep-fix P1-1):entry sync 把 editor.isDirty() 真相同步给
|
||||
// state.isDirty —— 防止 IME 合成期点「新建」时 stale-false 跳过 dirty 确认,
|
||||
// 用户的 CJK buffer 静默丢失。
|
||||
syncDirtyFromEditor(state, editor);
|
||||
syncDirtyFromEditor(state, getEditor());
|
||||
if (state.isDirty) {
|
||||
// 给用户一个机会先保存当前文件 —— 新建会丢掉未保存改动
|
||||
const choice = await showConfirmDialog({
|
||||
title: '新建文件',
|
||||
message: '当前文件有未保存改动,新建文件将丢弃这些改动。继续?',
|
||||
title: '新建笔记',
|
||||
message: `「${state.currentFile?.name || '当前笔记'}」有未保存改动,新建后会被丢弃。`,
|
||||
icon: 'warning',
|
||||
buttons: [
|
||||
{ value: 'cancel', label: '取消', variant: 'secondary' },
|
||||
{ value: 'confirm', label: '继续新建', variant: 'danger' },
|
||||
{ value: 'confirm', label: '丢弃并新建', variant: 'danger' },
|
||||
],
|
||||
});
|
||||
if (choice !== 'confirm') return;
|
||||
@@ -397,9 +455,11 @@ export function createFileOps(ctx) {
|
||||
const breadcrumbTail = (state.breadcrumb && state.breadcrumb.length > 0)
|
||||
? state.breadcrumb.map((s) => s.name).join('/')
|
||||
: '';
|
||||
const message = breadcrumbTail
|
||||
? `新建笔记的文件名(含后缀,可直接改 .md / .txt / .json 等)\n当前文件夹:${breadcrumbTail}`
|
||||
: '新建笔记的文件名(含后缀,可直接改 .md / .txt / .json 等)';
|
||||
// targetDir 非空 ⇒ 在某个文件夹内新建,提示用该文件夹名而非当前浏览路径
|
||||
const targetLabel = targetDir ? basenameOf(targetDir) : breadcrumbTail;
|
||||
const message = targetLabel
|
||||
? `新笔记的名称(含扩展名,默认 .md)\n当前文件夹:${targetLabel}`
|
||||
: '新笔记的名称(含扩展名,默认 .md)';
|
||||
const raw = await showPromptDialog({
|
||||
title: '新建笔记',
|
||||
message,
|
||||
@@ -413,8 +473,10 @@ export function createFileOps(ctx) {
|
||||
showToast('文件名不能为空', 'warning');
|
||||
return;
|
||||
}
|
||||
// Stage 8:把当前目录绝对路径传给主进程;主进程负责 auto-mkdir(用户主动在该目录下新建)。
|
||||
const result = await api.createFile(name, { dir: state.currentDir || '' });
|
||||
// 落点:targetDir 优先(右键文件夹内新建),否则当前浏览目录 state.currentDir。
|
||||
// 主进程负责 auto-mkdir(用户主动在该目录下新建)。
|
||||
const dir = targetDir || state.currentDir || '';
|
||||
const result = await api.createFile(name, { dir });
|
||||
if (!result || !result.ok) {
|
||||
showToast(`新建失败:${window.api.friendlyFsError(result && result.code, result && result.message)}`, 'error');
|
||||
return;
|
||||
@@ -432,27 +494,325 @@ export function createFileOps(ctx) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件列表右键菜单:重命名 / 在文件夹中显示 / 删除
|
||||
* 新建文件夹:弹 prompt 取名 → 走 folder:create。
|
||||
* 设计上**不自动进入**新建的空目录,保持当前视图不变(用户继续浏览原目录列表)。
|
||||
* 自动进入会让上下文跳动,且 fs-watcher 会推「新增一条」让侧栏自动出现新条目。
|
||||
*
|
||||
* Stage 8:folder 条目上隐藏「重命名」—— file:rename 主进程逻辑强制 .md 后缀,
|
||||
* 对目录并不合适;文件夹的批量整理通过 shell 文件管理器(系统级)操作更直接,
|
||||
* 这里至少保留删除 + 在文件夹中显示。
|
||||
* 落点:state.currentDir(当前浏览目录),与 createNewFile 同源语义。
|
||||
* 默认值不带 .md / 任何后缀 —— 文件夹不要扩展名。
|
||||
*
|
||||
* @param {string} [targetDir] 可选:在某个文件夹内新建时传入该文件夹绝对路径
|
||||
* (侧栏右键文件夹 → 「在文件夹中新建文件夹」)。省略则落在当前浏览目录。
|
||||
*/
|
||||
async function createNewFolder(targetDir) {
|
||||
syncDirtyFromEditor(state, getEditor());
|
||||
if (state.isDirty) {
|
||||
const choice = await showConfirmDialog({
|
||||
title: '新建文件夹',
|
||||
message: `「${state.currentFile?.name || '当前笔记'}」有未保存改动,新建后会被丢弃。`,
|
||||
icon: 'warning',
|
||||
buttons: [
|
||||
{ value: 'cancel', label: '取消', variant: 'secondary' },
|
||||
{ value: 'confirm', label: '丢弃并新建', variant: 'danger' },
|
||||
],
|
||||
});
|
||||
if (choice !== 'confirm') return;
|
||||
// 与 createNewFile 同源:在 prompt 阶段保持 dirty 文件不变,成功建完才清。
|
||||
// 若用户取消 prompt,编辑器内容原样保留。
|
||||
}
|
||||
const defaultName = `新建文件夹-${formatStampForName(new Date())}`;
|
||||
const breadcrumbTail = (state.breadcrumb && state.breadcrumb.length > 0)
|
||||
? state.breadcrumb.map((s) => s.name).join('/')
|
||||
: '';
|
||||
// targetDir 非空 ⇒ 在某个文件夹内新建,提示用该文件夹名而非当前浏览路径
|
||||
const targetLabel = targetDir ? basenameOf(targetDir) : breadcrumbTail;
|
||||
const message = targetLabel
|
||||
? `新文件夹的名称(不含扩展名)\n当前文件夹:${targetLabel}`
|
||||
: '新文件夹的名称(不含扩展名)';
|
||||
const raw = await showPromptDialog({
|
||||
title: '新建文件夹',
|
||||
message,
|
||||
defaultValue: defaultName,
|
||||
confirmLabel: '创建',
|
||||
validate: validateFolderName,
|
||||
});
|
||||
if (raw == null) return;
|
||||
const name = raw.trim();
|
||||
if (!name) {
|
||||
showToast('文件夹名不能为空', 'warning');
|
||||
return;
|
||||
}
|
||||
const dir = targetDir || state.currentDir || '';
|
||||
const result = await api.createFolder(name, { dir });
|
||||
if (!result || !result.ok) {
|
||||
showToast(`新建文件夹失败:${window.api.friendlyFsError(result && result.code, result && result.message)}`, 'error');
|
||||
return;
|
||||
}
|
||||
// 推迟清空当前文件 —— 与 createNewFile 同源语义(用户已经同意丢弃 → 现在建成功再清)。
|
||||
if (state.isDirty) clearCurrentFile();
|
||||
showToast(`已创建文件夹:${result.name}`, 'success', 1500);
|
||||
// 不自动进入新文件夹 —— fs-watcher 会把新条目推到侧栏,用户需要时再点。
|
||||
}
|
||||
|
||||
/**
|
||||
* 重命名文件夹:弹 prompt 取新名 → 走 folder:rename。
|
||||
* 若被重命名的是当前目录或其祖先上的某一段,需要 callback 把
|
||||
* state.currentDir / fs-watcher / breadcrumb 同步到新路径。
|
||||
* 决策权交给 caller 的 `wasEntryAffectingCurrentDir` + `navigateToRelDir` 回调。
|
||||
*/
|
||||
async function renameFolder(entry) {
|
||||
if (!entry || !entry.path || entry.entryType !== 'folder') return;
|
||||
syncDirtyFromEditor(state, getEditor()); // 与 file rename 对齐:IME 合成期避免 stale state
|
||||
const raw = await showPromptDialog({
|
||||
title: '重命名文件夹',
|
||||
message: '新文件夹名称(不含扩展名)',
|
||||
defaultValue: entry.name,
|
||||
confirmLabel: '重命名',
|
||||
validate: validateFolderName,
|
||||
});
|
||||
if (raw == null) return;
|
||||
const newName = raw.trim();
|
||||
if (!newName) {
|
||||
showToast('文件夹名不能为空', 'warning');
|
||||
return;
|
||||
}
|
||||
if (newName === entry.name) return;
|
||||
const result = await api.renameFolder(entry.path, newName);
|
||||
if (!result || !result.ok) {
|
||||
showToast(`重命名失败:${window.api.friendlyFsError(result && result.code, result && result.message)}`, 'error');
|
||||
return;
|
||||
}
|
||||
showToast(`已重命名为:${result.name}`, 'success', 1500);
|
||||
|
||||
// 重命名影响当前路径:把 state.currentDir / currentRelDir / fs-watcher 同步过去
|
||||
const { wasEntryAffectingCurrentDir, computeCurrentRelAfterRename, navigateToRelDir } = callbacks;
|
||||
if (typeof wasEntryAffectingCurrentDir === 'function'
|
||||
&& typeof computeCurrentRelAfterRename === 'function'
|
||||
&& typeof navigateToRelDir === 'function') {
|
||||
if (wasEntryAffectingCurrentDir(entry.path)) {
|
||||
const newRel = computeCurrentRelAfterRename(entry.path, entry.name, result.path, result.name);
|
||||
if (newRel != null) {
|
||||
try {
|
||||
await navigateToRelDir(newRel);
|
||||
} catch (e) {
|
||||
console.warn('[file-ops] rename 后续 navigateToRelDir 失败:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除文件夹:弹 confirm → 走 file:delete(主进程已支持 folder 分支)。
|
||||
* 用户反馈设计:"删除成功后将视图导到父级" —— 不因文件夹被删而失位。
|
||||
*
|
||||
* 复用 file:delete IPC 的 isFolder 分支:
|
||||
* - 主进程调用 shell.trashItem(macOS / Windows 把整树挪到回收站)
|
||||
* - 失败兜底 fs.rmdir(仅对空目录)
|
||||
* - 返回 result.isFolder(本函数用不上 —— 不需要判断 file vs folder,调用方已知是 folder)
|
||||
*
|
||||
* 文件级 dirty 内容由 onFilesChanged 后续触发 rescue —— shell.trashItem 异步移走之后
|
||||
* fs-watcher 会推「文件已不在」,state.currentFile 被清同时 dirty 内容被 rescue。
|
||||
*/
|
||||
async function deleteFolder(entry) {
|
||||
if (!entry || !entry.path || entry.entryType !== 'folder') return;
|
||||
syncDirtyFromEditor(state, getEditor());
|
||||
const choice = await showConfirmDialog({
|
||||
title: '删除文件夹',
|
||||
message: `「${entry.name}」将被移至回收站(含其全部内容)。\n\n若其中有正在编辑的笔记,未保存内容会另存为「<基名>-rescued-<时间戳>.md」。`,
|
||||
icon: 'danger',
|
||||
buttons: [
|
||||
{ value: 'cancel', label: '取消', variant: 'secondary' },
|
||||
{ value: 'confirm', label: '删除', variant: 'danger' },
|
||||
],
|
||||
});
|
||||
if (choice !== 'confirm') return;
|
||||
|
||||
// 记下删除前目录影响 —— 用 callback 判断被删的是否影响当前路径
|
||||
const { wasEntryAffectingCurrentDir, parentRelForRemovedEntry, navigateToRelDir } = callbacks;
|
||||
const affectCurrent = typeof wasEntryAffectingCurrentDir === 'function'
|
||||
&& wasEntryAffectingCurrentDir(entry.path);
|
||||
|
||||
const result = await api.deleteFile(entry.path);
|
||||
if (!result || !result.ok) {
|
||||
if (result && result.error === 'FILE_NOT_FOUND') {
|
||||
showToast('文件夹已不存在', 'warning');
|
||||
} else {
|
||||
showToast(`删除失败:${window.api.friendlyFsError(result && result.code, result && result.message)}`, 'error');
|
||||
return;
|
||||
}
|
||||
}
|
||||
showToast(`已移至回收站:${entry.name}`, 'success', 1500);
|
||||
|
||||
// 被删的是当前目录或其祖先 → 导航到被删 entry 的父级
|
||||
if (affectCurrent && typeof navigateToRelDir === 'function') {
|
||||
const parentRel = typeof parentRelForRemovedEntry === 'function'
|
||||
? parentRelForRemovedEntry(entry.path, entry.name)
|
||||
: '';
|
||||
try {
|
||||
await navigateToRelDir(parentRel);
|
||||
} catch (e) {
|
||||
console.warn('[file-ops] deleteFolder 后续 navigateToRelDir 失败:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 把条目(文件 / 文件夹)移动到目标目录。
|
||||
* 拖拽到文件夹、右键「剪切 → 粘贴」都走这里。
|
||||
*
|
||||
* 与 rename 的差异:move 不改扩展名、不动编辑器内容(只是换目录),
|
||||
* 所以**不需要** syncDirtyFromEditor 的脏确认(用户当前的编辑内容仍留在编辑器里)。
|
||||
*
|
||||
* 若被移动的条目是当前打开的文件(或它的某个祖先文件夹里包含打开的文件):
|
||||
* 更新 state.currentFile 的 path/name(mirror renameCurrentOrFile 的同步分支),
|
||||
* 让侧栏高亮 / 状态栏 / 图片相对路径都跟过去。
|
||||
*
|
||||
* @param {{path:string,name:string,entryType?:string}} entry
|
||||
* @param {string} destDir 目标目录绝对路径
|
||||
*/
|
||||
async function moveEntry(entry, destDir) {
|
||||
if (!entry || !entry.path || !destDir) return null;
|
||||
const result = await api.moveFile(entry.path, destDir);
|
||||
if (!result || !result.ok) {
|
||||
showToast(`移动失败:${window.api.friendlyFsError(result && result.code, result && result.message)}`, 'error');
|
||||
return result || null;
|
||||
}
|
||||
showToast(`已移动到:${result.name}`, 'success', 1500);
|
||||
// 移动涉及当前打开文件 → 同步路径 / 状态栏 / viewer 相对图解析
|
||||
// 覆盖两种情况:
|
||||
// 1) 移动的就是打开的文件本身(state.currentFile.path === entry.path)
|
||||
// 2) 移动的是打开文件的祖先文件夹(state.currentFile.path 在 entry.path 之下)——
|
||||
// audit 修复:之前只覆盖情况 1,导致情况 2 下 state.currentFileRelDir /
|
||||
// currentFile.path 仍指向旧位置,onFilesChanged 把"旧目录的移除事件"
|
||||
// 误判为"当前文件被删除",触发 rescue + 写一个孤儿 .md 到根目录。
|
||||
if (state.currentFile && state.currentFile.path) {
|
||||
const cfPath = state.currentFile.path;
|
||||
if (cfPath === entry.path) {
|
||||
_applyMoveToOpenFile(result);
|
||||
} else if (result.entryType === 'folder' && cfPath.startsWith(entry.path)) {
|
||||
// 祖先文件夹被移走 → 重新拼出文件新路径
|
||||
const newFilePath = destDir + cfPath.slice(entry.path.length);
|
||||
const newName = newFilePath.replace(/^.*[\\/]/, '');
|
||||
_applyMoveToOpenFile({
|
||||
path: newFilePath,
|
||||
name: newName,
|
||||
mtimeMs: state.currentFile.mtimeMs,
|
||||
size: state.currentFile.size,
|
||||
// destDir 的 relDir 与 result.relDir 一致(祖先移动后,新父 = destDir)
|
||||
relDir: typeof result.relDir === 'string' ? result.relDir : '',
|
||||
});
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把条目(文件 / 文件夹)复制到目标目录。
|
||||
* 拖拽(按住 Ctrl/Cmd)/ 右键「复制 → 粘贴」走这里。
|
||||
* 复制不影响当前打开的文件(源不变),无需同步 state.currentFile。
|
||||
*
|
||||
* @param {{path:string,name:string,entryType:string}} entry
|
||||
* @param {string} destDir 目标目录绝对路径
|
||||
*/
|
||||
async function copyEntry(entry, destDir) {
|
||||
if (!entry || !entry.path || !destDir) return null;
|
||||
const result = await api.copyFile(entry.path, destDir);
|
||||
if (!result || !result.ok) {
|
||||
showToast(`复制失败:${window.api.friendlyFsError(result && result.code, result && result.message)}`, 'error');
|
||||
return result || null;
|
||||
}
|
||||
showToast(`已复制到:${result.name}`, 'success', 1500);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 被「移动」的当前打开文件:把磁盘新路径同步到 state / viewer / 侧栏高亮 / AI。
|
||||
* move 不改变扩展名,所以不触发 内容重渲染(与 rename 带扩展名切换的路径不同)。
|
||||
* 逻辑收口自 renameCurrentOrFile 的同步分支。
|
||||
*/
|
||||
function _applyMoveToOpenFile(result) {
|
||||
// result 由 moveEntry 在 IPC 成功后传入,永不为 null(moveEntry 已有 !result.ok 守卫)。
|
||||
const newMeta = {
|
||||
path: result.path,
|
||||
name: result.name,
|
||||
mtimeMs: result.mtimeMs,
|
||||
size: result.size,
|
||||
};
|
||||
state.currentFile = { ...state.currentFile, ...newMeta };
|
||||
state.lastSavedMtimeMs = result.mtimeMs;
|
||||
// 移动后文件所在目录变了:同步 currentFileRelDir(来自主进程 file:move 返回的
|
||||
// POSIX relDir),否则 onFilesChanged 仍用旧目录比对,会把"旧目录的移除事件"
|
||||
// 误判为"当前文件被删除"并清空编辑器(见 app.js onFilesChanged 的 payloadRel 比对)。
|
||||
if (typeof result.relDir === 'string') {
|
||||
state.currentFileRelDir = result.relDir;
|
||||
}
|
||||
if (fileList && typeof fileList.setActive === 'function') {
|
||||
try { fileList.setActive(result.path); } catch { /* 不让 setActive 异常阻断流程 */ }
|
||||
}
|
||||
// viewer 用 currentFile.path 当相对图片 baseDir,路径变了必须重跑
|
||||
viewer.setFileMeta(newMeta);
|
||||
updateWindowTitle();
|
||||
// 路径变化 → AI pendingDiff 的 baseContent / forFilePath 脱节 → 通知清盘
|
||||
if (typeof callbacks.onFileChanged === 'function') {
|
||||
try { callbacks.onFileChanged(state.currentFile); } catch (e) {
|
||||
console.warn('[file-ops] move 通知 AI 失败:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件列表右键菜单:
|
||||
* 文件条目:剪切 / 复制 / 粘贴 / 重命名 / 在文件夹中显示 / 删除
|
||||
* 文件夹条目:在文件夹中新建文件 / 在文件夹中新建文件夹 / (以上 + 文件条目全套)
|
||||
*
|
||||
* Stage 8:folder 条目也解锁「重命名」。
|
||||
* 文件夹条目顶部多一组「在该文件夹内新建」,落点指向被右击的文件夹
|
||||
* (右键文件夹 → 「在文件夹中新建文件」直接建进它里面);
|
||||
* 侧栏空白右键的「新建」则落在当前浏览目录(见 src/app.js#showSidebarEmptyContextMenu)。
|
||||
* 剪切 / 复制把条目记到 clipboard(state.clipboard,由上层回调维护);
|
||||
* 粘贴仅在 clipboard 非空时可用(当前浏览目录为目标)。
|
||||
*/
|
||||
function showFileListContextMenu(entry, x, y) {
|
||||
const isFolder = entry && entry.entryType === 'folder';
|
||||
const items = [];
|
||||
if (!isFolder) {
|
||||
items.push({ label: '重命名', value: 'rename' });
|
||||
// 文件夹条目:顶部加「在该文件夹内新建」一组,落点指向被右击的文件夹
|
||||
// (与侧栏空白右键「新建」落当前目录相对,这里是「建进这个文件夹」)。
|
||||
if (isFolder) {
|
||||
items.push({ label: '在文件夹中新建文件', value: 'new_file_in' });
|
||||
items.push({ label: '在文件夹中新建文件夹', value: 'new_folder_in' });
|
||||
items.push({ separator: true, value: '' });
|
||||
}
|
||||
items.push({ label: '剪切', value: 'cut' });
|
||||
items.push({ label: '复制', value: 'copy' });
|
||||
items.push({ label: '粘贴', value: 'paste', disabled: !state.clipboard });
|
||||
items.push({ separator: true, value: '' });
|
||||
items.push({ label: '重命名', value: 'rename' });
|
||||
items.push({ label: '在文件夹中显示', value: 'reveal' });
|
||||
items.push({ separator: true, value: '' });
|
||||
items.push({ label: '删除', value: 'delete' });
|
||||
contextMenu.show({
|
||||
x, y, items,
|
||||
onSelect: (value) => {
|
||||
if (value === 'rename') renameCurrentOrFile(entry);
|
||||
else if (value === 'reveal') revealFile(entry);
|
||||
else if (value === 'delete') deleteFile(entry);
|
||||
if (value === 'new_file_in') {
|
||||
createNewFile(entry.path);
|
||||
} else if (value === 'new_folder_in') {
|
||||
createNewFolder(entry.path);
|
||||
} else if (value === 'cut') {
|
||||
if (typeof callbacks.onCut === 'function') callbacks.onCut(entry);
|
||||
} else if (value === 'copy') {
|
||||
if (typeof callbacks.onCopy === 'function') callbacks.onCopy(entry);
|
||||
} else if (value === 'paste') {
|
||||
if (typeof callbacks.onPaste === 'function') callbacks.onPaste();
|
||||
} else if (value === 'rename') {
|
||||
if (isFolder) renameFolder(entry);
|
||||
else renameCurrentOrFile(entry);
|
||||
} else if (value === 'reveal') {
|
||||
revealFile(entry);
|
||||
} else if (value === 'delete') {
|
||||
if (isFolder) deleteFolder(entry);
|
||||
else deleteFile(entry);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -460,9 +820,14 @@ export function createFileOps(ctx) {
|
||||
return {
|
||||
showFileListContextMenu,
|
||||
createNewFile,
|
||||
createNewFolder,
|
||||
moveEntry,
|
||||
copyEntry,
|
||||
// 暴露给测试 / 调试用
|
||||
renameCurrentOrFile,
|
||||
renameFolder,
|
||||
deleteFile,
|
||||
deleteFolder,
|
||||
revealFile,
|
||||
formatStampForName,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user