From dbd43102e142a1816496589a99da86edcdb35d95 Mon Sep 17 00:00:00 2001 From: hasanyildiz Date: Thu, 29 Oct 2026 10:41:42 +0000 Subject: [PATCH 1/2] Release memory held by search results, caches and closed previews - Find All in the document or open documents keeps only the hit lines, like the folder search, instead of a full copy of every document - The main process remembers a hash of the last written State.json instead of the whole serialized state - The search cache and match highlight drop the previous editor session when the editor reloads, and the cache is released when search closes - Mark layers and smart highlight share one search document instead of one copy per style - The preview tool window removes its editor listeners and pending render when it closes - A Find in Files worker that exits without reporting completes its search instead of staying registered --- src/ipc/SearchHandlers.ts | 1 + src/react/OpenDocuments.ts | 4 ++-- src/react/components/ToolWindow.tsx | 2 +- .../components/editor/plugins/PreviewPlugin.ts | 16 ++++++---------- src/react/components/editor/plugins/index.ts | 2 +- src/react/hooks/useEditorSearch.ts | 3 +++ src/react/hooks/useMarks.ts | 16 +++++++++++----- src/utils/EditorUtils.ts | 8 +++++--- src/utils/SearchResults.ts | 8 +++++--- src/utils/__tests__/SearchResults.test.ts | 12 +++++++++++- 10 files changed, 46 insertions(+), 26 deletions(-) diff --git a/src/ipc/SearchHandlers.ts b/src/ipc/SearchHandlers.ts index 6d20f7e..deedbe8 100644 --- a/src/ipc/SearchHandlers.ts +++ b/src/ipc/SearchHandlers.ts @@ -54,6 +54,7 @@ export function registerSearchHandlers() { searches.set(request.id, worker); worker.on("message", report); worker.on("error", error => report(failedProgress(request, summary, error))); + worker.on("exit", code => searches.has(request.id) && report(failedProgress(request, summary, new Error(`the search stopped unexpectedly (exit code ${code})`)))); })); handle(EventType.CANCEL_FIND_IN_FILES, (event, data) => { diff --git a/src/react/OpenDocuments.ts b/src/react/OpenDocuments.ts index 287fd54..dd1cde9 100644 --- a/src/react/OpenDocuments.ts +++ b/src/react/OpenDocuments.ts @@ -4,7 +4,7 @@ import {EolType, toEditorText} from "../domain/Encoding"; import {SearchQuery, SearchTarget} from "../domain/Search"; import {FolderSearch, isSearchedFile, parseFileFilters, relativePath} from "../domain/FindInFiles"; import {createSearchDocument, offsetToPosition, replaceMatches, SearchResult, searchText, splitLines} from "../utils/TextSearch"; -import {DocumentHits, SearchRun} from "../utils/SearchResults"; +import {compactHits, DocumentHits, SearchRun, toCompactDocument} from "../utils/SearchResults"; import {appendReplacement, createReplacementDeltas} from "../utils/UndoHistory"; import {normalizeRows, shiftRows} from "../domain/Bookmarks"; import {refreshIsChanged} from "../utils/TabUtils"; @@ -36,7 +36,7 @@ export function findInDocuments(source: SearchSource, query: SearchQuery, target const result = searchTab(source, tab, query); if (result.error) return {error: result.error}; - if (result.matches.length > 0) documents.push({tabId: tab.id || "", name: documentName(source, tab, target), file: tab.file, document: result.document, hits: result.matches}); + if (result.matches.length > 0) documents.push({tabId: tab.id || "", name: documentName(source, tab, target), file: tab.file, ...toCompactDocument(compactHits(result.document, result.matches))}); } return { diff --git a/src/react/components/ToolWindow.tsx b/src/react/components/ToolWindow.tsx index d08757f..2d6afef 100644 --- a/src/react/components/ToolWindow.tsx +++ b/src/react/components/ToolWindow.tsx @@ -24,7 +24,7 @@ export default function ToolWindow({tool, context, width, onResize, onClose}: { shadow.innerHTML = DOMPurify.sanitize(tool.getToolbarWindowContent()); - tool.onContentMount?.(shadow, context); + return tool.onContentMount?.(shadow, context); }, []); return ( diff --git a/src/react/components/editor/plugins/PreviewPlugin.ts b/src/react/components/editor/plugins/PreviewPlugin.ts index ce58891..0ee3565 100644 --- a/src/react/components/editor/plugins/PreviewPlugin.ts +++ b/src/react/components/editor/plugins/PreviewPlugin.ts @@ -56,22 +56,12 @@ export default class PreviewPlugin extends BasePlugin { } }; - const unsubscribeAll = () => { - clearTimeout(timer); - unsubscribeContent?.(); - unsubscribeLanguage?.(); - }; - const unsubscribeContent = this.editor?.onChange(() => { - if (!parent.host.isConnected) return unsubscribeAll(); - clearTimeout(timer); timer = setTimeout(render, LIVE_PREVIEW_DELAY); }); const unsubscribeLanguage = this.editor?.onLanguageChange(() => { - if (!parent.host.isConnected) return unsubscribeAll(); - language = this.editor?.getLanguage(); clearTimeout(timer); render(); @@ -94,6 +84,12 @@ export default class PreviewPlugin extends BasePlugin { refreshButton.addEventListener("click", render); render(); + + return () => { + clearTimeout(timer); + unsubscribeContent?.(); + unsubscribeLanguage?.(); + }; }, getToolbarWindowContent: () => { return ` diff --git a/src/react/components/editor/plugins/index.ts b/src/react/components/editor/plugins/index.ts index 01037e8..a49a8a9 100644 --- a/src/react/components/editor/plugins/index.ts +++ b/src/react/components/editor/plugins/index.ts @@ -36,6 +36,6 @@ export interface ToolWindowContext { export interface ToolbarMenuItem { label: string, icon: string, - onContentMount?: (parent: ShadowRoot, context: ToolWindowContext) => void, + onContentMount?: (parent: ShadowRoot, context: ToolWindowContext) => (() => void) | void, getToolbarWindowContent: () => string } \ No newline at end of file diff --git a/src/react/hooks/useEditorSearch.ts b/src/react/hooks/useEditorSearch.ts index f33f09e..403db1c 100644 --- a/src/react/hooks/useEditorSearch.ts +++ b/src/react/hooks/useEditorSearch.ts @@ -120,6 +120,8 @@ export function useEditorSearch(editorRef: MutableRefObject { removeOverriddenCommands(editor); removeScopeMarker.current = undefined; + highlight.current = undefined; + cache.current = undefined; setScope(undefined); editor.session.on("change", () => { @@ -134,6 +136,7 @@ export function useEditorSearch(editorRef: MutableRefObject { const editor = editorRef.current; + if (!isOpen) cache.current = undefined; if (!editor) return; if (isOpen) showHighlight(editor); diff --git a/src/react/hooks/useMarks.ts b/src/react/hooks/useMarks.ts index 2b2a0a6..12b6137 100644 --- a/src/react/hooks/useMarks.ts +++ b/src/react/hooks/useMarks.ts @@ -24,9 +24,9 @@ import { readWholeWord, TOKEN_STYLES, } from "../../domain/Marks"; -import {offsetToPosition, SearchResult} from "../../utils/TextSearch"; +import {offsetToPosition, SearchDocument, SearchResult, searchText} from "../../utils/TextSearch"; import {logError} from "../../utils/Log"; -import {addMatchLayers, goToTarget, MatchLayer, readSearchTerm, searchSession} from "../EditorSearch"; +import {addMatchLayers, goToTarget, MatchLayer, readSearchDocument, readSearchTerm, searchSession} from "../EditorSearch"; import {markStateChanged} from "../StateChangeTracker"; import {useEditorContext} from "../components/editor/EditorContext"; import {EditorCommandHandlers} from "./useEditorCommands"; @@ -51,6 +51,7 @@ export function useMarks(editorRef: MutableRefObject) { const bookmarks = useRef>([]); const smartHighlight = useRef(); const results = useRef(new Map()); + const searchDocument = useRef(); const isSmartHighlightOn = useRef(settings.search.smartHighlight); isSmartHighlightOn.current = settings.search.smartHighlight; @@ -66,13 +67,18 @@ export function useMarks(editorRef: MutableRefObject) { tab.current.state = {...tab.current.state, ...changes}; }; + const forgetResults = () => { + results.current = new Map(); + searchDocument.current = undefined; + }; + const readLayers = (session: Ace.EditSession): Array => { const previous = results.current; const next = new Map(); const layerOf = (query: SearchQuery, className: string): MatchLayer => { const key = JSON.stringify(query); - const result = previous.get(key) ?? next.get(key) ?? searchSession(session, query); + const result = previous.get(key) ?? next.get(key) ?? searchText(searchDocument.current ??= readSearchDocument(session), query); next.set(key, result); @@ -118,7 +124,7 @@ export function useMarks(editorRef: MutableRefObject) { const session = editor.session; tab.current = loadedTab; - results.current = new Map(); + forgetResults(); smartHighlight.current = undefined; rules.current = normalizeMarkRules(loadedTab.state?.marks); bookmarks.current = []; @@ -128,7 +134,7 @@ export function useMarks(editorRef: MutableRefObject) { session.on("change", delta => { const shifted = shiftRows(bookmarks.current, delta); - results.current = new Map(); + forgetResults(); if (shifted !== bookmarks.current) setBookmarks(session, shifted); }); diff --git a/src/utils/EditorUtils.ts b/src/utils/EditorUtils.ts index 1af4bd0..90fce59 100644 --- a/src/utils/EditorUtils.ts +++ b/src/utils/EditorUtils.ts @@ -1,4 +1,5 @@ import path from "node:path"; +import {createHash} from "node:crypto"; import {backupFile, getFileSize, isBinaryFile, isFileExists, readFileAsync, readFileBytes, writeFileAtomic} from "./FileUtils"; import {EditorTab} from "../domain/EditorTab"; import {documentHash, hash, shortenTabName} from "./TextUtils"; @@ -106,7 +107,7 @@ function normalizeTempTab(tab: EditorTab) { if (tab.content !== undefined) tab.content = toEditorText(tab.content, tab.eol); } -let lastWrittenState: string | undefined; +let lastWrittenStateHash: string | undefined; export function writeEditorState(state: EditorState, discardUnsaved: boolean) { state.tabs.forEach(t => { @@ -119,12 +120,13 @@ export function writeEditorState(state: EditorState, discardUnsaved: boolean) { }); const serialized = JSON.stringify(state, null, 4); + const stateHash = createHash("sha256").update(serialized).digest("hex"); - if (serialized === lastWrittenState) return; + if (stateHash === lastWrittenStateHash) return; writeFileAtomic(Environment.getStateFilePath(), serialized); - lastWrittenState = serialized; + lastWrittenStateHash = stateHash; } export const LARGE_FILE_WARNING_SIZE = 50 * 1024 * 1024; diff --git a/src/utils/SearchResults.ts b/src/utils/SearchResults.ts index 5679f93..09a741e 100644 --- a/src/utils/SearchResults.ts +++ b/src/utils/SearchResults.ts @@ -127,13 +127,15 @@ export function compactHits(document: SearchDocument, matches: Array): return {lines: lineNumbers.map(row => readLine(document, row)), lineNumbers: lineNumbers, hits: hits}; } +export function toCompactDocument({lines, lineNumbers, hits}: CompactHits): Pick { + return {document: createSearchDocument(lines), lineNumbers: lineNumbers, hits: hits}; +} + export function toDocumentHits(matches: FileMatches, directory: string): DocumentHits { return { name: relativePath(directory, matches.file) ?? getFileName(matches.file), file: matches.file, - document: createSearchDocument(matches.lines), - lineNumbers: matches.lineNumbers, - hits: matches.hits, + ...toCompactDocument(matches), }; } diff --git a/src/utils/__tests__/SearchResults.test.ts b/src/utils/__tests__/SearchResults.test.ts index 8440048..68f0724 100644 --- a/src/utils/__tests__/SearchResults.test.ts +++ b/src/utils/__tests__/SearchResults.test.ts @@ -1,6 +1,6 @@ import {describe, expect, it} from "vitest"; import {DEFAULT_SEARCH_OPTIONS, SearchTarget} from "../../domain/Search"; -import {applyProgress, compactHits, createResultRows, describeHit, findAdjacentHit, getHit, hitKey, previewMatch, SearchRun} from "../SearchResults"; +import {applyProgress, compactHits, createResultRows, describeHit, findAdjacentHit, getHit, hitKey, previewMatch, SearchRun, toCompactDocument} from "../SearchResults"; import {createSearchDocument} from "../TextSearch"; const DOCUMENT = createSearchDocument(["a", "a", "a"]); @@ -57,6 +57,16 @@ describe("SearchResults", () => { ]); }); + it("describes compacted hits like the hits of the whole document", () => { + const document = createSearchDocument(["first line", "skip", "the hit here", "skip", "hit", "spans", "last"]); + const matches = [{start: 20, end: 23}, {start: 34, end: 44}]; + const whole = {name: "d", document: document, hits: matches}; + const compact = {name: "d", ...toCompactDocument(compactHits(document, matches))}; + + expect(compact.document.text.length).toBeLessThan(document.text.length); + expect(compact.hits.map(hit => describeHit(compact, hit))).toEqual(matches.map(hit => describeHit(whole, hit))); + }); + it("lists the rows of runs and skips collapsed parts", () => { const runs = [run(2, [2, 1]), run(1, [1])]; const keys = (collapsed: Set) => { From 7c606e36ac37f9a728fb7cc7ecb55fb0d6086ff3 Mon Sep 17 00:00:00 2001 From: hasanyildiz Date: Thu, 29 Oct 2026 10:41:42 +0000 Subject: [PATCH 2/2] Bump version to 1.7.1 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 10dd798..3cde504 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "note-manager", - "version": "1.7.0", + "version": "1.7.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "note-manager", - "version": "1.7.0", + "version": "1.7.1", "license": "MIT", "workspaces": [ "tools/shims/glob", diff --git a/package.json b/package.json index 51bf58f..e84b3f7 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "note-manager", "productName": "Note Manager", - "version": "1.7.0", + "version": "1.7.1", "description": "A lightweight, fast and multi-functional desktop text editor", "homepage": "https://github.com/Note-Manager/note-manager#readme", "repository": {