wip: install menu

This commit is contained in:
thatben 2025-04-07 16:01:49 +02:00
parent 8365e556a0
commit c8d96425d8
No known key found for this signature in database
GPG Key ID: 62C543548433D43E
10 changed files with 214 additions and 59 deletions

View File

@ -14,3 +14,7 @@ export const mockMenuLoop = {
showLoop: vi.fn(),
stopLoop: vi.fn(),
};
export const mockDataDirMover = {
moveDataDir: vi.fn(),
};

View File

@ -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();

View File

@ -38,4 +38,8 @@ export class FsService {
makeDir = (dir) => {
fs.mkdirSync(dir);
};
moveDir = (oldPath, newPath) => {
fs.moveSync(oldPath, newPath);
};
}

View File

@ -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();

View File

@ -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();

View File

@ -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 () => {};
}

View File

@ -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.",
);
});
});

53
src/utils/dataDirMover.js Normal file
View File

@ -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.",
),
);
}
};
}

View File

@ -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 () => {

View File

@ -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.",
);
});
});