From c2f69f93030830a0b3471c720d7ab4c7648656b0 Mon Sep 17 00:00:00 2001 From: Muse Date: Sat, 14 Nov 2026 16:36:36 +0800 Subject: [PATCH] =?UTF-8?q?feat(files):=20=E6=96=87=E4=BB=B6=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E5=9B=9B=E9=A1=B9=E2=80=94=E2=80=94=E4=BE=A7=E6=A0=8F?= =?UTF-8?q?=E6=8B=96=E6=8B=BD=E7=A7=BB=E5=8A=A8=E3=80=81=E8=8D=89=E7=A8=BF?= =?UTF-8?q?=E8=B7=A8=E7=AA=97=E5=8F=A3=E8=AE=A4=E9=A2=86=E3=80=81=E6=9C=AA?= =?UTF-8?q?=E5=91=BD=E5=90=8D=E9=BB=98=E8=AE=A4=E5=AD=98=E6=A0=B9=E7=9B=AE?= =?UTF-8?q?=E5=BD=95=E3=80=81=E6=89=93=E5=BC=80=E6=89=80=E5=9C=A8=E4=BD=8D?= =?UTF-8?q?=E7=BD=AE=EF=BC=88docs/25=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/25-文件管理四项.md | 58 +++++++++++++++++ src-tauri/src/commands.rs | 15 +++++ src-tauri/src/lib.rs | 1 + src/App.tsx | 123 +++++++++++++++++++++++++++++++---- src/components/Sidebar.tsx | 72 ++++++++++++++++++-- src/services/platform.ts | 5 ++ src/services/windowClaims.ts | 80 +++++++++++++++++++++++ src/styles.css | 3 + src/utils/text.ts | 28 ++++++++ tests/sidebar-tree.test.tsx | 92 ++++++++++++++++++++++++++ tests/text.test.ts | 19 ++++++ tests/window-claims.test.ts | 36 ++++++++++ 12 files changed, 513 insertions(+), 19 deletions(-) create mode 100644 docs/25-文件管理四项.md create mode 100644 src/services/windowClaims.ts create mode 100644 tests/window-claims.test.ts diff --git a/docs/25-文件管理四项.md b/docs/25-文件管理四项.md new file mode 100644 index 0000000..35bdf85 --- /dev/null +++ b/docs/25-文件管理四项.md @@ -0,0 +1,58 @@ +# docs/25 文件管理四项:拖拽移动 / 草稿跨窗口 / 未命名默认保存 / 重命名与定位 + +## 1. 左侧栏拖拽移动 + +- 文件、文件夹均可拖到任意文件夹上松手完成移动(底层走 rename_path); + 拖到顶部根目录标题行 = 移回工作区根目录。 +- 投放合法性在拖拽悬停时就判定,不合法的目标不亮、不接受投放: + - **拖回原文件夹**(原位置)→ 不接受(无操作,避免无意义的报错与刷新); + - 拖到自己身上 → 不接受; + - 文件夹拖进自己或自己的子文件夹 → 不接受(App 侧执行时再校验一遍, + 并拦截目标已有同名的情况,报错不覆盖)。 +- 投放目标高亮(accent 描边+弱底色),被拖条目半透明。 +- 移动后已打开的标签路径跟随(含文件夹内的子文档)、最近列表与恢复 + 记录键统一换位(remapAfterRelocate,改名共用同一套)。 + +## 2. 新窗口不再重复弹「未保存草稿」 + +根因:恢复记录在全应用共享的 SQLite 里,每个窗口启动都全量扫描并 +按 planRecovery 提示;文档还开在另一个窗口里编辑时,它的自动保存 +记录也被当成了崩溃草稿。 + +修复(services/windowClaims.ts):跨窗口认领注册表(localStorage, +多窗口同源共享)——每个窗口把「打开中的文档键 + 正在提示的草稿键」 +登记并 10 秒心跳保鲜、关闭时注销;启动扫描滤掉被其他存活窗口认领 +的记录。窗口崩溃残留的认领 45 秒后过期,真正的崩溃草稿不会被吞。 + +## 3. 未命名文档保存默认落工作区根目录 + +- 普通保存(Ctrl+S):有工作区时直接写到根目录的「未命名.md」, + 同名自动避让为「未命名 1.md / 2.md…」(上限 100,仍撞名退回弹框); + 没有工作区才弹保存框(与原来一致)。 +- 另存为仍弹框,但默认位置预填到工作区根目录。 + +## 4. 重命名与打开所在位置 + +- 重命名本来就有(文件/文件夹右键均有),这次补齐短板:改文件夹名时, + 其内已打开的子文档标签、最近列表、恢复记录一并换位(此前只跟随 + 恰好同路径的单个文件标签,文件夹内的标签会指向失效路径)。 +- 右键新增「打开所在位置」:新增 Rust 命令 reveal_in_explorer, + 用 explorer /select 打开父目录并选中该文件/文件夹(文件、文件夹 + 均有此项)。 + +## 验证 + +- vitest 106+2(新增:window-claims 3 例、路径助手 2 例、文件树拖放 + 与定位菜单 5 例);tsc 0 错误;构建通过。 +- 无头浏览器实测:应用启动后认领注册表落盘、打开文档的键即时登记, + 无页面错误。 +- Rust 命令沙箱无工具链未编译(按 commands.rs 既有风格编写), + 待 Windows 构建验证。 + +## 待真机复验 + +- 拖拽手感(WebView2 下 HTML5 拖放)与各拦截提示; +- 双窗口:窗口 A 编辑未保存 → 开新窗口不再弹草稿;A 崩溃/强杀后 + 新窗口仍能收到草稿提示; +- 未命名保存直接落根目录、避让命名; +- 「打开所在位置」资源管理器定位效果。 diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index cf810c9..c53d6ae 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -427,6 +427,21 @@ pub fn create_dir(path: String) -> Result<(), String> { } /// 重命名/移动文件或文件夹;目标已存在时报错。 +/// 在 Windows 资源管理器中定位路径:打开父目录并选中该文件/文件夹。 +/// explorer 即使成功也常以退出码 1 结束,因此只 spawn 不等结果。 +#[tauri::command] +pub fn reveal_in_explorer(path: String) -> Result<(), String> { + if !Path::new(&path).exists() { + return Err("路径不存在".to_string()); + } + std::process::Command::new("explorer") + .arg(format!("/select,{path}")) + .spawn() + .map_err(|e| format!("打开资源管理器失败:{e}"))?; + Ok(()) +} + +#[tauri::command] #[tauri::command] pub fn rename_path(from: String, to: String) -> Result<(), String> { if Path::new(&to).exists() { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 360590d..6fa3123 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -31,6 +31,7 @@ pub fn run() { commands::create_text_file, commands::create_dir, commands::rename_path, + commands::reveal_in_explorer, commands::delete_to_trash, commands::db_recent_list, commands::db_recent_add, diff --git a/src/App.tsx b/src/App.tsx index 5b0a42e..8165c00 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -19,18 +19,19 @@ import { insertPlainText, insertLinkText } from './editor/paste' import { isImeComposing } from './utils/ime' import { ConfirmSaveModal, AboutModal, ShortcutsModal, LinkModal, ErrorModal } from './components/Modals' import { tabsReducer, createTab, activeTab, isDirty, type DocTab, type TabsState } from './stores/tabs' -import { countWords, countChars, extractOutline, baseName, type OutlineItem } from './utils/text' +import { countWords, countChars, extractOutline, baseName, isPathInside, type OutlineItem } from './utils/text' import { callUndo, callRedo, focusEditor, type CursorInfo } from './editor/editor' import { editorCommands, insertMermaidBlock, insertMathBlock } from './editor/commands' import { isTauri, pickOpenFile, pickSaveFile, pickFolder, readTextFile, writeTextFile, pickAndReadWebFile, downloadTextFile, pickImageFile, pickAnyFile, pickAndReadWebImage, copyToAssets, saveBytesToAssets, fileToBase64, - statFile, createTextFile, createDirPath, renamePath, deleteToTrash, type DirEntry, type RecoveryEntry, + statFile, createTextFile, createDirPath, renamePath, deleteToTrash, revealInExplorer, type DirEntry, type RecoveryEntry, pickSaveHtmlFile, pickImportFile, readFileBase64, readBinaryFile, pickAndReadWebImport, } from './services/platform' import * as store from './services/store' import { recoveryKey, planRecovery } from './utils/recovery' +import { appWindowClaims } from './services/windowClaims' import { RecoveryModal, ConflictModal, QuitModal, PromptModal, ConfirmModal } from './components/Stage4Modals' import { AppearanceModal, ExportModal } from './components/Stage5Modals' import { DOC_THEMES, docThemeById } from './themes' @@ -101,6 +102,30 @@ export default function App() { // ——— 阶段 4 状态 ——— const [notice, setNotice] = useState(null) const [recoveryOffer, setRecoveryOffer] = useState([]) + // 跨窗口草稿认领:本窗口打开中的文档与正在提示的草稿归本窗口所有, + // 其他窗口启动时不再重复弹恢复提示(心跳保鲜,关闭注销,见 windowClaims)。 + const claimKeysRef = useRef([]) + const claimSigRef = useRef('') + useEffect(() => { + const tick = () => appWindowClaims.update(claimKeysRef.current) + tick() + const timer = window.setInterval(tick, 10_000) + const onPageHide = () => appWindowClaims.dispose() + window.addEventListener('pagehide', onPageHide) + return () => { window.clearInterval(timer); window.removeEventListener('pagehide', onPageHide) } + }, []) + useEffect(() => { + const keys = [...new Set([ + ...state.tabs.map((t) => recoveryKey(t)), + ...recoveryOffer.map((e) => e.docKey), + ])] + const sig = [...keys].sort().join('\n') + if (sig !== claimSigRef.current) { + claimSigRef.current = sig + claimKeysRef.current = keys + appWindowClaims.update(keys) + } + }, [state.tabs, recoveryOffer]) const [recoveryDismissed, setRecoveryDismissed] = useState(false) const [conflict, setConflict] = useState<{ tabId: string } | null>(null) const [quitOpen, setQuitOpen] = useState(false) @@ -347,8 +372,18 @@ export default function App() { return true } let path = target.path + if (!path && !saveAs && workspaceRoot) { + // 未命名文档直接保存:默认落到左侧栏工作区根目录,不弹框; + // 根目录已有同名文件时自动避让(未命名 1.md、未命名 2.md…, + // 避让超过 100 次仍撞名则退回弹框让用户自己选) + let candidate = joinPath(workspaceRoot, `${target.title}.md`) + for (let n = 1; (await statFile(candidate)).exists && n <= 100; n += 1) { + candidate = joinPath(workspaceRoot, `${target.title} ${n}.md`) + } + if (!(await statFile(candidate)).exists) path = candidate + } if (!path || saveAs) { - path = await pickSaveFile(path ?? `${target.title}.md`) + path = await pickSaveFile(path ?? (workspaceRoot ? joinPath(workspaceRoot, `${target.title}.md`) : `${target.title}.md`)) if (!path) return false } await writeTextFile(path, toDiskContent(snapshot, target.lineEnding), target.hadBom) @@ -367,7 +402,7 @@ export default function App() { setError(String(e)) return false } - }, [tauriMode, pushRecent]) + }, [tauriMode, pushRecent, workspaceRoot]) const requestClose = useCallback((ids: string[], after: () => void) => { const dirtyOnes = state.tabs.filter((t) => ids.includes(t.id) && isDirty(t)) @@ -511,7 +546,10 @@ export default function App() { } const plan = planRecovery(records, (path) => disk.get(path) ?? null) for (const key of plan.purgeKeys) void store.deleteRecovery(key) - if (plan.offer.length > 0) setRecoveryOffer(plan.offer) + // 别的窗口正打开着的文档(含其未保存修改)不是本窗口的崩溃草稿,不提示 + const claimed = appWindowClaims.liveClaimedKeys() + const offer = plan.offer.filter((e) => !claimed.has(e.docKey)) + if (offer.length > 0) setRecoveryOffer(offer) } // 多窗口:新窗口通过 ?file= 参数打开指定文档(阶段 6)。 // 审计修复:这段原先写在恢复记录的提前 return 之后,无恢复记录时永远执行不到。 @@ -695,6 +733,68 @@ export default function App() { }) }, [flash]) + /** 文件/文件夹改名或移动后,把所有指向旧路径的引用换到新路径: + * 已打开的标签(文件夹时含其内全部子文档)、最近列表、已写的恢复记录。 */ + const remapAfterRelocate = useCallback((oldPath: string, newPath: string) => { + const swap = (p: string): string | null => { + const pn = p.replace(/\\/g, '/') + const oldN = oldPath.replace(/\\/g, '/') + const newN = newPath.replace(/\\/g, '/') + if (pn === oldN) return newPath + if (pn.startsWith(`${oldN}/`)) { + const moved = newN + pn.slice(oldN.length) + return newPath.includes('\\') ? moved.replace(/\//g, '\\') : moved + } + return null + } + for (const t of stateRef.current.tabs) { + if (!t.path) continue + const np = swap(t.path) + if (!np) continue + const oldKey = recoveryKey(t) + dispatch({ type: 'setPath', id: t.id, path: np, title: baseName(np) }) + if (recoveryWrittenRef.current.delete(oldKey)) void store.deleteRecovery(oldKey) + } + setRecent((prev) => prev.map((p) => swap(p) ?? p)) + for (const p of recent) { + const np = swap(p) + if (np) { + store.removeRecent(p) + store.addRecent(np, baseName(np)) + } + } + }, [recent]) + + /** 侧栏拖拽移动:把文件/文件夹移到目标文件夹(走 rename_path,同盘改名)。 */ + const moveEntry = useCallback((entry: DirEntry, destDir: string) => { + if (samePath(dirOf(entry.path), destDir)) { + flash('已经在该文件夹中') + return + } + if (entry.isDir && (samePath(destDir, entry.path) || isPathInside(destDir, entry.path))) { + setError('不能把文件夹移到它自己或它的子文件夹里') + return + } + const target = joinPath(destDir, entry.name) + void (async () => { + try { + if ((await statFile(target)).exists) { + setError(`目标文件夹中已有同名${entry.isDir ? '文件夹' : '文件'}「${entry.name}」`) + return + } + await renamePath(entry.path, target) + remapAfterRelocate(entry.path, target) + setTreeRefresh((n) => n + 1) + flash(`已移动到「${baseName(destDir)}」`) + } catch (e) { setError(String(e)) } + })() + }, [flash, remapAfterRelocate]) + + /** 在资源管理器中打开所在位置(父目录并选中)。 */ + const revealEntry = useCallback((entry: DirEntry) => { + void revealInExplorer(entry.path).catch((e) => setError(String(e))) + }, []) + const renameEntry = useCallback((entry: DirEntry) => { setPromptSpec({ title: '重命名', label: '新名称', initial: entry.name, @@ -705,21 +805,14 @@ export default function App() { void (async () => { try { await renamePath(entry.path, target) - const open = stateRef.current.tabs.find((t) => samePath(t.path, entry.path)) - if (open) { - dispatch({ type: 'setPath', id: open.id, path: target, title: baseName(target) }) - store.removeRecent(entry.path) - store.addRecent(target, baseName(target)) - const oldKey = recoveryKey(open) - if (recoveryWrittenRef.current.delete(oldKey)) void store.deleteRecovery(oldKey) - } + remapAfterRelocate(entry.path, target) setTreeRefresh((n) => n + 1) flash(`已重命名为 ${name}`) } catch (e) { setError(String(e)) } })() }, }) - }, [flash]) + }, [flash, remapAfterRelocate]) const deleteEntry = useCallback((entry: DirEntry) => { setConfirmSpec({ @@ -1233,6 +1326,8 @@ export default function App() { onCreateFolderIn={tauriMode && workspaceRoot ? createFolderInDir : undefined} onRenameEntry={tauriMode ? renameEntry : undefined} onDeleteEntry={tauriMode ? deleteEntry : undefined} + onMoveEntry={tauriMode ? moveEntry : undefined} + onRevealEntry={tauriMode ? revealEntry : undefined} onOpenInNewWindowEntry={tauriMode ? newWindowEntry : undefined} onSaveAsEntry={saveAsEntry} onExportEntry={exportEntry} diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index b99bc82..889e3ec 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -1,6 +1,7 @@ import { memo, useCallback, useEffect, useRef, useState } from 'react' import { useDismiss } from '../hooks/useDismiss' import { listDir, workspaceSearch, type DirEntry, type WorkspaceSearchFile } from '../services/platform' +import { isPathInside, parentDirOf, pathsEqual } from '../utils/text' import type { OutlineItem } from '../utils/text' import { baseName } from '../utils/text' @@ -16,6 +17,10 @@ interface Props { onCreateFolderIn?: (dir: string) => void onRenameEntry?: (entry: DirEntry) => void onDeleteEntry?: (entry: DirEntry) => void + /** 拖拽移动:把条目移到目标文件夹 */ + onMoveEntry?: (entry: DirEntry, destDir: string) => void + /** 在资源管理器中打开所在位置 */ + onRevealEntry?: (entry: DirEntry) => void /** 文件条目右键的文件类动作(打开/新窗口/另存为/导出/打印) */ onOpenInNewWindowEntry?: (entry: DirEntry) => void onSaveAsEntry?: (entry: DirEntry) => void @@ -73,6 +78,8 @@ function Sidebar(p: Props) { onCreateFolderIn={p.onCreateFolderIn} onRenameEntry={p.onRenameEntry} onDeleteEntry={p.onDeleteEntry} + onMoveEntry={p.onMoveEntry} + onRevealEntry={p.onRevealEntry} refreshSignal={p.refreshSignal ?? 0} /> ) : ( @@ -87,7 +94,18 @@ function Sidebar(p: Props) { ) } -function FileTree({ root, activePath, onOpenFile, onCreateFileIn, onCreateFolderIn, onRenameEntry, onDeleteEntry, refreshSignal, onOpenInNewWindowEntry, onSaveAsEntry, onExportEntry, onPrintEntry }: { +interface TreeDnd { + draggingPath: string | null + dropDir: string | null + canDrop: (destDir: string) => boolean + start: (entry: DirEntry, dataTransfer: DataTransfer) => void + end: () => void + over: (dir: string) => void + leave: (dir: string) => void + drop: (dir: string) => void +} + +function FileTree({ root, activePath, onOpenFile, onCreateFileIn, onCreateFolderIn, onRenameEntry, onDeleteEntry, refreshSignal, onOpenInNewWindowEntry, onSaveAsEntry, onExportEntry, onPrintEntry, onMoveEntry, onRevealEntry }: { root: string activePath: string | null onOpenFile: (path: string) => void @@ -100,8 +118,38 @@ function FileTree({ root, activePath, onOpenFile, onCreateFileIn, onCreateFolder onSaveAsEntry?: (entry: DirEntry) => void onExportEntry?: (entry: DirEntry) => void onPrintEntry?: (entry: DirEntry) => void + onMoveEntry?: (entry: DirEntry, destDir: string) => void + onRevealEntry?: (entry: DirEntry) => void }) { const [refreshKey, setRefreshKey] = useState(0) + const [dragEntry, setDragEntry] = useState(null) + const [dropDir, setDropDir] = useState(null) + // 拖放合法性:不许拖回原文件夹(无操作)、不许拖到自己身上、 + // 文件夹不许拖进自己或自己的子文件夹 + const canDropOn = useCallback((entry: DirEntry, destDir: string) => { + if (pathsEqual(parentDirOf(entry.path), destDir)) return false + if (pathsEqual(entry.path, destDir)) return false + if (entry.isDir && isPathInside(destDir, entry.path)) return false + return true + }, []) + const dnd: TreeDnd | undefined = onMoveEntry ? { + draggingPath: dragEntry?.path ?? null, + dropDir, + canDrop: (destDir) => dragEntry !== null && canDropOn(dragEntry, destDir), + start: (entry, dataTransfer) => { + dataTransfer.effectAllowed = 'move' + dataTransfer.setData('text/plain', entry.path) + setDragEntry(entry) + }, + end: () => { setDragEntry(null); setDropDir(null) }, + over: (dir) => setDropDir(dir), + leave: (dir) => setDropDir((cur) => (cur === dir ? null : cur)), + drop: (destDir) => { + if (dragEntry && canDropOn(dragEntry, destDir)) onMoveEntry(dragEntry, destDir) + setDragEntry(null) + setDropDir(null) + }, + } : undefined const [menu, setMenu] = useState<{ x: number; y: number; entry: DirEntry } | null>(null) const menuRef = useRef(null) const closeMenu = useCallback(() => setMenu(null), []) @@ -109,7 +157,12 @@ function FileTree({ root, activePath, onOpenFile, onCreateFileIn, onCreateFolder useEffect(() => { if (refreshSignal > 0) setRefreshKey((k) => k + 1) }, [refreshSignal]) return (
-
+
{ if (dnd.canDrop(root)) { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; dnd.over(root) } } : undefined} + onDragLeave={dnd ? () => dnd.leave(root) : undefined} + onDrop={dnd ? (e) => { e.preventDefault(); dnd.drop(root) } : undefined} + > {baseName(root)} {onCreateFileIn && } {onCreateFolderIn && } @@ -118,11 +171,13 @@ function FileTree({ root, activePath, onOpenFile, onCreateFileIn, onCreateFolder setMenu({ entry, x, y })} + dnd={dnd} /> {menu && (
e.stopPropagation()}> {!menu.entry.isDir && } {!menu.entry.isDir && onOpenInNewWindowEntry && } + {onRevealEntry && } {!menu.entry.isDir && onSaveAsEntry && } {!menu.entry.isDir && onExportEntry && } {!menu.entry.isDir && onPrintEntry && } @@ -137,13 +192,14 @@ function FileTree({ root, activePath, onOpenFile, onCreateFileIn, onCreateFolder ) } -function TreeLevel({ dir, depth, activePath, onOpenFile, refreshKey, onEntryMenu }: { +function TreeLevel({ dir, depth, activePath, onOpenFile, refreshKey, onEntryMenu, dnd }: { dir: string depth: number activePath: string | null onOpenFile: (path: string) => void refreshKey: number onEntryMenu: (entry: DirEntry, x: number, y: number) => void + dnd?: TreeDnd }) { const [entries, setEntries] = useState(null) const [error, setError] = useState(null) @@ -178,17 +234,23 @@ function TreeLevel({ dir, depth, activePath, onOpenFile, refreshKey, onEntryMenu {entries.map((entry) => (
{entry.isDir && expanded.has(entry.path) && ( - + )}
))} diff --git a/src/services/platform.ts b/src/services/platform.ts index 4a39ebe..d4b4097 100644 --- a/src/services/platform.ts +++ b/src/services/platform.ts @@ -306,3 +306,8 @@ export interface WorkspaceSearchFile { export async function workspaceSearch(root: string, query: string, caseSensitive: boolean): Promise { return invoke('workspace_search', { root, query, caseSensitive }) } + +/** 在系统文件管理器中定位该路径(打开父目录并选中它)。 */ +export async function revealInExplorer(path: string): Promise { + return invoke('reveal_in_explorer', { path }) +} diff --git a/src/services/windowClaims.ts b/src/services/windowClaims.ts new file mode 100644 index 0000000..b8bd6d9 --- /dev/null +++ b/src/services/windowClaims.ts @@ -0,0 +1,80 @@ +// 跨窗口文档认领:多窗口共享同一份恢复记录(SQLite),若不协调,每个新窗口 +// 启动时都会把别的窗口正在编辑的未保存文档当成「崩溃草稿」弹出来。 +// 机制:每个窗口把自己「打开中的文档键 + 正在提示恢复的文档键」写进 +// localStorage 注册表并心跳保鲜;启动扫描时,凡是被其他存活窗口认领的 +// 恢复记录一律不提示。窗口关闭即注销(pagehide 尽力而为),崩溃残留的 +// 认领超过保鲜期自动失效,不会吞掉真正的崩溃草稿。 +export interface WindowClaims { + /** 更新本窗口认领的文档键(同时清理已过期的他人条目)。 */ + update(keys: string[]): void + /** 其他存活窗口认领的文档键集合。 */ + liveClaimedKeys(): Set + /** 注销本窗口(窗口关闭时调用)。 */ + dispose(): void +} + +interface ClaimEntry { + keys: string[] + ts: number +} + +const REGISTRY_KEY = 'nm-window-docs' +/** 认领保鲜期:心跳 10 秒一次,超过 45 秒未刷新视为窗口已不在。 */ +export const CLAIM_STALE_MS = 45_000 + +export function createWindowClaims( + windowId: string, + storage: Pick = localStorage, + now: () => number = Date.now, +): WindowClaims { + const read = (): Record => { + try { + const raw = storage.getItem(REGISTRY_KEY) + if (!raw) return {} + const parsed = JSON.parse(raw) as Record + return parsed && typeof parsed === 'object' ? parsed : {} + } catch { + return {} + } + } + const write = (reg: Record) => { + try { + if (Object.keys(reg).length === 0) storage.removeItem(REGISTRY_KEY) + else storage.setItem(REGISTRY_KEY, JSON.stringify(reg)) + } catch { /* 存储不可用时降级为旧行为(各窗口各自提示) */ } + } + const prune = (reg: Record): Record => { + const t = now() + const out: Record = {} + for (const [id, entry] of Object.entries(reg)) { + if (entry && typeof entry.ts === 'number' && t - entry.ts <= CLAIM_STALE_MS) out[id] = entry + } + return out + } + return { + update(keys) { + const reg = prune(read()) + reg[windowId] = { keys: [...new Set(keys)], ts: now() } + write(reg) + }, + liveClaimedKeys() { + const reg = prune(read()) + const out = new Set() + for (const [id, entry] of Object.entries(reg)) { + if (id === windowId) continue + for (const key of entry.keys ?? []) out.add(key) + } + return out + }, + dispose() { + const reg = read() + delete reg[windowId] + write(reg) + }, + } +} + +const randomWindowId = `win-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}` + +/** 本应用窗口的认领单例(App 使用)。 */ +export const appWindowClaims: WindowClaims = createWindowClaims(randomWindowId) diff --git a/src/styles.css b/src/styles.css index 25d48f5..d1be11c 100644 --- a/src/styles.css +++ b/src/styles.css @@ -332,6 +332,9 @@ kbd { background: var(--code-bg); border: 1px solid var(--border); border-radius .seconds-input { width: 72px; margin: 0 6px; } .modal-actions.left { align-items: stretch; } .modal-actions.left button { text-align: left; } +.tree-item.dragging { opacity: 0.45; } +.tree-item.drop-target { background: var(--accent-weak); box-shadow: inset 0 0 0 1px var(--accent); } +.filetree-head.drop-target { background: var(--accent-weak); box-shadow: inset 0 0 0 1px var(--accent); border-radius: 6px; } .ws-search { display: flex; flex-direction: column; gap: 8px; padding: 10px; min-height: 0; } .ws-search-box { display: flex; gap: 6px; align-items: stretch; } .ws-search-go { flex: none; width: 32px; display: inline-flex; align-items: center; justify-content: center; border: 1px solid var(--border); background: var(--bg); color: var(--text-dim); border-radius: 6px; cursor: pointer; padding: 0; } diff --git a/src/utils/text.ts b/src/utils/text.ts index 1f13538..9ae6902 100644 --- a/src/utils/text.ts +++ b/src/utils/text.ts @@ -42,3 +42,31 @@ export function baseName(path: string): string { export function isMarkdownPath(path: string): boolean { return /\.(md|markdown)$/i.test(path) } + +/** 路径分隔符归一化(Windows 的 \ 与 / 混用时统一成 / 再比较)。 */ +export function normalizeSlashes(path: string): string { + return path.replace(/\\/g, '/') +} + +/** 归一化后比较两条路径是否相同。 */ +export function pathsEqual(a?: string | null, b?: string | null): boolean { + return Boolean(a && b && normalizeSlashes(a) === normalizeSlashes(b)) +} + +/** child 是否位于 parent 目录之内(不含 parent 本身)。 */ +export function isPathInside(child: string, parent: string): boolean { + const c = normalizeSlashes(child) + const p = normalizeSlashes(parent).replace(/\/+$/, '') + return c.startsWith(`${p}/`) +} + +/** 取父目录(保持原分隔符风格)。 */ +export function parentDirOf(path: string): string { + return path.replace(/[\\/][^\\/]*$/, '') +} + +/** 拼接路径(分隔符跟随 dir 的风格)。 */ +export function joinPathFs(dir: string, name: string): string { + const sep = dir.includes('\\') ? '\\' : '/' + return `${dir.replace(/[\\/]+$/, '')}${sep}${name}` +} diff --git a/tests/sidebar-tree.test.tsx b/tests/sidebar-tree.test.tsx index 9172d2d..a01719d 100644 --- a/tests/sidebar-tree.test.tsx +++ b/tests/sidebar-tree.test.tsx @@ -99,3 +99,95 @@ describe('文件树', () => { container.remove() }) }) + +describe('文件树拖拽移动', () => { + const fireDrag = async (el: Element | undefined | null, type: string) => { + if (!el) throw new Error(`元素不存在,无法派发 ${type}`) + const e = new Event(type, { bubbles: true, cancelable: true }) as Event & { dataTransfer: Record } + e.dataTransfer = { effectAllowed: '', dropEffect: '', setData: () => undefined, getData: () => '' } + await act(async () => { el.dispatchEvent(e) }) + } + + it('把文件拖到文件夹上触发移动回调', async () => { + const moves: { path: string; dest: string }[] = [] + const { container, root } = await renderSidebar({ + onMoveEntry: (entry: { path: string }, dest: string) => moves.push({ path: entry.path, dest }), + }) + const file = findButton(container, 'a.md') + const folder = findButton(container, 'sub') + await fireDrag(file, 'dragstart') + await fireDrag(folder, 'dragover') + expect(folder?.className).toContain('drop-target') + await fireDrag(folder, 'drop') + expect(moves).toEqual([{ path: 'C:/ws/a.md', dest: 'C:/ws/sub' }]) + await act(async () => { root.unmount() }) + container.remove() + }) + + it('拖回原文件夹(根目录)不触发移动', async () => { + const moves: unknown[] = [] + const { container, root } = await renderSidebar({ + onMoveEntry: (...args: unknown[]) => moves.push(args), + }) + const file = findButton(container, 'a.md') + const head = container.querySelector('.filetree-head') + await fireDrag(file, 'dragstart') + await fireDrag(head, 'dragover') + await fireDrag(head, 'drop') + expect(moves).toEqual([]) + await act(async () => { root.unmount() }) + container.remove() + }) + + it('子文件夹里的文件可以拖回根目录', async () => { + const moves: { path: string; dest: string }[] = [] + const { container, root } = await renderSidebar({ + onMoveEntry: (entry: { path: string }, dest: string) => moves.push({ path: entry.path, dest }), + }) + await act(async () => { + findButton(container, 'sub')?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + await act(async () => { await sleep(30) }) + const inner = findButton(container, 'b.md') + const head = container.querySelector('.filetree-head') + await fireDrag(inner, 'dragstart') + await fireDrag(head, 'dragover') + await fireDrag(head, 'drop') + expect(moves).toEqual([{ path: 'C:/ws/sub/b.md', dest: 'C:/ws' }]) + await act(async () => { root.unmount() }) + container.remove() + }) + + it('文件夹拖到自己身上不触发移动', async () => { + const moves: unknown[] = [] + const { container, root } = await renderSidebar({ + onMoveEntry: (...args: unknown[]) => moves.push(args), + }) + const folder = findButton(container, 'sub') + await fireDrag(folder, 'dragstart') + await fireDrag(folder, 'dragover') + await fireDrag(folder, 'drop') + expect(moves).toEqual([]) + await act(async () => { root.unmount() }) + container.remove() + }) + + it('右键菜单含「打开所在位置」并回传条目', async () => { + const revealed: string[] = [] + const { container, root } = await renderSidebar({ + onRevealEntry: (entry: { path: string }) => revealed.push(entry.path), + }) + await act(async () => { + findButton(container, 'a.md')?.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true })) + }) + await act(async () => { await sleep(10) }) + const item = findButton(container, '打开所在位置') + expect(item).toBeTruthy() + await act(async () => { + item?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + expect(revealed).toEqual(['C:/ws/a.md']) + await act(async () => { root.unmount() }) + container.remove() + }) +}) diff --git a/tests/text.test.ts b/tests/text.test.ts index d742237..de78c72 100644 --- a/tests/text.test.ts +++ b/tests/text.test.ts @@ -37,3 +37,22 @@ describe('路径工具', () => { expect(isMarkdownPath('a.txt')).toBe(false) }) }) + +describe('路径助手(拖放/改名校验)', () => { + it('分隔符混用也能判等与取父目录', async () => { + const { pathsEqual, parentDirOf, joinPathFs } = await import('../src/utils/text') + expect(pathsEqual('C:\\ws\\sub', 'C:/ws/sub')).toBe(true) + expect(pathsEqual('C:/ws/a.md', 'C:/ws/b.md')).toBe(false) + expect(parentDirOf('C:/ws/sub/a.md')).toBe('C:/ws/sub') + expect(parentDirOf('C:\\ws\\a.md')).toBe('C:\\ws') + expect(joinPathFs('C:\\ws', 'a.md')).toBe('C:\\ws\\a.md') + expect(joinPathFs('C:/ws/', 'a.md')).toBe('C:/ws/a.md') + }) + it('isPathInside 只认真正的子路径', async () => { + const { isPathInside } = await import('../src/utils/text') + expect(isPathInside('C:/ws/sub/deep', 'C:/ws/sub')).toBe(true) + expect(isPathInside('C:/ws/sub', 'C:/ws/sub')).toBe(false) + expect(isPathInside('C:/ws/sub2', 'C:/ws/sub')).toBe(false) + expect(isPathInside('C:\\ws\\sub\\deep', 'C:/ws/sub')).toBe(true) + }) +}) diff --git a/tests/window-claims.test.ts b/tests/window-claims.test.ts new file mode 100644 index 0000000..c2f1f63 --- /dev/null +++ b/tests/window-claims.test.ts @@ -0,0 +1,36 @@ +// 跨窗口草稿认领:别的窗口打开着的文档不该在本窗口再弹恢复提示; +// 窗口注销或认领过期后,草稿重新可被提示(真正的崩溃恢复不被吞)。 +import { describe, it, expect, beforeEach } from 'vitest' +import { createWindowClaims, CLAIM_STALE_MS } from '../src/services/windowClaims' + +describe('跨窗口文档认领', () => { + beforeEach(() => { localStorage.clear() }) + + it('其他窗口认领的文档键会被列出,本窗口自己的不算', () => { + const a = createWindowClaims('win-a') + const b = createWindowClaims('win-b') + a.update(['path:C:/docs/a.md']) + b.update(['path:C:/docs/b.md']) + expect([...b.liveClaimedKeys()]).toEqual(['path:C:/docs/a.md']) + expect([...a.liveClaimedKeys()]).toEqual(['path:C:/docs/b.md']) + }) + + it('窗口注销后其认领立即失效', () => { + const a = createWindowClaims('win-a') + const b = createWindowClaims('win-b') + a.update(['path:C:/docs/a.md']) + a.dispose() + expect(b.liveClaimedKeys().size).toBe(0) + }) + + it('认领过期(窗口崩溃未注销)后自动失效,不吞崩溃草稿', () => { + let t = 1_000_000 + const now = () => t + const a = createWindowClaims('win-a', localStorage, now) + const b = createWindowClaims('win-b', localStorage, now) + a.update(['path:C:/docs/a.md']) + expect(b.liveClaimedKeys().size).toBe(1) + t += CLAIM_STALE_MS + 1 + expect(b.liveClaimedKeys().size).toBe(0) + }) +})