audit(docs/29): 全项目审计——修分片载入卸载截断高危、Sidebar memo 击穿与 Rust 资源/搜索/恢复上限等 20 余处

三线并行审计(编辑器核心/Rust 后端/服务与组件)+ App 编排层自审。
高危:分片载入未收尾时窗格卸载回写部分序列化,干净标签会被静默
截断——cleanup 补 muted 守卫,create 完成回调补取消守卫。
性能:gitSync/outline/收起回调稳定化保住 Sidebar memo;光标行列
rAF 合并;图片显示解析加缓存;导出图片并发解析;chunkLoader
偏移前缀表;围栏切点只认顶格(列表内嵌围栏等价回归实测)。
Rust:资源命令 async 化 + 32MB 上限;搜索 junction 越界校验;
recovery/workspace 条数与单条上限;启动坏库改名重建 + WAL;
validate 补 ext:: 黑名单与 ref 形态;init 先种 .gitignore
(含 .*.tmp-* 防崩溃残留被提交);游离 HEAD 不再猜分支。
验证:vitest 122+2、tsc、构建通过;Rust 过 rustfmt 解析、
validate 纯函数抽出实跑断言、命令对账 28=28。
This commit is contained in:
Muse
2026-11-14 18:00:31 +08:00
parent 527ca5ecc5
commit fb2315a2f3
28 changed files with 657 additions and 140 deletions

View File

@@ -73,6 +73,12 @@ function toDiskContent(content: string, lineEnding: string): string {
return lineEnding === 'CRLF' ? content.replace(/\n/g, '\r\n') : content
}
/** 编辑区图片显示解析缓存:(文档路径|src) → data URL Promise,上限 60 条 FIFO。 */
const imageDisplayCache = new Map<string, Promise<string | null>>()
/** 非大纲模式下传给 Sidebar 的稳定空大纲(保住 memo,避免正文变化连带整栏重渲染)。 */
const EMPTY_OUTLINE: never[] = []
export default function App() {
const [state, dispatch] = useReducer(tabsReducer, undefined, (): TabsState => {
const welcome = createTab({ title: '欢迎', content: WELCOME_MD })
@@ -734,9 +740,13 @@ export default function App() {
// ——— 阶段 4:外部修改检测(4 秒轮询 stat) ———
const conflictRef = useRef<string | null>(null)
conflictRef.current = conflict?.tabId ?? null
const externalCheckBusyRef = useRef(false)
useEffect(() => {
const timer = window.setInterval(() => {
if (externalCheckBusyRef.current) return
externalCheckBusyRef.current = true
void (async () => {
try {
for (const t of stateRef.current.tabs) {
if (!t.path) continue
const base = baselinesRef.current.get(t.id)
@@ -756,6 +766,9 @@ export default function App() {
setConflict({ tabId: t.id })
}
}
} finally {
externalCheckBusyRef.current = false
}
})()
}, 4000)
return () => window.clearInterval(timer)
@@ -1184,6 +1197,10 @@ export default function App() {
/** 编辑区图片显示解析:相对/本地路径按文档目录归一化后读成 data URL(仅显示层)。 */
const resolveDisplayImage = useCallback(async (docPath: string, src: string): Promise<string | null> => {
const cacheKey = `${docPath}|${src}`
const cached = imageDisplayCache.get(cacheKey)
if (cached) return cached
const promise = (async (): Promise<string | null> => {
try {
if (/^(https?:|data:|blob:)/i.test(src)) return null
let decoded = src
@@ -1207,6 +1224,13 @@ export default function App() {
} catch {
return null
}
})()
imageDisplayCache.set(cacheKey, promise)
if (imageDisplayCache.size > 60) {
const oldest = imageDisplayCache.keys().next().value
if (oldest) imageDisplayCache.delete(oldest)
}
return promise
}, [])
const buildExportHtml = useCallback(async (
@@ -1464,6 +1488,29 @@ export default function App() {
? state.tabs.filter((t) => pendingClose.ids.includes(t.id) && isDirty(t)).map((t) => t.title).join('、')
: ''
// Sidebar 是 memo 组件:gitSync 对象与收起回调必须保持引用稳定,
// 否则每次击键的 App 重渲染都会连带整棵文件树重渲染(docs/29)
const collapseSidebar = useCallback(() => setUi((u) => ({ ...u, sidebar: false })), [])
const gitSyncUi = useMemo(() => (tauriMode && workspaceRoot ? {
isRepo: Boolean(gitInfo?.isRepo),
label: gitStatusLabel(gitInfo, gitSyncing),
syncing: gitSyncing,
lastMessage: gitMsg?.text ?? null,
lastIsError: gitMsg?.isError ?? false,
onSyncNow: () => { void runGitSync(false) },
onOpenSettings: () => {
// 仓库状态还没读出来时不开弹窗:否则会把已有仓库误判成
// 未初始化(远程地址显示为空、保存走 init 流程报错)
if (!gitInfo) {
setGitMsg({ text: '正在读取仓库状态,请稍候再打开设置', isError: false })
if (workspaceRoot) void refreshGitStatus(workspaceRoot)
return
}
setGitSettingsError(null)
setGitSettingsOpen(true)
},
} : undefined), [tauriMode, workspaceRoot, gitInfo, gitSyncing, gitMsg, runGitSync, refreshGitStatus])
return (
<div className="app">
<TitleBar docTitle={tab?.title ?? 'new-markdown'} dirty={tab ? isDirty(tab) : false} />
@@ -1488,31 +1535,13 @@ export default function App() {
onSaveAsEntry={saveAsEntry}
onExportEntry={exportEntry}
onPrintEntry={printEntry}
onCollapseSidebar={() => setUi((u) => ({ ...u, sidebar: false }))}
onCollapseSidebar={collapseSidebar}
refreshSignal={treeRefresh}
outline={outline}
outline={sidebarMode === 'outline' ? outline : EMPTY_OUTLINE}
onJumpHeading={jumpHeading}
onOpenSearchResult={openSearchResult}
tauriMode={tauriMode}
gitSync={tauriMode && workspaceRoot ? {
isRepo: Boolean(gitInfo?.isRepo),
label: gitStatusLabel(gitInfo, gitSyncing),
syncing: gitSyncing,
lastMessage: gitMsg?.text ?? null,
lastIsError: gitMsg?.isError ?? false,
onSyncNow: () => { void runGitSync(false) },
onOpenSettings: () => {
// 仓库状态还没读出来时不开弹窗:否则会把已有仓库误判成
// 未初始化(远程地址显示为空、保存走 init 流程报错)
if (!gitInfo) {
setGitMsg({ text: '正在读取仓库状态,请稍候再打开设置', isError: false })
if (workspaceRoot) void refreshGitStatus(workspaceRoot)
return
}
setGitSettingsError(null)
setGitSettingsOpen(true)
},
} : undefined}
gitSync={gitSyncUi}
/>
</div>
)}

View File

@@ -1,7 +1,7 @@
// 自绘下拉(替代原生 select:原生展开层是系统样式,与应用风格脱节)。
// 外观对齐 .input;展开层沿用应用菜单语言(面板底、6px 圆角、悬停 --hover、
// 选中 --accent-weak);点别处/Esc 关闭走统一的 useDismiss;支持上下键选行。
import { useEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useDismiss } from '../hooks/useDismiss'
export interface DropdownOption { value: string; label: string }
@@ -16,7 +16,8 @@ export function Dropdown({ value, options, onChange, disabled, ariaLabel }: {
const [open, setOpen] = useState(false)
const ref = useRef<HTMLDivElement>(null)
const menuRef = useRef<HTMLDivElement>(null)
useDismiss(ref, open, () => setOpen(false))
const close = useCallback(() => setOpen(false), [])
useDismiss(ref, open, close)
const current = options.find((o) => o.value === value) ?? options[0]

View File

@@ -1,5 +1,6 @@
import { useRef } from 'react'
import { useDismiss } from '../hooks/useDismiss'
import { clampMenuPosition } from '../utils/menuPos'
export type EditorMenuAction =
| 'cut' | 'copy' | 'paste' | 'selectAll'
@@ -26,9 +27,7 @@ export default function EditorContextMenu({ x, y, hasSelection, sourceMode, onAc
useDismiss(ref, true, onClose)
const act = (a: EditorMenuAction) => { onClose(); onAction(a) }
// 位置夹取,避免菜单溢出窗口右/下边(尺寸为估算值:宽约 190,高随模式)
const left = Math.max(4, Math.min(x, window.innerWidth - 196))
const estHeight = sourceMode ? 236 : 388
const top = Math.max(4, Math.min(y, window.innerHeight - estHeight - 8))
const { left, top } = clampMenuPosition(x, y, 188, sourceMode ? 236 : 388)
return (
<div
className="tree-context-menu editor-context-menu"

View File

@@ -70,7 +70,8 @@ export default function EditorPane({ tabId, initialMarkdown, onMarkdown, onFinal
if (!wrap || !el) { setTableBar(null); return }
const wr = wrap.getBoundingClientRect()
const tr = el.getBoundingClientRect()
setTableBar({ top: Math.max(0, tr.top - wr.top - 38), left: Math.max(0, tr.left - wr.left) })
const next = { top: Math.max(0, tr.top - wr.top - 38), left: Math.max(0, tr.left - wr.left) }
setTableBar((cur) => (cur && cur.top === next.top && cur.left === next.left ? cur : next))
}, [])
const hostRef = useRef<HTMLDivElement>(null)
const initialRef = useRef(initialMarkdown)
@@ -97,7 +98,7 @@ export default function EditorPane({ tabId, initialMarkdown, onMarkdown, onFinal
const lineCenter = (coords.top + coords.bottom) / 2 - rect.top + container.scrollTop
const target = lineCenter - container.clientHeight / 2
if (Math.abs(target - container.scrollTop) > 6) {
container.scrollTo({ top: target, behavior: 'smooth' })
container.scrollTo({ top: target, behavior: 'auto' })
}
})
} catch { /* 视图未就绪时跳过 */ }
@@ -232,6 +233,9 @@ export default function EditorPane({ tabId, initialMarkdown, onMarkdown, onFinal
}
editor.create()
.then(() => {
// 已卸载(关标签/保活淘汰/epoch 重挂)时不再注册 API:cleanup 已
// 注销并 destroy,这里若继续会把已销毁编辑器的 API 注册回 App
if (cancelled) { void editor.destroy().catch(() => undefined); return }
setReady(true)
setFocusModeEnabled(modesRef.current.focusMode)
try {
@@ -267,7 +271,11 @@ export default function EditorPane({ tabId, initialMarkdown, onMarkdown, onFinal
})
.catch((e) => console.error('编辑器创建失败', e))
const scrollParent = wrapRef.current?.closest('.editor-scroll')
const onScroll = () => { if (editorObjRef.current) updateTableBar() }
let scrollRaf = 0
const onScroll = () => {
if (!editorObjRef.current || scrollRaf) return
scrollRaf = requestAnimationFrame(() => { scrollRaf = 0; updateTableBar() })
}
scrollParent?.addEventListener('scroll', onScroll, { passive: true })
return () => {
cancelled = true
@@ -275,9 +283,15 @@ export default function EditorPane({ tabId, initialMarkdown, onMarkdown, onFinal
editorObjRef.current = null
setTableBar(null)
try {
const finalMarkdown = getMarkdown(editor)
if (callbacksRef.current.onFinalMarkdown) callbacksRef.current.onFinalMarkdown(tabId, finalMarkdown)
else callbacksRef.current.onMarkdown(tabId, finalMarkdown)
// 分片载入未收尾(muted)时编辑器里只有部分文档:跳过最终回写。
// 状态里存的是磁盘完整原文,回写部分序列化会把文档静默截断
// (syncEditorContent 对干净标签会把 savedContent 一起对齐)。
// 载入失败路径已主动解除 muted 并如实回写,不受此守卫影响。
if (!muted) {
const finalMarkdown = getMarkdown(editor)
if (callbacksRef.current.onFinalMarkdown) callbacksRef.current.onFinalMarkdown(tabId, finalMarkdown)
else callbacksRef.current.onMarkdown(tabId, finalMarkdown)
}
} catch { /* 编辑器未就绪时跳过回写 */ }
callbacksRef.current.onReady(EMPTY_API)
editor.destroy().catch(() => undefined)

View File

@@ -1,4 +1,5 @@
import { useEffect, useRef } from 'react'
import Tooltip from './Tooltip'
import type { FindOptions } from '../editor/search'
interface Props {
@@ -29,21 +30,26 @@ export default function FindBar(p: Props) {
}, [])
const toggle = (key: keyof FindOptions, label: string, title: string) => (
<button
className={`find-toggle ${p.opts[key] ? 'on' : ''}`}
title={title}
onClick={() => p.onOptsChange({ ...p.opts, [key]: !p.opts[key] })}
>
{label}
</button>
<Tooltip label={title}>
<button
className={`find-toggle ${p.opts[key] ? 'on' : ''}`}
aria-label={title}
aria-pressed={Boolean(p.opts[key])}
onClick={() => p.onOptsChange({ ...p.opts, [key]: !p.opts[key] })}
>
{label}
</button>
</Tooltip>
)
return (
<div className="findbar">
<div className="findbar-row">
<button className="find-icon" title={p.showReplace ? '收起替换' : '展开替换'} onClick={p.onToggleReplace}>
{p.showReplace ? '▾' : '▸'}
</button>
<Tooltip label={p.showReplace ? '收起替换' : '展开替换'}>
<button className="find-icon" aria-label={p.showReplace ? '收起替换' : '展开替换'} onClick={p.onToggleReplace}>
{p.showReplace ? '▾' : '▸'}
</button>
</Tooltip>
<input
ref={queryRef}
className="input find-input"
@@ -61,9 +67,9 @@ export default function FindBar(p: Props) {
<span className="find-count">
{p.error ? p.error : p.query ? (p.count === 0 ? '无结果' : `${p.current + 1} / ${p.count}`) : ''}
</span>
<button className="find-icon" title="上一个 (Shift+Enter)" onClick={p.onPrev}>↑</button>
<button className="find-icon" title="下一个 (Enter)" onClick={p.onNext}>↓</button>
<button className="find-icon" title="关闭 (Esc)" onClick={p.onClose}>✕</button>
<Tooltip label="上一个 (Shift+Enter)"><button className="find-icon" aria-label="上一个" onClick={p.onPrev}>↑</button></Tooltip>
<Tooltip label="下一个 (Enter)"><button className="find-icon" aria-label="下一个" onClick={p.onNext}>↓</button></Tooltip>
<Tooltip label="关闭 (Esc)"><button className="find-icon" aria-label="关闭" onClick={p.onClose}>✕</button></Tooltip>
</div>
{p.showReplace && (
<div className="findbar-row">

View File

@@ -1,6 +1,6 @@
// Git 同步设置:远程仓库地址、(初始化时的)分支、定时同步间隔。
// 工作区还不是仓库时,保存=初始化仓库并绑定远程;已是仓库时只更新设置。
import { useState } from 'react'
import { useEffect, useState } from 'react'
import { Dropdown } from './Dropdown'
const INTERVAL_OPTIONS = [
@@ -21,13 +21,18 @@ export function GitSyncModal({ isRepo, branch, initialRemoteUrl, initialInterval
onSave: (remoteUrl: string, branch: string, intervalMin: number) => void
onClose: () => void
}) {
useEffect(() => {
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape' && !busy) onClose() }
document.addEventListener('keydown', onKey)
return () => document.removeEventListener('keydown', onKey)
}, [busy, onClose])
const [remoteUrl, setRemoteUrl] = useState(initialRemoteUrl)
const [branchInput, setBranchInput] = useState('main')
const [intervalMin, setIntervalMin] = useState(String(initialIntervalMin))
return (
<div className="modal-backdrop">
<div className="modal">
<div className="modal-backdrop" onClick={() => { if (!busy) onClose() }}>
<div className="modal" onClick={(e) => e.stopPropagation()}>
<h3>Git 同步设置</h3>
<p className="modal-note">
同步顺序:提交本地更改 → 拉取远程更新(变基)→ 推送。若本地与远程改了相同内容,

View File

@@ -1,6 +1,7 @@
import { memo, useCallback, useEffect, useRef, useState } from 'react'
import { useDismiss } from '../hooks/useDismiss'
import Tooltip from './Tooltip'
import { clampMenuPosition } from '../utils/menuPos'
import { listDir, workspaceSearch, type DirEntry, type WorkspaceSearchFile } from '../services/platform'
import { isPathInside, parentDirOf, pathsEqual } from '../utils/text'
import type { OutlineItem } from '../utils/text'
@@ -83,6 +84,7 @@ function Sidebar(p: Props) {
<>
{p.gitSync && <GitSyncBar ui={p.gitSync} />}
<FileTree
key={p.workspaceRoot}
onOpenInNewWindowEntry={p.onOpenInNewWindowEntry}
onSaveAsEntry={p.onSaveAsEntry}
onExportEntry={p.onExportEntry}
@@ -217,7 +219,7 @@ function FileTree({ root, activePath, onOpenFile, onCreateFileIn, onCreateFolder
dnd={dnd}
/>
{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={clampMenuPosition(menu.x, menu.y, 208, 340)} 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>}
@@ -277,7 +279,7 @@ 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' : ''}${dnd && dnd.draggingPath === entry.path ? ' dragging' : ''}${dnd && entry.isDir && dnd.dropDir === entry.path ? ' drop-target' : ''}`}
className={`tree-item${entry.isDir ? ' dir' : ''}${!entry.isDir && pathsEqual(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) }}
@@ -313,18 +315,22 @@ function WorkspaceSearch({ root, tauriMode, onOpen }: {
const [searching, setSearching] = useState(false)
const [error, setError] = useState<string | null>(null)
const runSeqRef = useRef(0)
const run = async () => {
if (!root || !query.trim()) return
const seq = ++runSeqRef.current
setSearching(true)
setError(null)
try {
const word = query.trim()
setResults(await workspaceSearch(root, word, caseSensitive))
const found = await workspaceSearch(root, word, caseSensitive)
if (runSeqRef.current !== seq) return // 已有更新的搜索,旧结果作废
setResults(found)
setSearchedQuery(word)
} catch (e) {
setError(String(e))
if (runSeqRef.current === seq) setError(String(e))
} finally {
setSearching(false)
if (runSeqRef.current === seq) setSearching(false)
}
}

View File

@@ -1,4 +1,5 @@
import { useCallback, useRef, useState } from 'react'
import { clampMenuPosition } from '../utils/menuPos'
import type { DocTab } from '../stores/tabs'
import { isDirty } from '../stores/tabs'
import { useDismiss } from '../hooks/useDismiss'
@@ -23,6 +24,7 @@ interface Props {
export default function TabBar({ tabs, activeId, onActivate, onClose, onCloseOthers, onCloseRight, onNew, onMove, onSaveTab, onSaveAsTab, onOpenInNewWindowTab, onExportTab, onPrintTab }: Props) {
const [menuFor, setMenuFor] = useState<{ id: string; x: number; y: number } | null>(null)
const [dragId, setDragId] = useState<string | null>(null)
const lastMoveRef = useRef<string | null>(null)
const menuRef = useRef<HTMLDivElement>(null)
const closeMenu = useCallback(() => setMenuFor(null), [])
useDismiss(menuRef, menuFor !== null, closeMenu)
@@ -46,11 +48,12 @@ export default function TabBar({ tabs, activeId, onActivate, onClose, onCloseOth
onDragStart={() => setDragId(tab.id)}
onDragOver={(e) => {
e.preventDefault()
if (dragId && dragId !== tab.id) {
if (dragId && dragId !== tab.id && lastMoveRef.current !== `${dragId}:${index}`) {
lastMoveRef.current = `${dragId}:${index}`
onMove(dragId, index)
}
}}
onDragEnd={() => setDragId(null)}
onDragEnd={() => { setDragId(null); lastMoveRef.current = null }}
onClick={() => onActivate(tab.id)}
onContextMenu={(e) => {
e.preventDefault()
@@ -70,7 +73,7 @@ export default function TabBar({ tabs, activeId, onActivate, onClose, onCloseOth
))}
<button className="tab-new" aria-label="新建文档" onClick={onNew}>+</button>
{menuFor && menuTab && (
<div className="context-menu" ref={menuRef} style={{ left: menuFor.x, top: menuFor.y }}>
<div className="context-menu" ref={menuRef} style={clampMenuPosition(menuFor.x, menuFor.y, 200, 330)}>
<button disabled={!isDirty(menuTab)} onClick={() => act(onSaveTab)}>保存</button>
<button onClick={() => act(onSaveAsTab)}>另存为…</button>
{onOpenInNewWindowTab && <button onClick={() => act(onOpenInNewWindowTab)}>在新窗口打开</button>}

View File

@@ -1,5 +1,6 @@
import type { Editor } from '@milkdown/kit/core'
import { editorCommands } from '../editor/commands'
import Tooltip from './Tooltip'
interface Props {
editor: Editor
@@ -10,7 +11,9 @@ interface Props {
/** 表格浮动工具栏:光标在表格内时出现在表格上方(EditorPane 负责定位与显隐)。 */
export default function TableToolbar({ editor, top, left }: Props) {
const btn = (label: string, fn: () => void, title?: string) => (
<button onMouseDown={(e) => e.preventDefault()} onClick={fn} title={title ?? label}>{label}</button>
<Tooltip label={title ?? label}>
<button onMouseDown={(e) => e.preventDefault()} onClick={fn} aria-label={title ?? label}>{label}</button>
</Tooltip>
)
return (
<div className="table-toolbar" style={{ top, left }} role="toolbar" aria-label="表格工具">

View File

@@ -12,6 +12,8 @@ export const ASYNC_LOAD_THRESHOLD = 60_000
const CHUNK_TARGET = 24_000
const FENCE_RE = /^\s{0,3}(```+|~~~+)/
/** 围栏作切点时只认顶格:列表项内缩进的围栏是列表内容,切开会改变归属(docs/29) */
const FENCE_CUT_RE = /^(`{3,}|~{3,})/
const HEADING_RE = /^#{1,6}\s/
const HR_RE = /^\s{0,3}(\*\*\*+|---+|___+)\s*$/
const LINK_DEF_RE = /^\s{0,3}\[[^\]]+\]:\s*\S+/
@@ -44,20 +46,18 @@ export function splitMarkdownChunks(markdown: string): string[] {
if (inFence) continue
if (line.trim() !== '' || i + 1 >= lines.length) continue
const next = lines[i + 1]
if (HEADING_RE.test(next) || FENCE_RE.test(next) || HR_RE.test(next)) cuts.push(i + 1)
if (HEADING_RE.test(next) || FENCE_CUT_RE.test(next) || HR_RE.test(next)) cuts.push(i + 1)
}
if (cuts.length === 0) return [markdown]
// 按目标大小合并相邻片段:切点很多时攒够一片再切,避免碎成几百片
const chunks: string[] = []
let startLine = 0
let startOffset = 0
const lineOffset = (idx: number) => {
let off = 0
for (let i = 0; i < idx; i++) off += lines[i].length + 1
return off
}
// 行偏移前缀表(原实现每个切点都从头累加,切点密集时 O(切点×行数))
const lineOffsets: number[] = [0]
for (let i = 0; i < lines.length; i++) lineOffsets.push(lineOffsets[i] + lines[i].length + 1)
for (const cut of cuts) {
const off = lineOffset(cut)
const off = lineOffsets[cut]
if (off - startOffset >= CHUNK_TARGET && cut > startLine) {
chunks.push(markdown.slice(startOffset, off))
startLine = cut

View File

@@ -44,21 +44,34 @@ export function createMarkdownEditor(root: HTMLElement, initialMarkdown: string,
lm.markdownUpdated((_ctx, markdown) => callbacks.onMarkdown?.(markdown))
}
if (callbacks.onCursor || callbacks.onTableContext) {
// 选区事件在每个事务后同步分发,而行列计算要从文档头逐块数到
// 光标(大文档 O(光标位置)):用 rAF 合并,逐帧最多算一次(docs/29)
let cursorRaf = 0
let pendingRun: (() => void) | null = null
lm.selectionUpdated((selCtx, selection) => {
callbacks.onCursor?.(cursorFromSelection(selection))
if (callbacks.onTableContext) {
const inTable = selectionInTable(selection as never)
let tableEl: HTMLElement | null = null
if (inTable) {
try {
const view = selCtx.get(editorViewCtx)
const at = view.domAtPos(selection.from)
const base = at.node.nodeType === Node.ELEMENT_NODE ? (at.node as Element) : at.node.parentElement
tableEl = (base?.closest('table') as HTMLElement | null) ?? null
} catch { /* 定位失败时工具栏不显示 */ }
pendingRun = () => {
callbacks.onCursor?.(cursorFromSelection(selection))
if (callbacks.onTableContext) {
const inTable = selectionInTable(selection as never)
let tableEl: HTMLElement | null = null
if (inTable) {
try {
const view = selCtx.get(editorViewCtx)
const at = view.domAtPos(selection.from)
const base = at.node.nodeType === Node.ELEMENT_NODE ? (at.node as Element) : at.node.parentElement
tableEl = (base?.closest('table') as HTMLElement | null) ?? null
} catch { /* 定位失败时工具栏不显示 */ }
}
callbacks.onTableContext(inTable, tableEl)
}
callbacks.onTableContext(inTable, tableEl)
}
if (cursorRaf) return
cursorRaf = requestAnimationFrame(() => {
cursorRaf = 0
const run = pendingRun
pendingRun = null
run?.()
})
})
}
ctx.update(uploadConfig.key, (prev: UploadOptions) => ({

View File

@@ -16,6 +16,8 @@ interface ViewLike {
* 不产生空段落(上一版 replaceSelectionWith + insert 在段中粘贴会留一个 <br /> 空段)。 */
export function insertPlainTextIntoView(view: ViewLike, text: string): boolean {
const lines = text.replace(/\r\n?/g, '\n').split('\n')
// 原文以换行结尾时末项是空串:丢弃,否则粘贴后会多一个空段落
if (lines.length > 1 && lines[lines.length - 1] === '') lines.pop()
const paragraph = view.state.schema.nodes.paragraph
const parentType = view.state.selection.$from.parent.type.name
if (lines.length <= 1 || !paragraph || (parentType !== 'paragraph' && parentType !== 'heading')) {

View File

@@ -67,13 +67,14 @@ async function postProcess(tree: Root, opts: ExportOptions): Promise<void> {
// 图片内嵌
if (opts.imageMode === 'embed' && opts.resolveImage) {
for (const { node } of images) {
// 并发解析(原先逐张串行 IPC,图片多的文档导出线性变慢)
await Promise.all(images.map(async ({ node }) => {
const src = node.properties?.src
if (typeof src === 'string' && !/^(https?:|data:)/i.test(src)) {
const data = await opts.resolveImage(src)
const data = await opts.resolveImage?.(src)
if (data) node.properties.src = data
}
}
}))
}
// 相对模式按导出文件位置重写图片路径(验收 E3:原先原样保留文档相对路径,
@@ -291,6 +292,9 @@ function loadHighlighter(): Promise<import('shiki').HighlighterCore> {
})
})()
}
// 初始化失败时不永久缓存 rejected Promise(否则整会话高亮失效):
// 本次照常抛给调用方兜底,下次导出重新尝试初始化
highlighterPromise.catch(() => { highlighterPromise = null })
return highlighterPromise
}

View File

@@ -2,6 +2,7 @@
// 定时同步用 localStorage 租约保证多窗口只有一个执行(与草稿认领同一思路)。
import { dbPrefGet, dbPrefSet, type GitStatusInfo } from './platform'
import { pathCompareKey } from '../utils/text'
export interface GitSyncSettings {
/** 定时同步间隔(分钟);0=只手动 */
@@ -23,7 +24,7 @@ export function parseGitSyncSettings(raw: string | null): GitSyncSettings {
export async function loadGitSyncSettings(root: string): Promise<GitSyncSettings> {
try {
return parseGitSyncSettings(await dbPrefGet(`gitsync:${root}`))
return parseGitSyncSettings(await dbPrefGet(`gitsync:${pathCompareKey(root)}`))
} catch {
return { ...DEFAULT_GIT_SYNC_SETTINGS }
}
@@ -31,7 +32,7 @@ export async function loadGitSyncSettings(root: string): Promise<GitSyncSettings
export async function saveGitSyncSettings(root: string, settings: GitSyncSettings): Promise<void> {
try {
await dbPrefSet(`gitsync:${root}`, JSON.stringify(settings))
await dbPrefSet(`gitsync:${pathCompareKey(root)}`, JSON.stringify(settings))
} catch {
// 偏好写入失败不挡同步本身
}
@@ -72,7 +73,7 @@ interface Lease {
/** 尝试取得/续租该工作区的定时同步租约;被别的窗口新鲜占用时返回 false。 */
export function acquireGitSyncLease(root: string, windowId: string, now = Date.now()): boolean {
try {
const key = LEASE_PREFIX + root
const key = LEASE_PREFIX + pathCompareKey(root)
const raw = localStorage.getItem(key)
if (raw) {
const lease = JSON.parse(raw) as Lease
@@ -80,7 +81,14 @@ export function acquireGitSyncLease(root: string, windowId: string, now = Date.n
}
const mine: Lease = { win: windowId, ts: now }
localStorage.setItem(key, JSON.stringify(mine))
return true
// 回读确认:双窗口同一瞬间都通过检查时,后写者为准,先写者回读会发现
// 持有者不是自己,放弃本次(避免并发跑 git 撞 index.lock)
try {
const back = JSON.parse(localStorage.getItem(key) ?? 'null') as Lease | null
return back?.win === windowId
} catch {
return true
}
} catch {
return true // 存储不可用时不拦(单窗口场景)
}
@@ -88,7 +96,7 @@ export function acquireGitSyncLease(root: string, windowId: string, now = Date.n
export function releaseGitSyncLease(root: string, windowId: string): void {
try {
const key = LEASE_PREFIX + root
const key = LEASE_PREFIX + pathCompareKey(root)
const raw = localStorage.getItem(key)
if (raw && (JSON.parse(raw) as Lease).win === windowId) localStorage.removeItem(key)
} catch {

View File

@@ -5,6 +5,7 @@ import {
dbPrefGet, dbPrefSet, dbRecoveryList, dbRecoverySave, dbRecoveryDelete,
type RecoveryEntry,
} from './platform'
import { pathsEqual } from '../utils/text'
export type { RecoveryEntry }
@@ -52,7 +53,7 @@ export function addRecent(path: string, name: string): void {
void dbRecentAdd(path, name, Date.now()).catch(() => undefined)
return
}
const list = readLegacyRecent().filter((r) => r.path !== path)
const list = readLegacyRecent().filter((r) => !pathsEqual(r.path, path))
list.unshift({ path, name })
localStorage.setItem(LS_RECENT, JSON.stringify(list.slice(0, 10)))
}
@@ -62,7 +63,7 @@ export function removeRecent(path: string): void {
void dbRecentRemove(path).catch(() => undefined)
return
}
localStorage.setItem(LS_RECENT, JSON.stringify(readLegacyRecent().filter((r) => r.path !== path)))
localStorage.setItem(LS_RECENT, JSON.stringify(readLegacyRecent().filter((r) => !pathsEqual(r.path, path))))
}
export function addWorkspace(path: string): void {
@@ -90,7 +91,9 @@ export async function loadLastWorkspace(): Promise<string | null> {
}
export async function loadUiPref(): Promise<string | null> {
if (!isTauri()) return null
if (!isTauri()) {
try { return localStorage.getItem(LS_UI) } catch { return null }
}
try {
const fromDb = await dbPrefGet('ui')
if (fromDb) {

View File

@@ -47,7 +47,10 @@ export function createWindowClaims(
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
// keys 必须是字符串数组:注册表被写坏(旧版本/外部改动)时整条丢弃,
// 避免 liveClaimedKeys 逐字迭代字符串或对不可迭代值抛错
const keysOk = Array.isArray(entry?.keys) && entry.keys.every((k) => typeof k === 'string')
if (entry && typeof entry.ts === 'number' && keysOk && t - entry.ts <= CLAIM_STALE_MS) out[id] = entry
}
return out
}

7
src/utils/menuPos.ts Normal file
View File

@@ -0,0 +1,7 @@
/** 右键菜单位置夹取:不让菜单溢出窗口右/下边(尺寸为估算值,调用方按条目数给)。 */
export function clampMenuPosition(x: number, y: number, width: number, estHeight: number): { left: number; top: number } {
return {
left: Math.max(4, Math.min(x, window.innerWidth - width - 8)),
top: Math.max(4, Math.min(y, window.innerHeight - estHeight - 8)),
}
}

View File

@@ -1,5 +1,6 @@
// 崩溃恢复的纯逻辑(阶段 4):恢复记录的键、启动时该提供/该清理的划分。
import type { RecoveryEntry } from '../services/platform'
import { pathCompareKey } from './text'
export interface RecoveryTabLike {
id: string
@@ -8,7 +9,7 @@ export interface RecoveryTabLike {
/** 恢复记录的稳定键:文件文档按路径(跨会话一致),未命名文档按标签 id(仅本机延续)。 */
export function recoveryKey(tab: RecoveryTabLike): string {
return tab.path ? `path:${tab.path}` : `untitled:${tab.id}`
return tab.path ? `path:${pathCompareKey(tab.path)}` : `untitled:${tab.id}`
}
export interface RecoveryPlan {

View File

@@ -20,14 +20,27 @@ export interface OutlineItem {
line: number
}
/** 从 Markdown 文本提取大纲。注意跳过代码块内的 # 行。 */
/** 从 Markdown 文本提取大纲。注意跳过代码块内的 # 行。
* 围栏按字符+长度跟踪:四反引号围栏内的三反引号行不算闭合(docs/29)。 */
export function extractOutline(markdown: string): OutlineItem[] {
const items: OutlineItem[] = []
const lines = markdown.split('\n')
let inFence = false
let fenceChar = ''
let fenceLen = 0
lines.forEach((raw, i) => {
if (/^\s*(```|~~~)/.test(raw)) inFence = !inFence
if (inFence) return
const fence = /^\s{0,3}(`{3,}|~{3,})/.exec(raw)
if (fence) {
const run = fence[1]
if (!fenceChar) {
fenceChar = run[0]
fenceLen = run.length
} else if (run[0] === fenceChar && run.length >= fenceLen) {
fenceChar = ''
fenceLen = 0
}
return
}
if (fenceChar) return
const m = /^(#{1,6})\s+(.+?)\s*#*\s*$/.exec(raw)
if (m) items.push({ level: m[1].length, text: m[2].trim(), line: i })
})
@@ -48,21 +61,28 @@ export function normalizeSlashes(path: string): string {
return path.replace(/\\/g, '/')
}
/** 归一化后比较两条路径是否相同。 */
/** 路径比较键:统一斜杠;Windows 风格路径(盘符/UNC)大小写不敏感,一并折叠小写。 */
export function pathCompareKey(path: string): string {
const slashed = normalizeSlashes(path).replace(/\/+$/, '')
return /^[a-zA-Z]:/.test(slashed) || slashed.startsWith('//') ? slashed.toLowerCase() : slashed
}
/** 归一化后比较两条路径是否相同(Windows 路径忽略大小写)。 */
export function pathsEqual(a?: string | null, b?: string | null): boolean {
return Boolean(a && b && normalizeSlashes(a) === normalizeSlashes(b))
return Boolean(a && b && pathCompareKey(a) === pathCompareKey(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}/`)
return pathCompareKey(child).startsWith(`${pathCompareKey(parent)}/`)
}
/** 取父目录(保持原分隔符风格)。 */
/** 取父目录(保持原分隔符风格);盘符根下的文件返回盘符根(C:/ 而非相对的 C:)。 */
export function parentDirOf(path: string): string {
return path.replace(/[\\/][^\\/]*$/, '')
const parent = path.replace(/[\\/][^\\/]*$/, '')
if (/^[a-zA-Z]:$/.test(parent)) return `${parent}/`
if (parent === '' && path.startsWith('/')) return '/'
return parent
}
/** 拼接路径(分隔符跟随 dir 的风格)。 */