feat(files): 文件管理四项——侧栏拖拽移动、草稿跨窗口认领、未命名默认存根目录、打开所在位置(docs/25)
This commit is contained in:
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}`
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user