feat: cache note content and parsed editor blocks
This commit is contained in:
@@ -153,6 +153,7 @@ import {
|
||||
import type { VaultEntry } from '../types'
|
||||
import { bindVaultConfigStore, resetVaultConfigStore } from '../utils/vaultConfigStore'
|
||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||
import { clearParsedNoteBlockCache } from '../hooks/editorParsedBlockCache'
|
||||
|
||||
type EditorComponentProps = ComponentProps<typeof Editor>
|
||||
|
||||
@@ -235,6 +236,7 @@ describe('Editor', () => {
|
||||
beforeEach(() => {
|
||||
blockNoteCreation.options = []
|
||||
blockNoteViewState.onChange = null
|
||||
clearParsedNoteBlockCache()
|
||||
})
|
||||
|
||||
it('shows empty state when no tabs are open', () => {
|
||||
|
||||
@@ -17,6 +17,7 @@ import { useDragRegion } from '../hooks/useDragRegion'
|
||||
import { formatShortcutDisplay } from '../hooks/appCommandCatalog'
|
||||
import { EditorRightPanel } from './EditorRightPanel'
|
||||
import { EditorContent } from './EditorContent'
|
||||
import { EditorMemoryProbe } from './EditorMemoryProbe'
|
||||
import { FilePreview } from './FilePreview'
|
||||
import { schema } from './editorSchema'
|
||||
import type { RawEditorFindRequest } from './RawEditorFindBar'
|
||||
@@ -513,6 +514,7 @@ function EditorLayout({
|
||||
locale={locale}
|
||||
/>
|
||||
</div>
|
||||
<EditorMemoryProbe entries={entries} vaultPath={vaultPath} locale={locale} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -538,7 +540,6 @@ export const Editor = memo(function Editor(props: EditorProps) {
|
||||
isConflicted, onKeepMine, onKeepTheirs,
|
||||
flushPendingEditorContentRef, flushPendingRawContentRef, findInNoteRef, locale,
|
||||
} = props
|
||||
|
||||
const {
|
||||
editor, activeTab, rawLatestContentRef, rawModeContent,
|
||||
rawMode, diffMode, diffContent, diffLoading,
|
||||
@@ -560,7 +561,6 @@ export const Editor = memo(function Editor(props: EditorProps) {
|
||||
handleToggleRawExclusive,
|
||||
rawMode,
|
||||
})
|
||||
|
||||
useRegisterEditorContentFlushes({
|
||||
activeTab,
|
||||
flushPendingEditorChange,
|
||||
@@ -570,7 +570,6 @@ export const Editor = memo(function Editor(props: EditorProps) {
|
||||
onContentChange,
|
||||
flushPendingRawContentRef,
|
||||
})
|
||||
|
||||
return (
|
||||
<EditorLayout
|
||||
tabs={tabs}
|
||||
|
||||
82
src/components/EditorMemoryProbe.tsx
Normal file
82
src/components/EditorMemoryProbe.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import { useEffect } from 'react'
|
||||
import type { AppLocale } from '../lib/i18n'
|
||||
import type { VaultEntry } from '../types'
|
||||
import { HiddenEditorMemoryProbe } from './HiddenEditorMemoryProbe'
|
||||
import type { EditorMemoryProbeApi } from './editorMemoryProbeTypes'
|
||||
import { useEditorMemoryProbeController } from './useEditorMemoryProbeController'
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__tolariaEditorMemoryProbe?: EditorMemoryProbeApi
|
||||
}
|
||||
}
|
||||
|
||||
function useEditorMemoryProbeBridge(api: EditorMemoryProbeApi): void {
|
||||
useEffect(() => {
|
||||
if (!import.meta.env.DEV) return
|
||||
window.__tolariaEditorMemoryProbe = api
|
||||
return () => {
|
||||
if (window.__tolariaEditorMemoryProbe?.run === api.run) {
|
||||
delete window.__tolariaEditorMemoryProbe
|
||||
}
|
||||
}
|
||||
}, [api])
|
||||
}
|
||||
|
||||
function useEditorMemoryProbeShortcut(runAndCopy: EditorMemoryProbeApi['runAndCopy']): void {
|
||||
useEffect(() => {
|
||||
if (!import.meta.env.DEV) return
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (!event.metaKey || !event.altKey || !event.shiftKey || event.code !== 'KeyM') return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
void runAndCopy()
|
||||
}
|
||||
window.addEventListener('keydown', handleKeyDown, { capture: true })
|
||||
return () => window.removeEventListener('keydown', handleKeyDown, { capture: true })
|
||||
}, [runAndCopy])
|
||||
}
|
||||
|
||||
export function EditorMemoryProbe({
|
||||
entries,
|
||||
locale,
|
||||
vaultPath,
|
||||
}: {
|
||||
entries: VaultEntry[]
|
||||
locale?: AppLocale
|
||||
vaultPath?: string
|
||||
}) {
|
||||
const controller = useEditorMemoryProbeController(entries)
|
||||
useEditorMemoryProbeBridge(controller)
|
||||
useEditorMemoryProbeShortcut(controller.runAndCopy)
|
||||
|
||||
if (!import.meta.env.DEV || controller.targets.length === 0) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
height: 1,
|
||||
left: -100_000,
|
||||
opacity: 0,
|
||||
overflow: 'hidden',
|
||||
pointerEvents: 'none',
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
width: 1,
|
||||
zIndex: -1,
|
||||
}}
|
||||
>
|
||||
{controller.targets.map(target => (
|
||||
<HiddenEditorMemoryProbe
|
||||
key={target.entry.path}
|
||||
entries={entries}
|
||||
locale={locale}
|
||||
onReady={controller.handleReady}
|
||||
target={target}
|
||||
vaultPath={vaultPath}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
74
src/components/HiddenEditorMemoryProbe.tsx
Normal file
74
src/components/HiddenEditorMemoryProbe.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useCreateBlockNote } from '@blocknote/react'
|
||||
import { uploadImageFile } from '../hooks/useImageDrop'
|
||||
import { useEditorTabSwap } from '../hooks/useEditorTabSwap'
|
||||
import { RUNTIME_STYLE_NONCE } from '../lib/runtimeStyleNonce'
|
||||
import type { AppLocale } from '../lib/i18n'
|
||||
import type { VaultEntry } from '../types'
|
||||
import { createArrowLigaturesExtension } from './arrowLigaturesExtension'
|
||||
import { createMathInputExtension } from './mathInputExtension'
|
||||
import { schema } from './editorSchema'
|
||||
import type { ProbeTarget } from './editorMemoryProbeTypes'
|
||||
import { SingleEditorView } from './SingleEditorView'
|
||||
|
||||
function useProbeEditor(target: ProbeTarget, vaultPath?: string) {
|
||||
const editor = useCreateBlockNote({
|
||||
schema,
|
||||
uploadFile: (file: File) => uploadImageFile(file, vaultPath),
|
||||
_tiptapOptions: { injectNonce: RUNTIME_STYLE_NONCE },
|
||||
extensions: [createArrowLigaturesExtension(), createMathInputExtension()],
|
||||
})
|
||||
useEditorTabSwap({
|
||||
tabs: [{ entry: target.entry, content: target.content }],
|
||||
activeTabPath: target.entry.path,
|
||||
editor,
|
||||
rawMode: false,
|
||||
vaultPath,
|
||||
})
|
||||
return editor
|
||||
}
|
||||
|
||||
function useProbeReadySignal(target: ProbeTarget, onReady: (path: string) => void): void {
|
||||
useEffect(() => {
|
||||
const handleSwap = (event: Event) => {
|
||||
const detail = (event as CustomEvent<{ path?: string }>).detail
|
||||
if (detail?.path === target.entry.path) onReady(target.entry.path)
|
||||
}
|
||||
window.addEventListener('laputa:editor-tab-swapped', handleSwap)
|
||||
return () => window.removeEventListener('laputa:editor-tab-swapped', handleSwap)
|
||||
}, [onReady, target.entry.path])
|
||||
}
|
||||
|
||||
export function HiddenEditorMemoryProbe({
|
||||
entries,
|
||||
locale,
|
||||
onReady,
|
||||
target,
|
||||
vaultPath,
|
||||
}: {
|
||||
entries: VaultEntry[]
|
||||
locale?: AppLocale
|
||||
onReady: (path: string) => void
|
||||
target: ProbeTarget
|
||||
vaultPath?: string
|
||||
}) {
|
||||
const editor = useProbeEditor(target, vaultPath)
|
||||
useProbeReadySignal(target, onReady)
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
data-editor-memory-probe-path={target.entry.path}
|
||||
style={{ height: 900, overflow: 'hidden', width: 900 }}
|
||||
>
|
||||
<SingleEditorView
|
||||
editor={editor}
|
||||
entries={entries}
|
||||
onNavigateWikilink={() => {}}
|
||||
editable={false}
|
||||
vaultPath={vaultPath}
|
||||
locale={locale}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -328,7 +328,7 @@ type NoteItemProps = {
|
||||
allEntries?: VaultEntry[]
|
||||
displayPropsOverride?: string[] | null
|
||||
onClickNote: (entry: VaultEntry, e: ReactMouseEvent) => void
|
||||
onPrefetch?: (path: string) => void
|
||||
onPrefetch?: (entry: VaultEntry) => void
|
||||
onContextMenu?: (entry: VaultEntry, e: ReactMouseEvent) => void
|
||||
}
|
||||
|
||||
@@ -409,7 +409,7 @@ function resolveNoteItemSurfaceProps({
|
||||
style: resolveNoteItemSurfaceStyle({ isUnavailableBinary, isSelected, isMultiSelected, typeColor, typeLightColor }),
|
||||
onClick: createNoteItemClickHandler(entry, isUnavailableBinary, onClickNote),
|
||||
onContextMenu: onContextMenu ? (event) => onContextMenu(entry, event) : undefined,
|
||||
onMouseEnter: entry.fileKind !== 'binary' && onPrefetch ? () => onPrefetch(entry.path) : undefined,
|
||||
onMouseEnter: entry.fileKind !== 'binary' && onPrefetch ? () => onPrefetch(entry) : undefined,
|
||||
testId: resolveNoteItemTestId({ isMultiSelected, previewKind, isUnavailableBinary }),
|
||||
title: resolveNoteItemTitle({ previewKind, isUnavailableBinary }),
|
||||
}
|
||||
|
||||
@@ -115,6 +115,6 @@ describe('NoteList keyboard activation', () => {
|
||||
|
||||
fireEvent.mouseEnter(noteRow!)
|
||||
|
||||
expect(prefetchSpy).toHaveBeenCalledWith(mockEntries[1].path)
|
||||
expect(prefetchSpy).toHaveBeenCalledWith(mockEntries[1])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -361,7 +361,10 @@ function EditorCanvas({
|
||||
if (!showEditor) return null
|
||||
|
||||
return (
|
||||
<EditorFindScope className="editor-scroll-area" style={cssVars as React.CSSProperties}>
|
||||
<EditorFindScope
|
||||
className="editor-scroll-area"
|
||||
style={cssVars as React.CSSProperties}
|
||||
>
|
||||
<div className="editor-content-wrapper">
|
||||
<SingleEditorView
|
||||
editor={editor}
|
||||
|
||||
82
src/components/editorMemoryProbeRuntime.ts
Normal file
82
src/components/editorMemoryProbeRuntime.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { isTauri } from '../mock-tauri'
|
||||
import type { VaultEntry } from '../types'
|
||||
import type {
|
||||
ProbeResult,
|
||||
ProbeRunOptions,
|
||||
ProbeTarget,
|
||||
ProbeTargetSummary,
|
||||
ProcessMemorySnapshot,
|
||||
} from './editorMemoryProbeTypes'
|
||||
|
||||
export const DEFAULT_PROBE_LIMIT = 5
|
||||
export const DEFAULT_PROBE_BATCH_SIZE = 1
|
||||
export const DEFAULT_PROBE_SETTLE_MS = 700
|
||||
export const PROBE_READY_TIMEOUT_MS = 30_000
|
||||
|
||||
function wait(ms: number): Promise<void> {
|
||||
return new Promise(resolve => window.setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
function contentBytes(content: string): number {
|
||||
return new TextEncoder().encode(content).byteLength
|
||||
}
|
||||
|
||||
export function summarizeTarget({ entry, content }: ProbeTarget): ProbeTargetSummary {
|
||||
return {
|
||||
path: entry.path,
|
||||
fileSize: entry.fileSize,
|
||||
contentBytes: contentBytes(content),
|
||||
lineCount: content.split('\n').length,
|
||||
}
|
||||
}
|
||||
|
||||
export function memoryDelta(
|
||||
snapshot: ProcessMemorySnapshot | null,
|
||||
baseline: ProcessMemorySnapshot | null,
|
||||
): number | null {
|
||||
if (!snapshot || !baseline) return null
|
||||
return snapshot.totalRssBytes - baseline.totalRssBytes
|
||||
}
|
||||
|
||||
export function selectProbeEntries(entries: VaultEntry[], options: ProbeRunOptions): VaultEntry[] {
|
||||
const markdownEntries = entries.filter(entry => (entry.fileKind ?? 'markdown') === 'markdown')
|
||||
if (options.paths?.length) {
|
||||
const wantedPaths = new Set(options.paths)
|
||||
return markdownEntries.filter(entry => wantedPaths.has(entry.path))
|
||||
}
|
||||
|
||||
return [...markdownEntries]
|
||||
.sort((left, right) => right.fileSize - left.fileSize)
|
||||
.slice(0, options.limit ?? DEFAULT_PROBE_LIMIT)
|
||||
}
|
||||
|
||||
export function resolveMountCounts(targetCount: number, batchSize: number): number[] {
|
||||
const counts: number[] = []
|
||||
for (let count = batchSize; count < targetCount; count += batchSize) {
|
||||
counts.push(count)
|
||||
}
|
||||
if (targetCount > 0) counts.push(targetCount)
|
||||
return counts
|
||||
}
|
||||
|
||||
export async function readMemorySnapshot(): Promise<ProcessMemorySnapshot | null> {
|
||||
if (!isTauri()) return null
|
||||
return invoke<ProcessMemorySnapshot>('get_process_memory_snapshot')
|
||||
}
|
||||
|
||||
export async function loadProbeTarget(entry: VaultEntry): Promise<ProbeTarget> {
|
||||
const content = await invoke<string>('get_note_content', { path: entry.path })
|
||||
return { entry, content }
|
||||
}
|
||||
|
||||
export async function copyProbeResult(result: ProbeResult): Promise<void> {
|
||||
if (!isTauri()) return
|
||||
await invoke('copy_text_to_clipboard', {
|
||||
text: JSON.stringify(result, null, 2),
|
||||
})
|
||||
}
|
||||
|
||||
export function settleAfterMount(settleMs: number): Promise<void> {
|
||||
return wait(settleMs).then(() => new Promise<void>(resolve => requestAnimationFrame(() => resolve())))
|
||||
}
|
||||
61
src/components/editorMemoryProbeTypes.ts
Normal file
61
src/components/editorMemoryProbeTypes.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import type { VaultEntry } from '../types'
|
||||
|
||||
export interface ProbeTarget {
|
||||
entry: VaultEntry
|
||||
content: string
|
||||
}
|
||||
|
||||
export interface ProcessMemoryEntry {
|
||||
pid: number
|
||||
parentPid: number
|
||||
rssBytes: number
|
||||
role: string
|
||||
command: string
|
||||
}
|
||||
|
||||
export interface ProcessMemorySnapshot {
|
||||
currentPid: number
|
||||
totalRssBytes: number
|
||||
entries: ProcessMemoryEntry[]
|
||||
}
|
||||
|
||||
export interface ProbeRunOptions {
|
||||
paths?: string[]
|
||||
limit?: number
|
||||
batchSize?: number
|
||||
settleMs?: number
|
||||
}
|
||||
|
||||
export interface ProbeStep {
|
||||
mountedCount: number
|
||||
mountedPaths: string[]
|
||||
snapshot: ProcessMemorySnapshot | null
|
||||
deltaBytes: number | null
|
||||
}
|
||||
|
||||
export interface ProbeTargetSummary {
|
||||
path: string
|
||||
fileSize: number
|
||||
contentBytes: number
|
||||
lineCount: number
|
||||
}
|
||||
|
||||
export interface ProbeResult {
|
||||
targets: ProbeTargetSummary[]
|
||||
baseline: ProcessMemorySnapshot | null
|
||||
afterContentLoad: ProcessMemorySnapshot | null
|
||||
contentLoadDeltaBytes: number | null
|
||||
steps: ProbeStep[]
|
||||
}
|
||||
|
||||
export interface ProbeWaiter {
|
||||
paths: Set<string>
|
||||
resolve: () => void
|
||||
timer: number
|
||||
}
|
||||
|
||||
export interface EditorMemoryProbeApi {
|
||||
run: (options?: ProbeRunOptions) => Promise<ProbeResult>
|
||||
runAndCopy: (options?: ProbeRunOptions) => Promise<ProbeResult>
|
||||
clear: () => void
|
||||
}
|
||||
@@ -63,7 +63,7 @@ vi.mock('../../hooks/useNoteListKeyboard', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('../../hooks/useTabManagement', () => ({
|
||||
prefetchNoteContent: (path: string) => prefetchNoteContentMock(path),
|
||||
prefetchNoteContent: (entry: VaultEntry) => prefetchNoteContentMock(entry),
|
||||
}))
|
||||
|
||||
vi.mock('./noteListUtils', async () => {
|
||||
@@ -475,8 +475,8 @@ describe('noteListHooks extra', () => {
|
||||
expect(onOpenDeletedNote).toHaveBeenCalledWith(deletedEntry)
|
||||
expect(onReplaceActiveTab).toHaveBeenCalledWith(liveEntry)
|
||||
expect(onAutoTriggerDiff).toHaveBeenCalledOnce()
|
||||
expect(prefetchNoteContentMock).toHaveBeenCalledWith(liveEntry.path)
|
||||
expect(prefetchNoteContentMock).not.toHaveBeenCalledWith(imageEntry.path)
|
||||
expect(prefetchNoteContentMock).toHaveBeenCalledWith(liveEntry)
|
||||
expect(prefetchNoteContentMock).not.toHaveBeenCalledWith(imageEntry)
|
||||
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
@@ -1046,7 +1046,7 @@ function useKeyboardInteractionState({
|
||||
}, [onOpenDeletedNote, onReplaceActiveTab])
|
||||
|
||||
const handleKeyboardPrefetch = useCallback((entry: VaultEntry) => {
|
||||
if (canPrefetchEntryContent(entry)) prefetchNoteContent(entry.path)
|
||||
if (canPrefetchEntryContent(entry)) prefetchNoteContent(entry)
|
||||
}, [])
|
||||
|
||||
const handleNeighborhoodOpen = useCallback(async (entry: VaultEntry) => {
|
||||
|
||||
@@ -32,8 +32,10 @@ import { useChangesContextMenu } from './NoteListChangesMenu'
|
||||
import { addNoteListSearchToggleListener, dispatchNoteListSearchAvailability } from '../../utils/noteListSearchEvents'
|
||||
|
||||
type EntitySelection = Extract<SidebarSelection, { kind: 'entity' }>
|
||||
const LIKELY_NEXT_PRELOAD_LIMIT = 8
|
||||
const ADJACENT_PRELOAD_RADIUS = 2
|
||||
const LIKELY_NEXT_PRELOAD_LIMIT = 6
|
||||
const ADJACENT_PRELOAD_RADIUS = 3
|
||||
const LIKELY_NEXT_PRELOAD_START_DELAY_MS = 350
|
||||
const LIKELY_NEXT_PRELOAD_STEP_DELAY_MS = 180
|
||||
|
||||
function useViewFlags(selection: SidebarSelection) {
|
||||
const isSectionGroup = selection.kind === 'sectionGroup'
|
||||
@@ -58,7 +60,13 @@ function likelyNextPreloadEntries(entries: VaultEntry[], selectedNotePath: strin
|
||||
: Math.min(entries.length, LIKELY_NEXT_PRELOAD_LIMIT)
|
||||
return entries
|
||||
.slice(start, end)
|
||||
.filter((entry) => !isDeletedNoteEntry(entry) && entry.fileKind !== 'binary')
|
||||
.map((entry, offset) => ({ entry, index: start + offset }))
|
||||
.sort((left, right) => {
|
||||
if (selectedIndex < 0) return 0
|
||||
return Math.abs(left.index - selectedIndex) - Math.abs(right.index - selectedIndex)
|
||||
})
|
||||
.map(({ entry }) => entry)
|
||||
.filter((entry) => entry.path !== selectedNotePath && !isDeletedNoteEntry(entry) && entry.fileKind !== 'binary')
|
||||
.slice(0, LIKELY_NEXT_PRELOAD_LIMIT)
|
||||
}
|
||||
|
||||
@@ -67,11 +75,23 @@ function useLikelyNextPreload(entries: VaultEntry[], selectedNotePath: string |
|
||||
const candidates = likelyNextPreloadEntries(entries, selectedNotePath)
|
||||
if (candidates.length === 0) return
|
||||
|
||||
const timer = window.setTimeout(() => {
|
||||
for (const entry of candidates) prefetchNoteContent(entry.path)
|
||||
}, 100)
|
||||
let stepTimer: number | null = null
|
||||
let candidateIndex = 0
|
||||
const startTimer = window.setTimeout(() => {
|
||||
const preloadNext = () => {
|
||||
const entry = candidates[candidateIndex]
|
||||
if (!entry) return
|
||||
candidateIndex += 1
|
||||
prefetchNoteContent(entry)
|
||||
stepTimer = window.setTimeout(preloadNext, LIKELY_NEXT_PRELOAD_STEP_DELAY_MS)
|
||||
}
|
||||
preloadNext()
|
||||
}, LIKELY_NEXT_PRELOAD_START_DELAY_MS)
|
||||
|
||||
return () => window.clearTimeout(timer)
|
||||
return () => {
|
||||
window.clearTimeout(startTimer)
|
||||
if (stepTimer !== null) window.clearTimeout(stepTimer)
|
||||
}
|
||||
}, [entries, selectedNotePath])
|
||||
}
|
||||
|
||||
|
||||
131
src/components/useEditorMemoryProbeController.ts
Normal file
131
src/components/useEditorMemoryProbeController.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import type { VaultEntry } from '../types'
|
||||
import type {
|
||||
ProbeResult,
|
||||
ProbeRunOptions,
|
||||
ProbeStep,
|
||||
ProbeTarget,
|
||||
ProbeWaiter,
|
||||
} from './editorMemoryProbeTypes'
|
||||
import {
|
||||
copyProbeResult,
|
||||
DEFAULT_PROBE_BATCH_SIZE,
|
||||
DEFAULT_PROBE_SETTLE_MS,
|
||||
loadProbeTarget,
|
||||
memoryDelta,
|
||||
readMemorySnapshot,
|
||||
resolveMountCounts,
|
||||
selectProbeEntries,
|
||||
settleAfterMount,
|
||||
summarizeTarget,
|
||||
PROBE_READY_TIMEOUT_MS,
|
||||
} from './editorMemoryProbeRuntime'
|
||||
|
||||
function createProbeStep(
|
||||
mountedTargets: ProbeTarget[],
|
||||
snapshot: Awaited<ReturnType<typeof readMemorySnapshot>>,
|
||||
baseline: Awaited<ReturnType<typeof readMemorySnapshot>>,
|
||||
): ProbeStep {
|
||||
return {
|
||||
mountedCount: mountedTargets.length,
|
||||
mountedPaths: mountedTargets.map(target => target.entry.path),
|
||||
snapshot,
|
||||
deltaBytes: memoryDelta(snapshot, baseline),
|
||||
}
|
||||
}
|
||||
|
||||
function useProbeReadiness() {
|
||||
const readyPathsRef = useRef(new Set<string>())
|
||||
const waiterRef = useRef<ProbeWaiter | null>(null)
|
||||
|
||||
const resolveWaiterIfReady = useCallback(() => {
|
||||
const waiter = waiterRef.current
|
||||
if (!waiter) return
|
||||
for (const path of waiter.paths) {
|
||||
if (!readyPathsRef.current.has(path)) return
|
||||
}
|
||||
|
||||
window.clearTimeout(waiter.timer)
|
||||
waiterRef.current = null
|
||||
waiter.resolve()
|
||||
}, [])
|
||||
|
||||
const handleReady = useCallback((path: string) => {
|
||||
readyPathsRef.current.add(path)
|
||||
resolveWaiterIfReady()
|
||||
}, [resolveWaiterIfReady])
|
||||
|
||||
const waitForReadyPaths = useCallback((paths: string[]) => {
|
||||
return new Promise<void>((resolve) => {
|
||||
const unresolvedPaths = paths.filter(path => !readyPathsRef.current.has(path))
|
||||
if (unresolvedPaths.length === 0) {
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
|
||||
const timer = window.setTimeout(() => {
|
||||
waiterRef.current = null
|
||||
console.warn('[memory-probe] Timed out waiting for hidden editors:', unresolvedPaths)
|
||||
resolve()
|
||||
}, PROBE_READY_TIMEOUT_MS)
|
||||
waiterRef.current = { paths: new Set(paths), resolve, timer }
|
||||
})
|
||||
}, [])
|
||||
|
||||
const clearReadiness = useCallback(() => {
|
||||
if (waiterRef.current) {
|
||||
window.clearTimeout(waiterRef.current.timer)
|
||||
waiterRef.current = null
|
||||
}
|
||||
readyPathsRef.current.clear()
|
||||
}, [])
|
||||
|
||||
return { clearReadiness, handleReady, waitForReadyPaths }
|
||||
}
|
||||
|
||||
export function useEditorMemoryProbeController(entries: VaultEntry[]) {
|
||||
const [targets, setTargets] = useState<ProbeTarget[]>([])
|
||||
const { clearReadiness, handleReady, waitForReadyPaths } = useProbeReadiness()
|
||||
|
||||
const clear = useCallback(() => {
|
||||
clearReadiness()
|
||||
setTargets([])
|
||||
}, [clearReadiness])
|
||||
|
||||
const run = useCallback(async (options: ProbeRunOptions = {}): Promise<ProbeResult> => {
|
||||
clear()
|
||||
await settleAfterMount(options.settleMs ?? DEFAULT_PROBE_SETTLE_MS)
|
||||
const baseline = await readMemorySnapshot()
|
||||
const selectedEntries = selectProbeEntries(entries, options)
|
||||
const loadedTargets = await Promise.all(selectedEntries.map(loadProbeTarget))
|
||||
const afterContentLoad = await readMemorySnapshot()
|
||||
const batchSize = Math.max(1, options.batchSize ?? DEFAULT_PROBE_BATCH_SIZE)
|
||||
const settleMs = options.settleMs ?? DEFAULT_PROBE_SETTLE_MS
|
||||
const steps: ProbeStep[] = []
|
||||
|
||||
for (const count of resolveMountCounts(loadedTargets.length, batchSize)) {
|
||||
const mountedTargets = loadedTargets.slice(0, count)
|
||||
setTargets(mountedTargets)
|
||||
await waitForReadyPaths(mountedTargets.map(target => target.entry.path))
|
||||
await settleAfterMount(settleMs)
|
||||
steps.push(createProbeStep(mountedTargets, await readMemorySnapshot(), baseline))
|
||||
}
|
||||
|
||||
return {
|
||||
targets: loadedTargets.map(summarizeTarget),
|
||||
baseline,
|
||||
afterContentLoad,
|
||||
contentLoadDeltaBytes: memoryDelta(afterContentLoad, baseline),
|
||||
steps,
|
||||
}
|
||||
}, [clear, entries, waitForReadyPaths])
|
||||
|
||||
const runAndCopy = useCallback(async (options: ProbeRunOptions = {}) => {
|
||||
const result = await run(options)
|
||||
await copyProbeResult(result)
|
||||
console.info('[memory-probe] Result copied to clipboard', result)
|
||||
return result
|
||||
}, [run])
|
||||
|
||||
return { clear, handleReady, run, runAndCopy, targets }
|
||||
}
|
||||
Reference in New Issue
Block a user