From c8d96425d88ef3082d00ad970675f1c895c7aecd Mon Sep 17 00:00:00 2001 From: thatben Date: Mon, 7 Apr 2025 16:01:49 +0200 Subject: [PATCH] wip: install menu --- src/__mocks__/utils.mocks.js | 4 ++ src/main.js | 3 +- src/services/fsService.js | 4 ++ src/ui/configMenu.js | 56 ++++++++------------------- src/ui/configMenu.test.js | 32 +++++++++++++++ src/ui/installMenu.js | 24 ++++++++---- src/ui/installMenu.test.js | 71 ++++++++++++++++++++++++++++++++++ src/utils/dataDirMover.js | 53 +++++++++++++++++++++++++ src/utils/pathSelector.js | 6 +-- src/utils/pathSelector.test.js | 20 ++++++---- 10 files changed, 214 insertions(+), 59 deletions(-) create mode 100644 src/ui/installMenu.test.js create mode 100644 src/utils/dataDirMover.js diff --git a/src/__mocks__/utils.mocks.js b/src/__mocks__/utils.mocks.js index 2274803..5b0d1b6 100644 --- a/src/__mocks__/utils.mocks.js +++ b/src/__mocks__/utils.mocks.js @@ -14,3 +14,7 @@ export const mockMenuLoop = { showLoop: vi.fn(), stopLoop: vi.fn(), }; + +export const mockDataDirMover = { + moveDataDir: vi.fn(), +}; diff --git a/src/main.js b/src/main.js index 3bbfecb..4cd4c53 100644 --- a/src/main.js +++ b/src/main.js @@ -99,7 +99,7 @@ export async function main() { const fsService = new FsService(); const pathSelector = new PathSelector(uiService, new MenuLoop(), fsService); const numberSelector = new NumberSelector(uiService); - const installMenu = new InstallMenu(uiService, configService); + const installMenu = new InstallMenu(uiService, configService, pathSelector); const configMenu = new ConfigMenu( uiService, new MenuLoop(), @@ -112,6 +112,7 @@ export async function main() { new MenuLoop(), installMenu, configMenu, + new DataDirMover(fsService, uiService) ); await mainMenu.show(); diff --git a/src/services/fsService.js b/src/services/fsService.js index 45f22c5..645a099 100644 --- a/src/services/fsService.js +++ b/src/services/fsService.js @@ -38,4 +38,8 @@ export class FsService { makeDir = (dir) => { fs.mkdirSync(dir); }; + + moveDir = (oldPath, newPath) => { + fs.moveSync(oldPath, newPath); + }; } diff --git a/src/ui/configMenu.js b/src/ui/configMenu.js index ce50aa6..6a3b241 100644 --- a/src/ui/configMenu.js +++ b/src/ui/configMenu.js @@ -5,18 +5,21 @@ export class ConfigMenu { configService, pathSelector, numberSelector, + dataDirMover, ) { this.ui = uiService; this.loop = menuLoop; this.configService = configService; this.pathSelector = pathSelector; this.numberSelector = numberSelector; + this.dataDirMover = dataDirMover; this.loop.initialize(this.showConfigMenu); } show = async () => { this.config = this.configService.get(); + this.originalDataDir = this.config.dataDir; this.ui.showInfoMessage("Codex Configuration"); await this.loop.showLoop(); }; @@ -76,46 +79,10 @@ export class ConfigMenu { }; editDataDir = async () => { - // todo - // function updateDataDir(config, newDataDir) { - // if (config.dataDir == newDataDir) return config; - // // The Codex dataDir is a little strange: - // // If the old one is empty: The new one should not exist, so that codex creates it - // // with the correct security permissions. - // // If the old one does exist: We move it. - // if (isDir(config.dataDir)) { - // console.log( - // showInfoMessage( - // "Moving Codex data folder...\n" + - // `From: "${config.dataDir}"\n` + - // `To: "${newDataDir}"`, - // ), - // ); - // try { - // fs.moveSync(config.dataDir, newDataDir); - // } catch (error) { - // console.log( - // showErrorMessage("Error while moving dataDir: " + error.message), - // ); - // throw error; - // } - // } else { - // // Old data dir does not exist. - // if (isDir(newDataDir)) { - // console.log( - // showInfoMessage( - // "Warning: the selected data path already exists.\n" + - // `New data path = "${newDataDir}"\n` + - // "Codex may overwrite data in this folder.\n" + - // "Codex will fail to start if this folder does not have the required\n" + - // "security permissions.", - // ), - // ); - // } - // } - // config.dataDir = newDataDir; - // return config; - // } + this.config.dataDir = await this.pathSelector.show( + this.config.dataDir, + false, + ); }; editLogsDir = async () => { @@ -181,6 +148,15 @@ export class ConfigMenu { }; saveChangesAndExit = async () => { + if (this.config.dataDir !== this.originalDataDir) { + // The Codex data-dir is a little special. + // Use a dedicated module to move it. + await this.dataDirMover.moveDataDir( + this.originalDataDir, + this.config.dataDir, + ); + } + this.configService.saveConfig(); this.ui.showInfoMessage("Configuration changes saved."); this.loop.stopLoop(); diff --git a/src/ui/configMenu.test.js b/src/ui/configMenu.test.js index 8f5c1d7..00d0a80 100644 --- a/src/ui/configMenu.test.js +++ b/src/ui/configMenu.test.js @@ -6,6 +6,7 @@ import { mockPathSelector, mockNumberSelector, mockMenuLoop, + mockDataDirMover, } from "../__mocks__/utils.mocks.js"; describe("ConfigMenu", () => { @@ -31,6 +32,7 @@ describe("ConfigMenu", () => { mockConfigService, mockPathSelector, mockNumberSelector, + mockDataDirMover, ); }); @@ -57,6 +59,11 @@ describe("ConfigMenu", () => { expect(configMenu.config).toEqual(config); }); + it("sets the original datadir field", async () => { + await configMenu.show(); + expect(configMenu.originalDataDir).toEqual(config.dataDir); + }); + describe("config menu options", () => { beforeEach(() => { configMenu.config = config; @@ -103,6 +110,15 @@ describe("ConfigMenu", () => { ); }); + it("edits the logs directory", async () => { + const originalPath = config.dataDir; + mockPathSelector.show.mockResolvedValue("/new-data"); + await configMenu.editDataDir(); + + expect(mockPathSelector.show).toHaveBeenCalledWith(originalPath, false); + expect(configMenu.config.dataDir).toEqual("/new-data"); + }); + it("edits the logs directory", async () => { const originalPath = config.logsDir; mockPathSelector.show.mockResolvedValue("/new-logs"); @@ -219,8 +235,11 @@ describe("ConfigMenu", () => { ); expect(configMenu.config.ports.apiPort).toEqual(originalPort); }); + }); + describe("save and discard changes", () => { it("saves changes and exits", async () => { + await configMenu.show(); await configMenu.saveChangesAndExit(); expect(mockConfigService.saveConfig).toHaveBeenCalled(); @@ -230,6 +249,19 @@ describe("ConfigMenu", () => { expect(mockMenuLoop.stopLoop).toHaveBeenCalled(); }); + it("calls the dataDirMover when the new datadir is not equal to the original dataDir when saving changes", async () => { + config.dataDir = "/original-data"; + await configMenu.show(); + + configMenu.config.dataDir = "/new-data"; + await configMenu.saveChangesAndExit(); + + expect(mockDataDirMover.moveDataDir).toHaveBeenCalledWith( + configMenu.originalDataDir, + configMenu.config.dataDir, + ); + }); + it("discards changes and exits", async () => { await configMenu.discardChangesAndExit(); diff --git a/src/ui/installMenu.js b/src/ui/installMenu.js index f00c620..b42eb82 100644 --- a/src/ui/installMenu.js +++ b/src/ui/installMenu.js @@ -1,16 +1,16 @@ export class InstallMenu { - constructor(uiService, configService) { + constructor(uiService, configService, pathSelector) { this.ui = uiService; + this.configService = configService; this.config = configService.get(); + this.pathSelector = pathSelector; } show = async () => { await this.ui.askMultipleChoice("Configure your Codex installation", [ { label: "Install path: " + this.config.codexPath, - action: async function () { - console.log("run path selector"); - }, + action: this.selectInstallPath, }, { label: "Storage provider module: Disabled (todo)", @@ -22,17 +22,25 @@ export class InstallMenu { }, { label: "Cancel", - action: async function () {}, + action: this.doNothing, }, ]); }; + selectInstallPath = async () => { + this.config.codexPath = await this.pathSelector.show( + this.config.codexPath, + false, + ); + this.configService.saveConfig(); + }; + storageProviderOption = async () => { this.ui.showInfoMessage("This option is not currently available."); await this.show(); }; - performInstall = async () => { - console.log("todo"); - }; + performInstall = async () => {}; + + doNothing = async () => {}; } diff --git a/src/ui/installMenu.test.js b/src/ui/installMenu.test.js new file mode 100644 index 0000000..1a0c7c9 --- /dev/null +++ b/src/ui/installMenu.test.js @@ -0,0 +1,71 @@ +import { describe, beforeEach, it, expect, vi } from "vitest"; +import { InstallMenu } from "./installMenu.js"; +import { mockUiService } from "../__mocks__/service.mocks.js"; +import { mockConfigService } from "../__mocks__/service.mocks.js"; +import { mockPathSelector } from "../__mocks__/utils.mocks.js"; + +describe("InstallMenu", () => { + const config = { + codexPath: "/codex", + }; + let installMenu; + + beforeEach(() => { + vi.resetAllMocks(); + mockConfigService.get.mockReturnValue(config); + + installMenu = new InstallMenu( + mockUiService, + mockConfigService, + mockPathSelector, + ); + }); + + it("displays the install menu", async () => { + await installMenu.show(); + expect(mockUiService.askMultipleChoice).toHaveBeenCalledWith( + "Configure your Codex installation", + [ + { + label: "Install path: " + config.codexPath, + action: installMenu.selectInstallPath, + }, + { + label: "Storage provider module: Disabled (todo)", + action: installMenu.storageProviderOption, + }, + { + label: "Install!", + action: installMenu.performInstall, + }, + { + label: "Cancel", + action: installMenu.doNothing, + }, + ], + ); + }); + + it("allows selecting the install path", async () => { + const originalPath = config.codexPath; + const newPath = "/new/path"; + mockPathSelector.show.mockResolvedValue(newPath); + + await installMenu.selectInstallPath(); + + expect(mockPathSelector.show).toHaveBeenCalledWith(originalPath, false); + expect(config.codexPath).toBe(newPath); + expect(mockConfigService.saveConfig).toHaveBeenCalled(); + }); + + it("shows storage provider option is unavailable", async () => { + const showMock = vi.fn(); + installMenu.show = showMock; + + await installMenu.storageProviderOption(); + + expect(mockUiService.showInfoMessage).toHaveBeenCalledWith( + "This option is not currently available.", + ); + }); +}); diff --git a/src/utils/dataDirMover.js b/src/utils/dataDirMover.js new file mode 100644 index 0000000..9fcc827 --- /dev/null +++ b/src/utils/dataDirMover.js @@ -0,0 +1,53 @@ +export class DataDirMover { + constructor(fsService, uiService) { + this.fs = fsService; + this.ui = uiService; + } + + moveDataDir = (oldPath, newPath) => { + if (oldPath === newPath) return; + + // The Codex dataDir is a little strange: + // If the old one is empty: The new one should not exist, so that codex creates it with the correct security permissions. + // If the old one does exist: We move it. + + if (this.fs.isDir(oldPath)) { + this.moveDir(oldPath, newPath); + } else { + this.ensureDoesNotExist(newPath); + } + }; + + moveDir = (oldPath, newPath) => { + this.ui.showInfoMessage( + "Moving Codex data folder...\n" + + `From: "${oldPath}"\n` + + `To: "${newPath}"`, + ); + + try { + this.fs.moveDir(oldPath, newPath); + } catch (error) { + console.log( + this.ui.showErrorMessage( + "Error while moving dataDir: " + error.message, + ), + ); + throw error; + } + }; + + ensureDoesNotExist = (path) => { + if (this.fs.isDir(path)) { + console.log( + this.ui.showInfoMessage( + "Warning: the selected data path already exists.\n" + + `New data path = "${path}"\n` + + "Codex may overwrite data in this folder.\n" + + "Codex will fail to start if this folder does not have the required\n" + + "security permissions.", + ), + ); + } + }; +} diff --git a/src/utils/pathSelector.js b/src/utils/pathSelector.js index 0289363..af39af6 100644 --- a/src/utils/pathSelector.js +++ b/src/utils/pathSelector.js @@ -55,13 +55,13 @@ export class PathSelector { splitPath = (str) => { var result = this.dropEmptyParts(str.replaceAll("\\", "/").split("/")); if (str.startsWith("/") && this.roots.includes("/")) { - result = ["/", ...result]; + result = ["/", ...result]; } return result; }; dropEmptyParts = (parts) => { - return parts.filter(part => part.length > 0); + return parts.filter((part) => part.length > 0); }; combine = (parts) => { @@ -141,7 +141,7 @@ export class PathSelector { getSubDirOptions = () => { const fullPath = this.combine(this.currentPath); const entries = this.fs.readDir(fullPath); - return entries.filter(entry => this.isSubDir(entry)); + return entries.filter((entry) => this.isSubDir(entry)); }; downOne = async () => { diff --git a/src/utils/pathSelector.test.js b/src/utils/pathSelector.test.js index 8c24719..16d5439 100644 --- a/src/utils/pathSelector.test.js +++ b/src/utils/pathSelector.test.js @@ -20,7 +20,9 @@ describe("PathSelector", () => { describe("initialization", () => { it("initializes the menu loop", () => { - expect(mockMenuLoop.initialize).toHaveBeenCalledWith(pathSelector.showPathSelector); + expect(mockMenuLoop.initialize).toHaveBeenCalledWith( + pathSelector.showPathSelector, + ); }); }); @@ -92,9 +94,9 @@ describe("PathSelector", () => { it("shows down directory navigation", async () => { mockFsService.readDir.mockReturnValue(["subdir1", "subdir2"]); mockFsService.isDir.mockReturnValue(true); - + await pathSelector.downOne(); - + expect(mockUiService.askMultipleChoice).toHaveBeenCalled(); expect(mockFsService.readDir).toHaveBeenCalledWith(mockStartPath); }); @@ -106,7 +108,7 @@ describe("PathSelector", () => { options[0].action(); // Select the first option }); await pathSelector.downOne(); - + expect(pathSelector.currentPath).toEqual(["/", "home", "user", subdir]); }); @@ -114,9 +116,11 @@ describe("PathSelector", () => { const newDir = "newdir"; mockUiService.askPrompt.mockResolvedValue(newDir); await pathSelector.createSubDir(); - + expect(mockUiService.askPrompt).toHaveBeenCalledWith("Enter name:"); - expect(mockFsService.makeDir).toHaveBeenCalled(mockStartPath + "/" + newDir); + expect(mockFsService.makeDir).toHaveBeenCalled( + mockStartPath + "/" + newDir, + ); expect(pathSelector.currentPath).toEqual(["/", "home", "user", newDir]); }); }); @@ -135,7 +139,9 @@ describe("PathSelector", () => { it("validates full paths", () => { mockFsService.isDir.mockReturnValue(false); pathSelector.updateCurrentIfValidFull("/invalid/path"); - expect(mockUiService.showErrorMessage).toHaveBeenCalledWith("The path does not exist."); + expect(mockUiService.showErrorMessage).toHaveBeenCalledWith( + "The path does not exist.", + ); }); });