Repository: oliverschwendener/ueli
Pull request #1299 (2024-12-31 12:08:10)

perf: load last seen search index from file system for better startup performance
This PR improves the perceived startup performance by initially loading the search index from the last session. 


Patch:
diff --git a/src/common/Core/ContextBridge.ts b/src/common/Core/ContextBridge.ts
index bcd90ec1..86bcd344 100644
--- a/src/common/Core/ContextBridge.ts
+++ b/src/common/Core/ContextBridge.ts
@@ -29,18 +29,18 @@ export type ContextBridge = {
     getAvailableTerminals: () => Terminal[];
     getEnabledExtensions: () => ExtensionInfo[];
     getEnvironmentVariable: (environmentVariable: string) => string | undefined;
-    getExtension: (extensionId: string) => ExtensionInfo;
-    getExtensionResources: <T extends Translations>() => { extensionId: string; resources: Resources<T> }[];
     getExcludedSearchResultItemIds: () => string[];
+    getExtension: (extensionId: string) => ExtensionInfo;
     getExtensionAssetFilePath: (extensionId: string, key: string) => string;
+    getExtensionResources: <T extends Translations>() => { extensionId: string; resources: Resources<T> }[];
     getExtensionSettingDefaultValue: <Value>(extensionId: string, settingKey: string) => Value;
     getFavorites: () => string[];
-    getLogs: () => string[];
     getInstantSearchResultItems: (searchTerm: string) => InstantSearchResultItems;
+    getLogs: () => string[];
     getOperatingSystem: () => OperatingSystem;
+    getScanCount: () => number;
     getSearchResultItems: () => SearchResultItem[];
     getSettingValue: <Value>(key: string, defaultValue: Value, isSensitive?: boolean) => Value;
-    getScanCount: () => number;
     invokeAction: (action: SearchResultItemAction) => Promise<void>;
     invokeExtension: <Argument, Result>(extensionId: string, searchArguments: Argument) => Promise<Result>;
     openExternal: (url: string, options?: OpenExternalOptions) => Promise<void>;
@@ -48,6 +48,7 @@ export type ContextBridge = {
     removeExcludedSearchResultItem: (itemId: string) => Promise<void>;
     removeFavorite: (id: string) => Promise<void>;
     resetAllSettings: () => Promise<void>;
+    searchIndexCacheFileExists: () => boolean;
     showOpenDialog: (options: OpenDialogOptions) => Promise<OpenDialogReturnValue>;
     triggerExtensionRescan: (extensionId: string) => Promise<void>;
     updateSettingValue: <Value>(key: string, value: Value, isSensitive?: boolean) => Promise<void>;
diff --git a/src/main/Core/ExtensionManager/ExtensionManager.ts b/src/main/Core/ExtensionManager/ExtensionManager.ts
index 00582263..24123422 100644
--- a/src/main/Core/ExtensionManager/ExtensionManager.ts
+++ b/src/main/Core/ExtensionManager/ExtensionManager.ts
@@ -1,6 +1,6 @@
 import type { ExtensionRegistry } from "@Core/ExtensionRegistry";
 import type { Logger } from "@Core/Logger";
-import type { SearchIndex } from "@Core/SearchIndex";
+import type { Index, SearchIndex } from "@Core/SearchIndex";
 import type { SettingsManager } from "@Core/SettingsManager";
 import { createEmptyInstantSearchResult, type InstantSearchResultItems } from "@common/Core";
 import type { ScanCounter } from "./ScanCounter";
@@ -21,12 +21,14 @@ export class ExtensionManager {
             enabledExtensions.map((extension) => extension.getSearchResultItems()),
         );
 
+        const newIndex: Index = {};
+
         for (let i = 0; i < enabledExtensions.length; i++) {
             const promiseResult = promiseResults[i];
             const { id: extensionId } = enabledExtensions[i];
 
             if (promiseResult.status === "fulfilled") {
-                this.searchIndex.addSearchResultItems(extensionId, promiseResult.value);
+                newIndex[extensionId] = promiseResult.value;
             } else {
                 this.logger.error(
                     `Failed to get search result items for extension with id '${extensionId}.` +
@@ -35,6 +37,7 @@ export class ExtensionManager {
             }
         }
 
+        this.searchIndex.set(newIndex);
         this.scanCounter.increment();
     }
 
diff --git a/src/main/Core/RescanOrchestrator/RescanOrchestratorModule.ts b/src/main/Core/RescanOrchestrator/RescanOrchestratorModule.ts
index 783d407a..46979ca9 100644
--- a/src/main/Core/RescanOrchestrator/RescanOrchestratorModule.ts
+++ b/src/main/Core/RescanOrchestrator/RescanOrchestratorModule.ts
@@ -1,9 +1,11 @@
 import type { UeliModuleRegistry } from "@Core/ModuleRegistry";
+import type { UeliCommandInvokedEvent } from "@Core/UeliCommand";
 import { RescanOrchestrator } from "./RescanOrchestrator";
 
 export class RescanOrchestratorModule {
     public static bootstrap(moduleRegistry: UeliModuleRegistry): void {
         const eventSubscriber = moduleRegistry.get("EventSubscriber");
+
         const rescanOrchestrator = new RescanOrchestrator(
             moduleRegistry.get("EventEmitter"),
             moduleRegistry.get("SettingsManager"),
@@ -34,9 +36,11 @@ export class RescanOrchestratorModule {
             }
         });
 
-        eventSubscriber.subscribe("ueliCommandInvoked", async () => {
-            rescanOrchestrator.cancel();
-            rescanOrchestrator.scanUntilCancelled();
+        eventSubscriber.subscribe("ueliCommandInvoked", ({ ueliCommand }: UeliCommandInvokedEvent<unknown>) => {
+            if (ueliCommand === "rescanExtensions") {
+                rescanOrchestrator.cancel();
+                rescanOrchestrator.scanUntilCancelled();
+            }
         });
     }
 }
diff --git a/src/main/Core/SearchIndex/Contract/SearchIndex.ts b/src/main/Core/SearchIndex/Contract/SearchIndex.ts
index 862cef95..30a63a0c 100644
--- a/src/main/Core/SearchIndex/Contract/SearchIndex.ts
+++ b/src/main/Core/SearchIndex/Contract/SearchIndex.ts
@@ -1,9 +1,16 @@
 import type { SearchResultItem } from "@common/Core";
+import type { Index } from "./SearchIndexStructure";
 
 /**
  * Offers methods to interact with the search index.
  */
 export interface SearchIndex {
+    /**
+     * Sets the search index. Note that all existing items in the search index will be replaced.
+     * @param index The search index to set.
+     */
+    set(index: Index): void;
+
     /**
      * Gets all search result items in a flat list.
      */
diff --git a/src/main/Core/SearchIndex/Contract/SearchIndexFile.ts b/src/main/Core/SearchIndex/Contract/SearchIndexFile.ts
new file mode 100644
index 00000000..47553b83
--- /dev/null
+++ b/src/main/Core/SearchIndex/Contract/SearchIndexFile.ts
@@ -0,0 +1,8 @@
+import type { Index } from "./SearchIndexStructure";
+
+export interface SearchIndexFile {
+    getPath(): string;
+    exists(): boolean;
+    read(): Index;
+    write(index: Index): void;
+}
diff --git a/src/main/Core/SearchIndex/Contract/SearchIndexStructure.ts b/src/main/Core/SearchIndex/Contract/SearchIndexStructure.ts
new file mode 100644
index 00000000..ab795e15
--- /dev/null
+++ b/src/main/Core/SearchIndex/Contract/SearchIndexStructure.ts
@@ -0,0 +1,3 @@
+import type { SearchResultItem } from "@common/Core";
+
+export type Index = Record<string, SearchResultItem[]>;
diff --git a/src/main/Core/SearchIndex/Contract/index.ts b/src/main/Core/SearchIndex/Contract/index.ts
index 80c8d5c7..bb8713ee 100644
--- a/src/main/Core/SearchIndex/Contract/index.ts
+++ b/src/main/Core/SearchIndex/Contract/index.ts
@@ -1,1 +1,2 @@
 export * from "./SearchIndex";
+export * from "./SearchIndexStructure";
diff --git a/src/main/Core/SearchIndex/InMemoryIndex.ts b/src/main/Core/SearchIndex/InMemoryIndex.ts
deleted file mode 100644
index 8eb77e22..00000000
--- a/src/main/Core/SearchIndex/InMemoryIndex.ts
+++ /dev/null
@@ -1,3 +0,0 @@
-import type { SearchResultItem } from "@common/Core";
-
-export type InMemoryIndex = Record<string, SearchResultItem[]>;
diff --git a/src/main/Core/SearchIndex/InMemorySearchIndex.ts b/src/main/Core/SearchIndex/SearchIndex.ts
similarity index 54%
rename from src/main/Core/SearchIndex/InMemorySearchIndex.ts
rename to src/main/Core/SearchIndex/SearchIndex.ts
index 1020c810..2fd18a15 100644
--- a/src/main/Core/SearchIndex/InMemorySearchIndex.ts
+++ b/src/main/Core/SearchIndex/SearchIndex.ts
@@ -1,13 +1,23 @@
 import type { BrowserWindowNotifier } from "@Core/BrowserWindowNotifier";
 import type { SearchResultItem } from "@common/Core";
-import type { SearchIndex } from "./Contract";
-import type { InMemoryIndex } from "./InMemoryIndex";
-
-export class InMemorySearchIndex implements SearchIndex {
-    private index: InMemoryIndex;
+import type { SearchIndex as SearchIndexInterface } from "./Contract";
+import type { SearchIndexFile } from "./Contract/SearchIndexFile";
+import type { Index } from "./Contract/SearchIndexStructure";
+
+export class SearchIndex implements SearchIndexInterface {
+    private index: Index;
+
+    public constructor(
+        private readonly browserWindowNotifier: BrowserWindowNotifier,
+        private readonly indexFile: SearchIndexFile,
+    ) {
+        this.index = indexFile.read();
+    }
 
-    public constructor(private readonly browserWindowNotifier: BrowserWindowNotifier) {
-        this.index = {};
+    public set(index: Index): void {
+        this.index = index;
+        this.indexFile.write(this.index);
+        this.browserWindowNotifier.notifyAll({ channel: "searchIndexUpdated" });
     }
 
     public getSearchResultItems(): SearchResultItem[] {
@@ -22,19 +32,13 @@ export class InMemorySearchIndex implements SearchIndex {
 
     public addSearchResultItems(extensionId: string, searchResultItems: SearchResultItem[]): void {
         this.index[extensionId] = searchResultItems;
+        this.indexFile.write(this.index);
         this.browserWindowNotifier.notifyAll({ channel: "searchIndexUpdated" });
     }
 
     public removeSearchResultItems(extensionId: string): void {
         delete this.index[extensionId];
+        this.indexFile.write(this.index);
         this.browserWindowNotifier.notifyAll({ channel: "searchIndexUpdated" });
     }
-
-    public getIndex(): InMemoryIndex {
-        return this.index;
-    }
-
-    public setIndex(index: InMemoryIndex): void {
-        this.index = index;
-    }
 }
diff --git a/src/main/Core/SearchIndex/SearchIndexFile.ts b/src/main/Core/SearchIndex/SearchIndexFile.ts
new file mode 100644
index 00000000..9eee3317
--- /dev/null
+++ b/src/main/Core/SearchIndex/SearchIndexFile.ts
@@ -0,0 +1,30 @@
+import type { FileSystemUtility } from "@Core/FileSystemUtility";
+import type { App } from "electron";
+import { join } from "path";
+import type { SearchIndexFile as SearchIndexFileInterface } from "./Contract/SearchIndexFile";
+import type { Index } from "./Contract/SearchIndexStructure";
+
+export class SearchIndexFile implements SearchIndexFileInterface {
+    public constructor(
+        private readonly app: App,
+        private readonly fileSystemUtility: FileSystemUtility,
+    ) {}
+
+    public getPath(): string {
+        return join(this.app.getPath("userData"), "searchIndex.json");
+    }
+
+    public exists(): boolean {
+        return this.fileSystemUtility.existsSync(this.getPath());
+    }
+
+    public read(): Index {
+        return this.fileSystemUtility.existsSync(this.getPath())
+            ? this.fileSystemUtility.readJsonFileSync<Index>(this.getPath())
+            : {};
+    }
+
+    public write(index: Index): void {
+        this.fileSystemUtility.writeJsonFileSync(index, this.getPath());
+    }
+}
diff --git a/src/main/Core/SearchIndex/SearchIndexModule.ts b/src/main/Core/SearchIndex/SearchIndexModule.ts
index b6b4ef57..132cc527 100644
--- a/src/main/Core/SearchIndex/SearchIndexModule.ts
+++ b/src/main/Core/SearchIndex/SearchIndexModule.ts
@@ -1,11 +1,18 @@
 import type { UeliModuleRegistry } from "@Core/ModuleRegistry";
-import { InMemorySearchIndex } from "./InMemorySearchIndex";
+import type { SearchIndexFile as SearchIndexFileInterface } from "./Contract/SearchIndexFile";
+import { SearchIndex } from "./SearchIndex";
+import { SearchIndexFile } from "./SearchIndexFile";
 
 export class SearchIndexModule {
     public static bootstrap(moduleRegistry: UeliModuleRegistry) {
+        const app = moduleRegistry.get("App");
+        const browserWindowNotifier = moduleRegistry.get("BrowserWindowNotifier");
+        const fileSystemUtility = moduleRegistry.get("FileSystemUtility");
         const ipcMain = moduleRegistry.get("IpcMain");
 
-        const searchIndex = new InMemorySearchIndex(moduleRegistry.get("BrowserWindowNotifier"));
+        const searchIndexFile: SearchIndexFileInterface = new SearchIndexFile(app, fileSystemUtility);
+
+        const searchIndex = new SearchIndex(browserWindowNotifier, searchIndexFile);
 
         moduleRegistry.register("SearchIndex", searchIndex);
 
@@ -14,5 +21,7 @@ export class SearchIndexModule {
         );
 
         ipcMain.on("getSearchResultItems", (event) => (event.returnValue = searchIndex.getSearchResultItems()));
+
+        ipcMain.on("searchIndexCacheFileExists", (event) => (event.returnValue = searchIndexFile.exists()));
     }
 }
diff --git a/src/main/Core/SettingsWindow/SettingsWindowModule.ts b/src/main/Core/SettingsWindow/SettingsWindowModule.ts
index fee6d9db..0985a151 100644
--- a/src/main/Core/SettingsWindow/SettingsWindowModule.ts
+++ b/src/main/Core/SettingsWindow/SettingsWindowModule.ts
@@ -42,13 +42,15 @@ export class SettingsWindowModule {
 
         eventSubscriber.subscribe(
             "ueliCommandInvoked",
-            async ({ ueliCommand, argument: { pathname } }: UeliCommandInvokedEvent<{ pathname: string }>) => {
+            async ({ ueliCommand, argument }: UeliCommandInvokedEvent<{ pathname: string }>) => {
                 if (["openAbout", "openExtensions", "openSettings"].includes(ueliCommand)) {
                     const settingsWindow = await settingsWindowManager.getWindow();
 
                     settingsWindow.show();
                     settingsWindow.focus();
 
+                    const { pathname } = argument;
+
                     browserWindowNotifier.notify({
                         browserWindowId: "settings",
                         channel: "navigateTo",
diff --git a/src/preload/index.ts b/src/preload/index.ts
index f44177ac..d0609f91 100644
--- a/src/preload/index.ts
+++ b/src/preload/index.ts
@@ -49,6 +49,7 @@ const contextBridgeImplementation: ContextBridge = {
     triggerExtensionRescan: (extensionId) => ipcRenderer.invoke("triggerExtensionRescan", { extensionId }),
     updateSettingValue: (key, value, isSensitive) =>
         ipcRenderer.invoke("updateSettingValue", { key, value, isSensitive }),
+    searchIndexCacheFileExists: () => ipcRenderer.sendSync("searchIndexCacheFileExists"),
 };
 
 contextBridge.exposeInMainWorld("ContextBridge", contextBridgeImplementation);
diff --git a/src/renderer/Core/Search/Search.tsx b/src/renderer/Core/Search/Search.tsx
index c34f030a..8a1efb85 100644
--- a/src/renderer/Core/Search/Search.tsx
+++ b/src/renderer/Core/Search/Search.tsx
@@ -267,7 +267,7 @@ export const Search = ({
             }
             contentRef={containerRef}
             content={
-                window.ContextBridge.getScanCount() === 0 ? (
+                window.ContextBridge.getScanCount() === 0 && !window.ContextBridge.searchIndexCacheFileExists() ? (
                     <ScanIndicator />
                 ) : (
                     <>


Test patch:
diff --git a/src/main/Core/SearchIndex/InMemorySearchIndex.test.ts b/src/main/Core/SearchIndex/InMemorySearchIndex.test.ts
deleted file mode 100644
index 9531572e..00000000
--- a/src/main/Core/SearchIndex/InMemorySearchIndex.test.ts
+++ /dev/null
@@ -1,66 +0,0 @@
-import type { BrowserWindowNotifier } from "@Core/BrowserWindowNotifier";
-import type { SearchResultItem } from "@common/Core";
-import { describe, expect, it, vi } from "vitest";
-import { InMemorySearchIndex } from "./InMemorySearchIndex";
-
-describe(InMemorySearchIndex, () => {
-    it("should return an empty list of search result items if the search index is empty", () => {
-        const searchIndex = new InMemorySearchIndex(<BrowserWindowNotifier>{});
-
-        searchIndex.setIndex({});
-
-        expect(searchIndex.getSearchResultItems()).toEqual([]);
-    });
-
-    it("should return all search result items if the search index is not empty", () => {
-        const searchResultItems: SearchResultItem[] = [
-            <SearchResultItem>{ description: "item1", id: "item1", name: "item1" },
-            <SearchResultItem>{ description: "item2", id: "item2", name: "item2" },
-            <SearchResultItem>{ description: "item3", id: "item3", name: "item3" },
-        ];
-
-        const searchIndex = new InMemorySearchIndex(<BrowserWindowNotifier>{});
-
-        searchIndex.setIndex({ testExtensionId: searchResultItems });
-
-        expect(searchIndex.getSearchResultItems()).toEqual(searchResultItems);
-    });
-
-    it("should add search result items to the index with the extension id as a key and emit an event", () => {
-        const notifyAllMock = vi.fn();
-        const eventEmitter = <BrowserWindowNotifier>{ notifyAll: (a) => notifyAllMock(a) };
-
-        const searchResultItems: SearchResultItem[] = [
-            <SearchResultItem>{ description: "item1", id: "item1", name: "item1" },
-            <SearchResultItem>{ description: "item2", id: "item2", name: "item2" },
-            <SearchResultItem>{ description: "item3", id: "item3", name: "item3" },
-        ];
-
-        const searchIndex = new InMemorySearchIndex(eventEmitter);
-
-        searchIndex.addSearchResultItems("extensionId1", searchResultItems);
-
-        expect(searchIndex.getIndex()).toEqual({ extensionId1: searchResultItems });
-        expect(notifyAllMock).toHaveBeenCalledWith({ channel: "searchIndexUpdated" });
-    });
-
-    it("should delete the key from the index and emit an event when removing search result items by extension id", () => {
-        const notifyAllMock = vi.fn();
-        const eventEmitter = <BrowserWindowNotifier>{ notifyAll: (a) => notifyAllMock(a) };
-
-        const searchIndex = new InMemorySearchIndex(eventEmitter);
-
-        searchIndex.setIndex({
-            extensionId1: [
-                <SearchResultItem>{ description: "item1", id: "item1", name: "item1" },
-                <SearchResultItem>{ description: "item2", id: "item2", name: "item2" },
-                <SearchResultItem>{ description: "item3", id: "item3", name: "item3" },
-            ],
-        });
-
-        searchIndex.removeSearchResultItems("extensionId1");
-
-        expect(searchIndex.getIndex()).toEqual({});
-        expect(notifyAllMock).toHaveBeenCalledWith({ channel: "searchIndexUpdated" });
-    });
-});
diff --git a/src/main/Core/SearchIndex/SearchIndex.test.ts b/src/main/Core/SearchIndex/SearchIndex.test.ts
new file mode 100644
index 00000000..3e7a04c7
--- /dev/null
+++ b/src/main/Core/SearchIndex/SearchIndex.test.ts
@@ -0,0 +1,150 @@
+import type { BrowserWindowNotifier } from "@Core/BrowserWindowNotifier";
+import type { SearchResultItem } from "@common/Core";
+import { describe, expect, it, vi } from "vitest";
+import type { Index } from "./Contract";
+import type { SearchIndexFile } from "./Contract/SearchIndexFile";
+import { SearchIndex } from "./SearchIndex";
+
+describe(SearchIndex, () => {
+    const searchIndexFileDummy = ({ index, exists }: { index: Index; exists?: boolean }) => {
+        const writeMock = vi.fn();
+
+        const searchIndexFile = new (class implements SearchIndexFile {
+            public getPath(): string {
+                return "dummypath";
+            }
+
+            public exists(): boolean {
+                return exists ?? false;
+            }
+
+            public read(): Index {
+                return index;
+            }
+
+            public write(index: Index): void {
+                writeMock(index);
+            }
+        })();
+
+        return { searchIndexFile, writeMock };
+    };
+
+    describe(SearchIndex.constructor, () => {
+        it("should read the index from the file", () => {
+            const index: Index = {
+                extension17: [<SearchResultItem>{ name: "item1", id: "item1", description: "item1" }],
+                extension33: [<SearchResultItem>{ name: "item2", id: "item2", description: "item2" }],
+            };
+
+            const { searchIndexFile } = searchIndexFileDummy({ index });
+
+            const searchIndex = new SearchIndex(<BrowserWindowNotifier>{}, searchIndexFile);
+
+            expect(searchIndex["index"]).toEqual(index);
+        });
+    });
+
+    describe(SearchIndex.prototype.set, () => {
+        it("should replace the current index", () => {
+            const { searchIndexFile, writeMock } = searchIndexFileDummy({
+                index: {
+                    extensionId1: [
+                        <SearchResultItem>{ description: "item1", id: "item1", name: "item1" },
+                        <SearchResultItem>{ description: "item2", id: "item2", name: "item2" },
+                        <SearchResultItem>{ description: "item3", id: "item3", name: "item3" },
+                    ],
+                },
+            });
+
+            const notifyAllMock = vi.fn();
+            const browserWindowNotifier = <BrowserWindowNotifier>{ notifyAll: (c) => notifyAllMock(c) };
+
+            const searchIndex = new SearchIndex(browserWindowNotifier, searchIndexFile);
+
+            const newIndex: Index = {
+                extensionId2: [
+                    <SearchResultItem>{ description: "item4", id: "item4", name: "item4" },
+                    <SearchResultItem>{ description: "item5", id: "item5", name: "item5" },
+                    <SearchResultItem>{ description: "item6", id: "item6", name: "item6" },
+                ],
+            };
+
+            searchIndex.set(newIndex);
+
+            expect(searchIndex["index"]).toEqual(newIndex);
+            expect(writeMock).toHaveBeenCalledWith(newIndex);
+            expect(notifyAllMock).toHaveBeenCalledWith({ channel: "searchIndexUpdated" });
+        });
+    });
+
+    describe(SearchIndex.prototype.getSearchResultItems, () => {
+        it("should return an empty list of search result items if the search index is empty", () => {
+            const { searchIndexFile } = searchIndexFileDummy({ index: {} });
+            const searchIndex = new SearchIndex(<BrowserWindowNotifier>{}, searchIndexFile);
+            expect(searchIndex.getSearchResultItems()).toEqual([]);
+        });
+
+        it("should return all search result items if the search index is not empty", () => {
+            const searchResultItems: SearchResultItem[] = [
+                <SearchResultItem>{ description: "item1", id: "item1", name: "item1" },
+                <SearchResultItem>{ description: "item2", id: "item2", name: "item2" },
+                <SearchResultItem>{ description: "item3", id: "item3", name: "item3" },
+            ];
+
+            const { searchIndexFile } = searchIndexFileDummy({ index: { testExtensionId: searchResultItems } });
+
+            const searchIndex = new SearchIndex(<BrowserWindowNotifier>{}, searchIndexFile);
+
+            expect(searchIndex.getSearchResultItems()).toEqual(searchResultItems);
+        });
+    });
+
+    describe(SearchIndex.prototype.addSearchResultItems, () => {
+        it("should add search result items to the index with the extension id as a key and emit an event", () => {
+            const notifyAllMock = vi.fn();
+            const eventEmitter = <BrowserWindowNotifier>{ notifyAll: (a) => notifyAllMock(a) };
+
+            const searchResultItems: SearchResultItem[] = [
+                <SearchResultItem>{ description: "item1", id: "item1", name: "item1" },
+                <SearchResultItem>{ description: "item2", id: "item2", name: "item2" },
+                <SearchResultItem>{ description: "item3", id: "item3", name: "item3" },
+            ];
+
+            const { searchIndexFile, writeMock } = searchIndexFileDummy({ index: {} });
+
+            const searchIndex = new SearchIndex(eventEmitter, searchIndexFile);
+
+            searchIndex.addSearchResultItems("extensionId1", searchResultItems);
+
+            expect(searchIndex["index"]).toEqual({ extensionId1: searchResultItems });
+            expect(writeMock).toHaveBeenCalledWith({ extensionId1: searchResultItems });
+            expect(notifyAllMock).toHaveBeenCalledWith({ channel: "searchIndexUpdated" });
+        });
+    });
+
+    describe(SearchIndex.prototype.removeSearchResultItems, () => {
+        it("should delete the key from the index and emit an event when removing search result items by extension id", () => {
+            const notifyAllMock = vi.fn();
+            const eventEmitter = <BrowserWindowNotifier>{ notifyAll: (a) => notifyAllMock(a) };
+
+            const { searchIndexFile, writeMock } = searchIndexFileDummy({
+                index: {
+                    extensionId1: [
+                        <SearchResultItem>{ description: "item1", id: "item1", name: "item1" },
+                        <SearchResultItem>{ description: "item2", id: "item2", name: "item2" },
+                        <SearchResultItem>{ description: "item3", id: "item3", name: "item3" },
+                    ],
+                },
+            });
+
+            const searchIndex = new SearchIndex(eventEmitter, searchIndexFile);
+
+            searchIndex.removeSearchResultItems("extensionId1");
+
+            expect(searchIndex["index"]).toEqual({});
+            expect(writeMock).toHaveBeenCalledWith({});
+            expect(notifyAllMock).toHaveBeenCalledWith({ channel: "searchIndexUpdated" });
+        });
+    });
+});