feat(files): 文件管理四项——侧栏拖拽移动、草稿跨窗口认领、未命名默认存根目录、打开所在位置(docs/25)
This commit is contained in:
58
docs/25-文件管理四项.md
Normal file
58
docs/25-文件管理四项.md
Normal 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 崩溃/强杀后
|
||||
新窗口仍能收到草稿提示;
|
||||
- 未命名保存直接落根目录、避让命名;
|
||||
- 「打开所在位置」资源管理器定位效果。
|
||||
@@ -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() {
|
||||
|
||||
@@ -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,
|
||||
|
||||
123
src/App.tsx
123
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<string | null>(null)
|
||||
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 [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}
|
||||
|
||||
@@ -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<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 menuRef = useRef<HTMLDivElement>(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 (
|
||||
<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>
|
||||
{onCreateFileIn && <button className="filetree-add" title="在工作区根目录新建文档" onClick={() => onCreateFileIn(root)}>+ 文档</button>}
|
||||
{onCreateFolderIn && <button className="filetree-add" title="在工作区根目录新建文件夹" onClick={() => onCreateFolderIn(root)}>+ 文件夹</button>}
|
||||
@@ -118,11 +171,13 @@ function FileTree({ root, activePath, onOpenFile, onCreateFileIn, onCreateFolder
|
||||
<TreeLevel
|
||||
dir={root} depth={0} activePath={activePath} onOpenFile={onOpenFile} refreshKey={refreshKey}
|
||||
onEntryMenu={(entry, x, y) => setMenu({ entry, x, y })}
|
||||
dnd={dnd}
|
||||
/>
|
||||
{menu && (
|
||||
<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 && 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 && onExportEntry && <button onClick={() => { onExportEntry(menu.entry); setMenu(null) }}>导出…</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
|
||||
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<DirEntry[] | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
@@ -178,17 +234,23 @@ function TreeLevel({ dir, depth, activePath, onOpenFile, refreshKey, onEntryMenu
|
||||
{entries.map((entry) => (
|
||||
<div key={entry.path}>
|
||||
<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 }}
|
||||
onClick={() => (entry.isDir ? toggle(entry.path) : onOpenFile(entry.path))}
|
||||
onContextMenu={(e) => { e.preventDefault(); onEntryMenu(entry, e.clientX, e.clientY) }}
|
||||
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>
|
||||
{entry.name}
|
||||
</button>
|
||||
{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>
|
||||
))}
|
||||
|
||||
@@ -306,3 +306,8 @@ export interface WorkspaceSearchFile {
|
||||
export async function workspaceSearch(root: string, query: string, caseSensitive: boolean): Promise<WorkspaceSearchFile[]> {
|
||||
return invoke<WorkspaceSearchFile[]>('workspace_search', { root, query, caseSensitive })
|
||||
}
|
||||
|
||||
/** 在系统文件管理器中定位该路径(打开父目录并选中它)。 */
|
||||
export async function revealInExplorer(path: string): Promise<void> {
|
||||
return invoke<void>('reveal_in_explorer', { path })
|
||||
}
|
||||
|
||||
80
src/services/windowClaims.ts
Normal file
80
src/services/windowClaims.ts
Normal 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)
|
||||
@@ -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; }
|
||||
|
||||
@@ -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}`
|
||||
}
|
||||
|
||||
@@ -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<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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
36
tests/window-claims.test.ts
Normal file
36
tests/window-claims.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user