feat(files): 文件管理四项——侧栏拖拽移动、草稿跨窗口认领、未命名默认存根目录、打开所在位置(docs/25)

This commit is contained in:
Muse
2026-11-14 16:36:36 +08:00
parent 71825201a2
commit c2f69f9303
12 changed files with 513 additions and 19 deletions

View File

@@ -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 崩溃/强杀后
新窗口仍能收到草稿提示;
- 未命名保存直接落根目录、避让命名;
- 「打开所在位置」资源管理器定位效果。

View File

@@ -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] #[tauri::command]
pub fn rename_path(from: String, to: String) -> Result<(), String> { pub fn rename_path(from: String, to: String) -> Result<(), String> {
if Path::new(&to).exists() { if Path::new(&to).exists() {

View File

@@ -31,6 +31,7 @@ pub fn run() {
commands::create_text_file, commands::create_text_file,
commands::create_dir, commands::create_dir,
commands::rename_path, commands::rename_path,
commands::reveal_in_explorer,
commands::delete_to_trash, commands::delete_to_trash,
commands::db_recent_list, commands::db_recent_list,
commands::db_recent_add, commands::db_recent_add,

View File

@@ -19,18 +19,19 @@ import { insertPlainText, insertLinkText } from './editor/paste'
import { isImeComposing } from './utils/ime' import { isImeComposing } from './utils/ime'
import { ConfirmSaveModal, AboutModal, ShortcutsModal, LinkModal, ErrorModal } from './components/Modals' import { ConfirmSaveModal, AboutModal, ShortcutsModal, LinkModal, ErrorModal } from './components/Modals'
import { tabsReducer, createTab, activeTab, isDirty, type DocTab, type TabsState } from './stores/tabs' 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 { callUndo, callRedo, focusEditor, type CursorInfo } from './editor/editor'
import { editorCommands, insertMermaidBlock, insertMathBlock } from './editor/commands' import { editorCommands, insertMermaidBlock, insertMathBlock } from './editor/commands'
import { import {
isTauri, pickOpenFile, pickSaveFile, pickFolder, readTextFile, writeTextFile, isTauri, pickOpenFile, pickSaveFile, pickFolder, readTextFile, writeTextFile,
pickAndReadWebFile, downloadTextFile, pickAndReadWebFile, downloadTextFile,
pickImageFile, pickAnyFile, pickAndReadWebImage, copyToAssets, saveBytesToAssets, fileToBase64, 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, pickSaveHtmlFile, pickImportFile, readFileBase64, readBinaryFile, pickAndReadWebImport,
} from './services/platform' } from './services/platform'
import * as store from './services/store' import * as store from './services/store'
import { recoveryKey, planRecovery } from './utils/recovery' import { recoveryKey, planRecovery } from './utils/recovery'
import { appWindowClaims } from './services/windowClaims'
import { RecoveryModal, ConflictModal, QuitModal, PromptModal, ConfirmModal } from './components/Stage4Modals' import { RecoveryModal, ConflictModal, QuitModal, PromptModal, ConfirmModal } from './components/Stage4Modals'
import { AppearanceModal, ExportModal } from './components/Stage5Modals' import { AppearanceModal, ExportModal } from './components/Stage5Modals'
import { DOC_THEMES, docThemeById } from './themes' import { DOC_THEMES, docThemeById } from './themes'
@@ -101,6 +102,30 @@ export default function App() {
// ——— 阶段 4 状态 ——— // ——— 阶段 4 状态 ———
const [notice, setNotice] = useState<string | null>(null) const [notice, setNotice] = useState<string | null>(null)
const [recoveryOffer, setRecoveryOffer] = useState<RecoveryEntry[]>([]) const [recoveryOffer, setRecoveryOffer] = useState<RecoveryEntry[]>([])
// 跨窗口草稿认领:本窗口打开中的文档与正在提示的草稿归本窗口所有,
// 其他窗口启动时不再重复弹恢复提示(心跳保鲜,关闭注销,见 windowClaims)。
const claimKeysRef = useRef<string[]>([])
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 [recoveryDismissed, setRecoveryDismissed] = useState(false)
const [conflict, setConflict] = useState<{ tabId: string } | null>(null) const [conflict, setConflict] = useState<{ tabId: string } | null>(null)
const [quitOpen, setQuitOpen] = useState(false) const [quitOpen, setQuitOpen] = useState(false)
@@ -347,8 +372,18 @@ export default function App() {
return true return true
} }
let path = target.path 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) { 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 if (!path) return false
} }
await writeTextFile(path, toDiskContent(snapshot, target.lineEnding), target.hadBom) await writeTextFile(path, toDiskContent(snapshot, target.lineEnding), target.hadBom)
@@ -367,7 +402,7 @@ export default function App() {
setError(String(e)) setError(String(e))
return false return false
} }
}, [tauriMode, pushRecent]) }, [tauriMode, pushRecent, workspaceRoot])
const requestClose = useCallback((ids: string[], after: () => void) => { const requestClose = useCallback((ids: string[], after: () => void) => {
const dirtyOnes = state.tabs.filter((t) => ids.includes(t.id) && isDirty(t)) 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) const plan = planRecovery(records, (path) => disk.get(path) ?? null)
for (const key of plan.purgeKeys) void store.deleteRecovery(key) 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)。 // 多窗口:新窗口通过 ?file= 参数打开指定文档(阶段 6)。
// 审计修复:这段原先写在恢复记录的提前 return 之后,无恢复记录时永远执行不到。 // 审计修复:这段原先写在恢复记录的提前 return 之后,无恢复记录时永远执行不到。
@@ -695,6 +733,68 @@ export default function App() {
}) })
}, [flash]) }, [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) => { const renameEntry = useCallback((entry: DirEntry) => {
setPromptSpec({ setPromptSpec({
title: '重命名', label: '新名称', initial: entry.name, title: '重命名', label: '新名称', initial: entry.name,
@@ -705,21 +805,14 @@ export default function App() {
void (async () => { void (async () => {
try { try {
await renamePath(entry.path, target) await renamePath(entry.path, target)
const open = stateRef.current.tabs.find((t) => samePath(t.path, entry.path)) remapAfterRelocate(entry.path, target)
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)
}
setTreeRefresh((n) => n + 1) setTreeRefresh((n) => n + 1)
flash(`已重命名为 ${name}`) flash(`已重命名为 ${name}`)
} catch (e) { setError(String(e)) } } catch (e) { setError(String(e)) }
})() })()
}, },
}) })
}, [flash]) }, [flash, remapAfterRelocate])
const deleteEntry = useCallback((entry: DirEntry) => { const deleteEntry = useCallback((entry: DirEntry) => {
setConfirmSpec({ setConfirmSpec({
@@ -1233,6 +1326,8 @@ export default function App() {
onCreateFolderIn={tauriMode && workspaceRoot ? createFolderInDir : undefined} onCreateFolderIn={tauriMode && workspaceRoot ? createFolderInDir : undefined}
onRenameEntry={tauriMode ? renameEntry : undefined} onRenameEntry={tauriMode ? renameEntry : undefined}
onDeleteEntry={tauriMode ? deleteEntry : undefined} onDeleteEntry={tauriMode ? deleteEntry : undefined}
onMoveEntry={tauriMode ? moveEntry : undefined}
onRevealEntry={tauriMode ? revealEntry : undefined}
onOpenInNewWindowEntry={tauriMode ? newWindowEntry : undefined} onOpenInNewWindowEntry={tauriMode ? newWindowEntry : undefined}
onSaveAsEntry={saveAsEntry} onSaveAsEntry={saveAsEntry}
onExportEntry={exportEntry} onExportEntry={exportEntry}

View File

@@ -1,6 +1,7 @@
import { memo, useCallback, useEffect, useRef, useState } from 'react' import { memo, useCallback, useEffect, useRef, useState } from 'react'
import { useDismiss } from '../hooks/useDismiss' import { useDismiss } from '../hooks/useDismiss'
import { listDir, workspaceSearch, type DirEntry, type WorkspaceSearchFile } from '../services/platform' import { listDir, workspaceSearch, type DirEntry, type WorkspaceSearchFile } from '../services/platform'
import { isPathInside, parentDirOf, pathsEqual } from '../utils/text'
import type { OutlineItem } from '../utils/text' import type { OutlineItem } from '../utils/text'
import { baseName } from '../utils/text' import { baseName } from '../utils/text'
@@ -16,6 +17,10 @@ interface Props {
onCreateFolderIn?: (dir: string) => void onCreateFolderIn?: (dir: string) => void
onRenameEntry?: (entry: DirEntry) => void onRenameEntry?: (entry: DirEntry) => void
onDeleteEntry?: (entry: DirEntry) => void onDeleteEntry?: (entry: DirEntry) => void
/** 拖拽移动:把条目移到目标文件夹 */
onMoveEntry?: (entry: DirEntry, destDir: string) => void
/** 在资源管理器中打开所在位置 */
onRevealEntry?: (entry: DirEntry) => void
/** 文件条目右键的文件类动作(打开/新窗口/另存为/导出/打印) */ /** 文件条目右键的文件类动作(打开/新窗口/另存为/导出/打印) */
onOpenInNewWindowEntry?: (entry: DirEntry) => void onOpenInNewWindowEntry?: (entry: DirEntry) => void
onSaveAsEntry?: (entry: DirEntry) => void onSaveAsEntry?: (entry: DirEntry) => void
@@ -73,6 +78,8 @@ function Sidebar(p: Props) {
onCreateFolderIn={p.onCreateFolderIn} onCreateFolderIn={p.onCreateFolderIn}
onRenameEntry={p.onRenameEntry} onRenameEntry={p.onRenameEntry}
onDeleteEntry={p.onDeleteEntry} onDeleteEntry={p.onDeleteEntry}
onMoveEntry={p.onMoveEntry}
onRevealEntry={p.onRevealEntry}
refreshSignal={p.refreshSignal ?? 0} 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 root: string
activePath: string | null activePath: string | null
onOpenFile: (path: string) => void onOpenFile: (path: string) => void
@@ -100,8 +118,38 @@ function FileTree({ root, activePath, onOpenFile, onCreateFileIn, onCreateFolder
onSaveAsEntry?: (entry: DirEntry) => void onSaveAsEntry?: (entry: DirEntry) => void
onExportEntry?: (entry: DirEntry) => void onExportEntry?: (entry: DirEntry) => void
onPrintEntry?: (entry: DirEntry) => void onPrintEntry?: (entry: DirEntry) => void
onMoveEntry?: (entry: DirEntry, destDir: string) => void
onRevealEntry?: (entry: DirEntry) => void
}) { }) {
const [refreshKey, setRefreshKey] = useState(0) const [refreshKey, setRefreshKey] = useState(0)
const [dragEntry, setDragEntry] = useState<DirEntry | null>(null)
const [dropDir, setDropDir] = useState<string | null>(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 [menu, setMenu] = useState<{ x: number; y: number; entry: DirEntry } | null>(null)
const menuRef = useRef<HTMLDivElement>(null) const menuRef = useRef<HTMLDivElement>(null)
const closeMenu = useCallback(() => setMenu(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]) useEffect(() => { if (refreshSignal > 0) setRefreshKey((k) => k + 1) }, [refreshSignal])
return ( return (
<div className="filetree"> <div className="filetree">
<div className="filetree-head"> <div
className={`filetree-head${dnd && dropDir === root ? ' drop-target' : ''}`}
onDragOver={dnd ? (e) => { 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}
>
<span className="filetree-root" title={root}>{baseName(root)}</span> <span className="filetree-root" title={root}>{baseName(root)}</span>
{onCreateFileIn && <button className="filetree-add" title="在工作区根目录新建文档" onClick={() => onCreateFileIn(root)}>+ 文档</button>} {onCreateFileIn && <button className="filetree-add" title="在工作区根目录新建文档" onClick={() => onCreateFileIn(root)}>+ 文档</button>}
{onCreateFolderIn && <button className="filetree-add" title="在工作区根目录新建文件夹" onClick={() => onCreateFolderIn(root)}>+ 文件夹</button>} {onCreateFolderIn && <button className="filetree-add" title="在工作区根目录新建文件夹" onClick={() => onCreateFolderIn(root)}>+ 文件夹</button>}
@@ -118,11 +171,13 @@ function FileTree({ root, activePath, onOpenFile, onCreateFileIn, onCreateFolder
<TreeLevel <TreeLevel
dir={root} depth={0} activePath={activePath} onOpenFile={onOpenFile} refreshKey={refreshKey} dir={root} depth={0} activePath={activePath} onOpenFile={onOpenFile} refreshKey={refreshKey}
onEntryMenu={(entry, x, y) => setMenu({ entry, x, y })} onEntryMenu={(entry, x, y) => setMenu({ entry, x, y })}
dnd={dnd}
/> />
{menu && ( {menu && (
<div className="tree-context-menu" ref={menuRef} style={{ left: menu.x, top: menu.y }} onClick={(e) => e.stopPropagation()}> <div className="tree-context-menu" ref={menuRef} style={{ left: menu.x, top: menu.y }} onClick={(e) => e.stopPropagation()}>
{!menu.entry.isDir && <button onClick={() => { onOpenFile(menu.entry.path); setMenu(null) }}>打开</button>} {!menu.entry.isDir && <button onClick={() => { onOpenFile(menu.entry.path); setMenu(null) }}>打开</button>}
{!menu.entry.isDir && onOpenInNewWindowEntry && <button onClick={() => { onOpenInNewWindowEntry(menu.entry); setMenu(null) }}>在新窗口打开</button>} {!menu.entry.isDir && onOpenInNewWindowEntry && <button onClick={() => { onOpenInNewWindowEntry(menu.entry); setMenu(null) }}>在新窗口打开</button>}
{onRevealEntry && <button onClick={() => { onRevealEntry(menu.entry); setMenu(null) }}>打开所在位置</button>}
{!menu.entry.isDir && onSaveAsEntry && <button onClick={() => { onSaveAsEntry(menu.entry); setMenu(null) }}>另存为…</button>} {!menu.entry.isDir && onSaveAsEntry && <button onClick={() => { onSaveAsEntry(menu.entry); setMenu(null) }}>另存为…</button>}
{!menu.entry.isDir && onExportEntry && <button onClick={() => { onExportEntry(menu.entry); setMenu(null) }}>导出…</button>} {!menu.entry.isDir && onExportEntry && <button onClick={() => { onExportEntry(menu.entry); setMenu(null) }}>导出…</button>}
{!menu.entry.isDir && onPrintEntry && <button onClick={() => { onPrintEntry(menu.entry); setMenu(null) }}>打印 / 导出 PDF…</button>} {!menu.entry.isDir && onPrintEntry && <button onClick={() => { onPrintEntry(menu.entry); setMenu(null) }}>打印 / 导出 PDF…</button>}
@@ -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 dir: string
depth: number depth: number
activePath: string | null activePath: string | null
onOpenFile: (path: string) => void onOpenFile: (path: string) => void
refreshKey: number refreshKey: number
onEntryMenu: (entry: DirEntry, x: number, y: number) => void onEntryMenu: (entry: DirEntry, x: number, y: number) => void
dnd?: TreeDnd
}) { }) {
const [entries, setEntries] = useState<DirEntry[] | null>(null) const [entries, setEntries] = useState<DirEntry[] | null>(null)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
@@ -178,17 +234,23 @@ function TreeLevel({ dir, depth, activePath, onOpenFile, refreshKey, onEntryMenu
{entries.map((entry) => ( {entries.map((entry) => (
<div key={entry.path}> <div key={entry.path}>
<button <button
className={`tree-item${entry.isDir ? ' dir' : ''}${!entry.isDir && entry.path === activePath ? ' active' : ''}`} className={`tree-item${entry.isDir ? ' dir' : ''}${!entry.isDir && entry.path === activePath ? ' active' : ''}${dnd && dnd.draggingPath === entry.path ? ' dragging' : ''}${dnd && entry.isDir && dnd.dropDir === entry.path ? ' drop-target' : ''}`}
style={{ paddingLeft: 8 + depth * 13 }} style={{ paddingLeft: 8 + depth * 13 }}
onClick={() => (entry.isDir ? toggle(entry.path) : onOpenFile(entry.path))} onClick={() => (entry.isDir ? toggle(entry.path) : onOpenFile(entry.path))}
onContextMenu={(e) => { e.preventDefault(); onEntryMenu(entry, e.clientX, e.clientY) }} onContextMenu={(e) => { e.preventDefault(); onEntryMenu(entry, e.clientX, e.clientY) }}
title={entry.path} title={entry.path}
draggable={Boolean(dnd)}
onDragStart={dnd ? (e) => dnd.start(entry, e.dataTransfer) : undefined}
onDragEnd={dnd ? () => dnd.end() : undefined}
onDragOver={dnd && entry.isDir ? (e) => { if (dnd.canDrop(entry.path)) { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; dnd.over(entry.path) } } : undefined}
onDragLeave={dnd && entry.isDir ? () => dnd.leave(entry.path) : undefined}
onDrop={dnd && entry.isDir ? (e) => { e.preventDefault(); dnd.drop(entry.path) } : undefined}
> >
<span className="tree-icon">{entry.isDir ? (expanded.has(entry.path) ? '▾' : '▸') : '·'}</span> <span className="tree-icon">{entry.isDir ? (expanded.has(entry.path) ? '▾' : '▸') : '·'}</span>
{entry.name} {entry.name}
</button> </button>
{entry.isDir && expanded.has(entry.path) && ( {entry.isDir && expanded.has(entry.path) && (
<TreeLevel dir={entry.path} depth={depth + 1} activePath={activePath} onOpenFile={onOpenFile} refreshKey={refreshKey} onEntryMenu={onEntryMenu} /> <TreeLevel dir={entry.path} depth={depth + 1} activePath={activePath} onOpenFile={onOpenFile} refreshKey={refreshKey} onEntryMenu={onEntryMenu} dnd={dnd} />
)} )}
</div> </div>
))} ))}

View File

@@ -306,3 +306,8 @@ export interface WorkspaceSearchFile {
export async function workspaceSearch(root: string, query: string, caseSensitive: boolean): Promise<WorkspaceSearchFile[]> { export async function workspaceSearch(root: string, query: string, caseSensitive: boolean): Promise<WorkspaceSearchFile[]> {
return invoke<WorkspaceSearchFile[]>('workspace_search', { root, query, caseSensitive }) return invoke<WorkspaceSearchFile[]>('workspace_search', { root, query, caseSensitive })
} }
/** 在系统文件管理器中定位该路径(打开父目录并选中它)。 */
export async function revealInExplorer(path: string): Promise<void> {
return invoke<void>('reveal_in_explorer', { path })
}

View File

@@ -0,0 +1,80 @@
// 跨窗口文档认领:多窗口共享同一份恢复记录(SQLite),若不协调,每个新窗口
// 启动时都会把别的窗口正在编辑的未保存文档当成「崩溃草稿」弹出来。
// 机制:每个窗口把自己「打开中的文档键 + 正在提示恢复的文档键」写进
// localStorage 注册表并心跳保鲜;启动扫描时,凡是被其他存活窗口认领的
// 恢复记录一律不提示。窗口关闭即注销(pagehide 尽力而为),崩溃残留的
// 认领超过保鲜期自动失效,不会吞掉真正的崩溃草稿。
export interface WindowClaims {
/** 更新本窗口认领的文档键(同时清理已过期的他人条目)。 */
update(keys: string[]): void
/** 其他存活窗口认领的文档键集合。 */
liveClaimedKeys(): Set<string>
/** 注销本窗口(窗口关闭时调用)。 */
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<Storage, 'getItem' | 'setItem' | 'removeItem'> = localStorage,
now: () => number = Date.now,
): WindowClaims {
const read = (): Record<string, ClaimEntry> => {
try {
const raw = storage.getItem(REGISTRY_KEY)
if (!raw) return {}
const parsed = JSON.parse(raw) as Record<string, ClaimEntry>
return parsed && typeof parsed === 'object' ? parsed : {}
} catch {
return {}
}
}
const write = (reg: Record<string, ClaimEntry>) => {
try {
if (Object.keys(reg).length === 0) storage.removeItem(REGISTRY_KEY)
else storage.setItem(REGISTRY_KEY, JSON.stringify(reg))
} catch { /* 存储不可用时降级为旧行为(各窗口各自提示) */ }
}
const prune = (reg: Record<string, ClaimEntry>): Record<string, ClaimEntry> => {
const t = now()
const out: Record<string, ClaimEntry> = {}
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<string>()
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)

View File

@@ -332,6 +332,9 @@ kbd { background: var(--code-bg); border: 1px solid var(--border); border-radius
.seconds-input { width: 72px; margin: 0 6px; } .seconds-input { width: 72px; margin: 0 6px; }
.modal-actions.left { align-items: stretch; } .modal-actions.left { align-items: stretch; }
.modal-actions.left button { text-align: left; } .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 { display: flex; flex-direction: column; gap: 8px; padding: 10px; min-height: 0; }
.ws-search-box { display: flex; gap: 6px; align-items: stretch; } .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; } .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; }

View File

@@ -42,3 +42,31 @@ export function baseName(path: string): string {
export function isMarkdownPath(path: string): boolean { export function isMarkdownPath(path: string): boolean {
return /\.(md|markdown)$/i.test(path) 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}`
}

View File

@@ -99,3 +99,95 @@ describe('文件树', () => {
container.remove() 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<string, unknown> }
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()
})
})

View File

@@ -37,3 +37,22 @@ describe('路径工具', () => {
expect(isMarkdownPath('a.txt')).toBe(false) 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)
})
})

View File

@@ -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)
})
})