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

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

View File

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

View File

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

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; }
.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; }

View File

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