From 769fb06ad7ec86d79cb4ad14d1b127bab44f65c1 Mon Sep 17 00:00:00 2001 From: thatben Date: Fri, 7 Mar 2025 13:14:32 +0100 Subject: [PATCH] wip --- src/main.js | 6 +- src/services/config.js | 2 + src/services/fsservice.js | 41 +++ src/services/uiservice.js | 17 +- src/ui/installmenu.js | 38 +++ src/ui/mainmenu.js | 27 +- src/ui/mainmenu.test.js | 51 ++++ src/utils/numberSelector.js | 91 ++++--- src/utils/numberSelector.test.js | 66 +++++ src/utils/pathSelector.js | 426 ++++++++++++++----------------- 10 files changed, 470 insertions(+), 295 deletions(-) create mode 100644 src/services/fsservice.js create mode 100644 src/ui/installmenu.js create mode 100644 src/ui/mainmenu.test.js create mode 100644 src/utils/numberSelector.test.js diff --git a/src/main.js b/src/main.js index 8b25b2c..c00e9c3 100644 --- a/src/main.js +++ b/src/main.js @@ -24,6 +24,7 @@ import { showConfigMenu } from "./configmenu.js"; import { openCodexApp } from "./services/codexapp.js"; import { MainMenu } from "./ui/mainmenu.js"; +import { InstallMenu } from "./ui/installmenu.js"; import { UiService } from "./services/uiservice.js"; async function showNavigationMenu() { @@ -89,14 +90,15 @@ export async function main() { process.on("SIGTERM", handleExit); process.on("SIGQUIT", handleExit); + const config = loadConfig(); const uiService = new UiService(); - const mainMenu = new MainMenu(uiService); + const installMenu = new InstallMenu(uiService, config); + const mainMenu = new MainMenu(uiService, installMenu); await mainMenu.show(); return; try { - const config = loadConfig(); while (true) { console.log("\n" + chalk.cyanBright(ASCII_ART)); const { choice } = await inquirer diff --git a/src/services/config.js b/src/services/config.js index 9211cc5..7bbc2de 100644 --- a/src/services/config.js +++ b/src/services/config.js @@ -2,6 +2,7 @@ import fs from "fs"; import path from "path"; import { getAppDataDir } from "../utils/appdata.js"; import { + getCodexBinPath, getCodexDataDirDefaultPath, getCodexLogsDefaultPath, } from "../utils/appdata.js"; @@ -9,6 +10,7 @@ import { const defaultConfig = { codexExe: "", // User-selected config options: + codexPath: getCodexBinPath(), dataDir: getCodexDataDirDefaultPath(), logsDir: getCodexLogsDefaultPath(), storageQuota: 8 * 1024 * 1024 * 1024, diff --git a/src/services/fsservice.js b/src/services/fsservice.js new file mode 100644 index 0000000..45f22c5 --- /dev/null +++ b/src/services/fsservice.js @@ -0,0 +1,41 @@ +import path from "path"; +import fs from "fs"; +import { filesystemSync } from "fs-filesystem"; + +export class FsService { + getAvailableRoots = () => { + const devices = filesystemSync(); + var mountPoints = []; + Object.keys(devices).forEach(function (key) { + var val = devices[key]; + val.volumes.forEach(function (volume) { + mountPoints.push(volume.mountPoint); + }); + }); + + if (mountPoints.length < 1) { + throw new Error("Failed to detect file system devices."); + } + return mountPoints; + }; + + pathJoin = (parts) => { + return path.join(...parts); + }; + + isDir = (dir) => { + try { + return fs.lstatSync(dir).isDirectory(); + } catch { + return false; + } + }; + + readDir = (dir) => { + return fs.readdirSync(dir); + }; + + makeDir = (dir) => { + fs.mkdirSync(dir); + }; +} diff --git a/src/services/uiservice.js b/src/services/uiservice.js index 7711403..0ff9233 100644 --- a/src/services/uiservice.js +++ b/src/services/uiservice.js @@ -55,7 +55,7 @@ export class UiService { askMultipleChoice = async (message, choices) => { var counter = 1; var promptChoices = []; - choices.forEach(function(choice) { + choices.forEach(function (choice) { promptChoices.push(`${counter}. ${choice.label}`); counter++; }); @@ -67,8 +67,8 @@ export class UiService { message: message, choices: promptChoices, pageSize: counter - 1, - loop: true - } + loop: true, + }, ]); const selectStr = choice.split(".")[0]; @@ -76,4 +76,15 @@ export class UiService { await choices[selectIndex].action(); }; + + askPrompt = async (prompt) => { + const response = await inquirer.prompt([ + { + type: "input", + name: "valueStr", + message: prompt, + }, + ]); + return response.valueStr; + }; } diff --git a/src/ui/installmenu.js b/src/ui/installmenu.js new file mode 100644 index 0000000..86a8a45 --- /dev/null +++ b/src/ui/installmenu.js @@ -0,0 +1,38 @@ +export class InstallMenu { + constructor(uiService, config) { + this.ui = uiService; + this.config = config; + } + + show = async () => { + await this.ui.askMultipleChoice("Configure your Codex installation", [ + { + label: "Install path: " + this.config.codexPath, + action: async function () { + console.log("run path selector"); + }, + }, + { + label: "Storage provider module: Disabled (todo)", + action: this.storageProviderOption, + }, + { + label: "Install!", + action: this.performInstall, + }, + { + label: "Cancel", + action: async function () {}, + }, + ]); + }; + + storageProviderOption = async () => { + this.ui.showInfoMessage("This option is not currently available."); + await this.show(); + }; + + performInstall = async () => { + console.log("todo"); + }; +} diff --git a/src/ui/mainmenu.js b/src/ui/mainmenu.js index 3fcf0fd..867c392 100644 --- a/src/ui/mainmenu.js +++ b/src/ui/mainmenu.js @@ -1,13 +1,14 @@ export class MainMenu { - constructor(uiService) { + constructor(uiService, installMenu) { this.ui = uiService; + this.installMenu = installMenu; this.running = true; } show = async () => { this.ui.showLogo(); this.ui.showInfoMessage("hello"); - + while (this.running) { await this.promptMainMenu(); } @@ -15,22 +16,20 @@ export class MainMenu { this.ui.showInfoMessage("K-THX-BYE"); }; - promptMainMenu = async() => { - await this.ui.askMultipleChoice("Select an option",[ + promptMainMenu = async () => { + await this.ui.askMultipleChoice("Select an option", [ { - label: "optionOne", - action: async function() { - console.log("A!") - } - },{ - label: "optionTwo", - action: this.closeMainMenu + label: "Install Codex", + action: this.installMenu.show, }, - ]) + { + label: "Exit", + action: this.closeMainMenu, + }, + ]); }; - closeMainMenu = async() => { - console.log("B!") + closeMainMenu = async () => { this.running = false; }; } diff --git a/src/ui/mainmenu.test.js b/src/ui/mainmenu.test.js new file mode 100644 index 0000000..249a6c7 --- /dev/null +++ b/src/ui/mainmenu.test.js @@ -0,0 +1,51 @@ +import { describe, beforeEach, it, expect, vi } from "vitest"; +import { MainMenu } from "./mainmenu.js"; + +describe("mainmenu", () => { + let mainmenu; + const mockUiService = { + showLogo: vi.fn(), + showInfoMessage: vi.fn(), + askMultipleChoice: vi.fn(), + }; + const mockInstallMenu = { + show: vi.fn(), + }; + + beforeEach(() => { + vi.resetAllMocks(); + + mainmenu = new MainMenu(mockUiService, mockInstallMenu); + + // Presents test getting stuck in main loop. + const originalPrompt = mainmenu.promptMainMenu; + mainmenu.promptMainMenu = async () => { + mainmenu.running = false; + await originalPrompt(); + }; + }); + + it("shows the main menu", async () => { + await mainmenu.show(); + + expect(mockUiService.showLogo).toHaveBeenCalled(); + expect(mockUiService.showInfoMessage).toHaveBeenCalledWith("hello"); // example, delete this later. + expect(mockUiService.showInfoMessage).toHaveBeenCalledWith("K-THX-BYE"); // example, delete this later. + + expect(mockUiService.askMultipleChoice).toHaveBeenCalledWith( + "Select an option", + [ + { label: "Install Codex", action: mockInstallMenu.show }, + { label: "Exit", action: mainmenu.closeMainMenu }, + ], + ); + }); + + it("sets running to false when closeMainMenu is called", async () => { + mainmenu.running = true; + + await mainmenu.closeMainMenu(); + + expect(mainmenu.running).toEqual(false); + }); +}); diff --git a/src/utils/numberSelector.js b/src/utils/numberSelector.js index 8342f6c..1dcc10c 100644 --- a/src/utils/numberSelector.js +++ b/src/utils/numberSelector.js @@ -1,52 +1,49 @@ -import inquirer from "inquirer"; +export class NumberSelector { + constructor(uiService) { + this.uiService = uiService; + } -function getMetricsMult(valueStr, allowMetricPostfixes) { - if (!allowMetricPostfixes) return 1; - const lower = valueStr.toLowerCase(); - if (lower.endsWith("tb") || lower.endsWith("t")) return Math.pow(1024, 4); - if (lower.endsWith("gb") || lower.endsWith("g")) return Math.pow(1024, 3); - if (lower.endsWith("mb") || lower.endsWith("m")) return Math.pow(1024, 2); - if (lower.endsWith("kb") || lower.endsWith("k")) return Math.pow(1024, 1); - return 1; -} - -function getNumericValue(valueStr) { - try { - const num = valueStr.match(/\d+/g); - const result = parseInt(num); - if (isNaN(result) || !isFinite(result)) { - throw new Error("Invalid input received."); + showNumberSelector = async ( + currentValue, + promptMessage, + allowMetricPostfixes, + ) => { + try { + var valueStr = await this.promptForValueStr(promptMessage); + valueStr = valueStr.replaceAll(" ", ""); + const mult = this.getMetricsMult(valueStr, allowMetricPostfixes); + const value = this.getNumericValue(valueStr); + return value * mult; + } catch { + return currentValue; } - return result; - } catch (error) { - console.log("Failed to parse input: " + error.message); - throw error; - } -} + }; -async function promptForValueStr(promptMessage) { - const response = await inquirer.prompt([ - { - type: "input", - name: "valueStr", - message: promptMessage, - }, - ]); - return response.valueStr; -} + getMetricsMult = (valueStr, allowMetricPostfixes) => { + if (!allowMetricPostfixes) return 1; + const lower = valueStr.toLowerCase(); + if (lower.endsWith("tb") || lower.endsWith("t")) return Math.pow(1024, 4); + if (lower.endsWith("gb") || lower.endsWith("g")) return Math.pow(1024, 3); + if (lower.endsWith("mb") || lower.endsWith("m")) return Math.pow(1024, 2); + if (lower.endsWith("kb") || lower.endsWith("k")) return Math.pow(1024, 1); + return 1; + }; -export async function showNumberSelector( - currentValue, - promptMessage, - allowMetricPostfixes, -) { - try { - var valueStr = await promptForValueStr(promptMessage); - valueStr = valueStr.replaceAll(" ", ""); - const mult = getMetricsMult(valueStr, allowMetricPostfixes); - const value = getNumericValue(valueStr); - return value * mult; - } catch { - return currentValue; - } + getNumericValue = (valueStr) => { + try { + const num = valueStr.match(/\d+/g); + const result = parseInt(num); + if (isNaN(result) || !isFinite(result)) { + throw new Error("Invalid input received."); + } + return result; + } catch (error) { + console.log("Failed to parse input: " + error.message); + throw error; + } + }; + + promptForValueStr = async (promptMessage) => { + return this.uiService.askPrompt(promptMessage); + }; } diff --git a/src/utils/numberSelector.test.js b/src/utils/numberSelector.test.js new file mode 100644 index 0000000..4ee3269 --- /dev/null +++ b/src/utils/numberSelector.test.js @@ -0,0 +1,66 @@ +import { describe, beforeEach, it, expect, vi } from "vitest"; +import { NumberSelector } from "./numberSelector.js"; + +describe("number selector", () => { + let numberSelector; + const mockUiService = { + askPrompt: vi.fn(), + }; + + const prompt = "abc??"; + + beforeEach(() => { + vi.resetAllMocks(); + + numberSelector = new NumberSelector(mockUiService); + }); + + it("shows the prompt", async () => { + await numberSelector.showNumberSelector(0, prompt, false); + + expect(mockUiService.askPrompt).toHaveBeenCalledWith(prompt); + }); + + it("returns a number given valid input", async () => { + mockUiService.askPrompt.mockResolvedValue("123"); + + const number = await numberSelector.showNumberSelector(0, prompt, false); + + expect(number).toEqual(123); + }); + + it("returns the current number given invalid input", async () => { + const currentValue = 321; + + mockUiService.askPrompt.mockResolvedValue("what?!"); + + const number = await numberSelector.showNumberSelector( + currentValue, + prompt, + false, + ); + + expect(number).toEqual(currentValue); + }); + + async function run(input) { + mockUiService.askPrompt.mockResolvedValue(input); + return await numberSelector.showNumberSelector(0, prompt, true); + } + + it("allows for metric postfixes (k)", async () => { + expect(await run("1k")).toEqual(1024); + }); + + it("allows for metric postfixes (m)", async () => { + expect(await run("1m")).toEqual(1024 * 1024); + }); + + it("allows for metric postfixes (g)", async () => { + expect(await run("1g")).toEqual(1024 * 1024 * 1024); + }); + + it("allows for metric postfixes (t)", async () => { + expect(await run("1t")).toEqual(1024 * 1024 * 1024 * 1024); + }); +}); diff --git a/src/utils/pathSelector.js b/src/utils/pathSelector.js index c8a5aaa..0f6590b 100644 --- a/src/utils/pathSelector.js +++ b/src/utils/pathSelector.js @@ -1,251 +1,219 @@ -import path from "path"; -import inquirer from "inquirer"; -import boxen from "boxen"; -import chalk from "chalk"; -import fs from "fs"; -import { filesystemSync } from "fs-filesystem"; +export class PathSelector { + constructor(uiService, fsService) { + this.ui = uiService; + this.fs = fsService; -function showMsg(msg) { - console.log( - boxen(chalk.white(msg), { - padding: 1, - margin: 1, - borderStyle: "round", - borderColor: "white", - titleAlignment: "center", - }), - ); -} - -function getAvailableRoots() { - const devices = filesystemSync(); - var mountPoints = []; - Object.keys(devices).forEach(function (key) { - var val = devices[key]; - val.volumes.forEach(function (volume) { - mountPoints.push(volume.mountPoint); - }); - }); - - if (mountPoints.length < 1) { - throw new Error("Failed to detect file system devices."); + this.pathMustExist = true; } - return mountPoints; -} -function splitPath(str) { - return str.replaceAll("\\", "/").split("/"); -} - -function dropEmptyParts(parts) { - var result = []; - parts.forEach(function (part) { - if (part.length > 0) { - result.push(part); + showPathSelector = async (startingPath, pathMustExist) => { + this.pathMustExist = pathMustExist; + this.roots = this.fs.getAvailableRoots(); + this.currentPath = this.splitPath(startingPath); + if (!this.hasValidRoot(this.currentPath)) { + this.currentPath = [roots[0]]; } - }); - return result; -} + while (true) { + this.showCurrent(); + this.ui.askMultiChoice("Select an option:", [ + { + label: "Enter path", + action: this.enterPath, + }, + { + label: "Go up one", + action: this.upOne, + }, + { + label: "Go down one", + action: this.downOne, + }, + { + label: "Create new folder here", + action: this.createSubDir, + }, + { + label: "Select this path", + action: this.selectThisPath, + }, + { + label: "Cancel", + action: this.cancel, + }, + ]); + // var newCurrentPath = currentPath; + // switch (choice.split(".")[0]) { + // case "1": + // newCurrentPath = await enterPath(currentPath, pathMustExist); + // break; + // case "2": + // newCurrentPath = upOne(currentPath); + // break; + // case "3": + // newCurrentPath = await downOne(currentPath); + // break; + // case "4": + // newCurrentPath = await createSubDir(currentPath, pathMustExist); + // break; + // case "5": + // if (pathMustExist && !isDir(combine(currentPath))) { + // console.log("Current path does not exist."); + // break; + // } else { + // return combine(currentPath); + // } + // case "6": + // return combine(currentPath); + // } -function combine(parts) { - const toJoin = dropEmptyParts(parts); - if (toJoin.length == 1) return toJoin[0]; - return path.join(...toJoin); -} + // if (hasValidRoot(roots, newCurrentPath)) { + // currentPath = newCurrentPath; + // } else { + // console.log("Selected path has no valid root."); + // } + } + }; -function combineWith(parts, extra) { - const toJoin = dropEmptyParts(parts); - if (toJoin.length == 1) return path.join(toJoin[0], extra); - return path.join(...toJoin, extra); -} + splitPath = (str) => { + return str.replaceAll("\\", "/").split("/"); + }; -function showCurrent(currentPath) { - const len = currentPath.length; - showMsg(`Current path: [${len}]\n` + combine(currentPath)); + dropEmptyParts = (parts) => { + var result = []; + parts.forEach(function (part) { + if (part.length > 0) { + result.push(part); + } + }); + return result; + }; - if (len < 2) { - showMsg( - "Warning - Known issue:\n" + - "Path selection does not work in root paths on some platforms.\n" + - 'Use "Enter path" or "Create new folder" to navigate and create folders\n' + - "if this is the case for you.", + combine = (parts) => { + const toJoin = this.dropEmptyParts(parts); + if (toJoin.length == 1) return toJoin[0]; + return this.fs.pathJoin(...toJoin); + }; + + combineWith = (parts, extra) => { + const toJoin = this.dropEmptyParts(parts); + if (toJoin.length == 1) return this.fs.pathJoin(toJoin[0], extra); + return this.fs.pathJoin(...toJoin, extra); + }; + + showCurrent = () => { + const len = this.currentPath.length; + this.ui.showInfoMessage( + `Current path: [${len}]\n` + this.combine(this.currentPath), ); - } -} -function hasValidRoot(roots, checkPath) { - if (checkPath.length < 1) return false; - var result = false; - roots.forEach(function (root) { - if (root.toLowerCase() == checkPath[0].toLowerCase()) { - console.log("valid root: " + combine(checkPath)); - result = true; + if (len < 2) { + this.ui.showInfoMessage( + "Warning - Known issue:\n" + + "Path selection does not work in root paths on some platforms.\n" + + 'Use "Enter path" or "Create new folder" to navigate and create folders\n' + + "if this is the case for you.", + ); } - }); - if (!result) console.log("invalid root: " + combine(checkPath)); - return result; -} + }; -async function showMain(currentPath) { - showCurrent(currentPath); - const { choice } = await inquirer - .prompt([ - { - type: "list", - name: "choice", - message: "Select an option:", - choices: [ - "1. Enter path", - "2. Go up one", - "3. Go down one", - "4. Create new folder here", - "5. Select this path", - "6. Cancel", - ], - pageSize: 6, - loop: true, - }, - ]) - .catch(() => { - handleExit(); - return { choice: "6" }; + hasValidRoot = (checkPath) => { + if (checkPath.length < 1) return false; + var result = false; + this.roots.forEach(function (root) { + if (root.toLowerCase() == checkPath[0].toLowerCase()) { + result = true; + } }); + return result; + }; - return choice; -} - -export async function showPathSelector(startingPath, pathMustExist) { - const roots = getAvailableRoots(); - var currentPath = splitPath(startingPath); - if (!hasValidRoot(roots, currentPath)) { - currentPath = [roots[0]]; - } - - while (true) { - const choice = await showMain(currentPath); - - var newCurrentPath = currentPath; - switch (choice.split(".")[0]) { - case "1": - newCurrentPath = await enterPath(currentPath, pathMustExist); - break; - case "2": - newCurrentPath = upOne(currentPath); - break; - case "3": - newCurrentPath = await downOne(currentPath); - break; - case "4": - newCurrentPath = await createSubDir(currentPath, pathMustExist); - break; - case "5": - if (pathMustExist && !isDir(combine(currentPath))) { - console.log("Current path does not exist."); - break; - } else { - return combine(currentPath); - } - case "6": - return combine(currentPath); + updateCurrentIfValidFull = (newFullPath) => { + if (this.pathMustExist && !this.fs.isDir(newFullPath)) { + console.log("The path does not exist."); } + this.updateCurrentIfValidParts(this.splitPath(newFullPath)); + } - if (hasValidRoot(roots, newCurrentPath)) { - currentPath = newCurrentPath; - } else { - console.log("Selected path has no valid root."); + updateCurrentIfValidParts = (newParts) => { + if (!this.hasValidRoot(newParts)) { + console.log("The path has no valid root."); } - } -} - -async function enterPath(currentPath, pathMustExist) { - const response = await inquirer.prompt([ - { - type: "input", - name: "path", - message: "Enter Path:", - }, - ]); - - const newPath = response.path; - if (pathMustExist && !isDir(newPath)) { - console.log("The entered path does not exist."); - return currentPath; - } - return splitPath(response.path); -} - -function upOne(currentPath) { - return currentPath.slice(0, currentPath.length - 1); -} - -export function isDir(dir) { - try { - return fs.lstatSync(dir).isDirectory(); - } catch { - return false; - } -} - -function isSubDir(currentPath, entry) { - const newPath = combineWith(currentPath, entry); - return isDir(newPath); -} - -function getSubDirOptions(currentPath) { - const fullPath = combine(currentPath); - const entries = fs.readdirSync(fullPath); - var result = []; - var counter = 1; - entries.forEach(function (entry) { - if (isSubDir(currentPath, entry)) { - result.push(counter + ". " + entry); - counter = counter + 1; - } - }); - return result; -} - -async function downOne(currentPath) { - const options = getSubDirOptions(currentPath); - if (options.length == 0) { - console.log("There are no subdirectories here."); - return currentPath; + this.currentPath = newParts; } - const { choice } = await inquirer - .prompt([ - { - type: "list", - name: "choice", - message: "Select an subdir:", - choices: options, - pageSize: options.length, - loop: true, - }, - ]) - .catch(() => { - return currentPath; + enterPath = async () => { + const newPath = await this.ui.askPrompt("Enter Path:"); + this.updateCurrentIfValidFull(newPath); + }; + + upOne = () => { + const newParts = this.currentPath.slice(0, this.currentPath.length - 1); + this.updateCurrentIfValidParts(newParts); + }; + + isSubDir = (entry) => { + const newPath = this.combineWith(this.currentPath, entry); + return this.fs.isDir(newPath); + }; + + getSubDirOptions = () => { + const fullPath = this.combine(this.currentPath); + const entries = this.fs.readDir(fullPath); + var result = []; + entries.forEach(function (entry) { + if (this.isSubDir(entry)) { + result.push(entry); + } }); + return result; + }; - const subDir = choice.split(". ")[1]; - return [...currentPath, subDir]; -} - -async function createSubDir(currentPath, pathMustExist) { - const response = await inquirer.prompt([ - { - type: "input", - name: "name", - message: "Enter name:", - }, - ]); - - const name = response.name; - if (name.length < 1) return; - - const fullDir = combineWith(currentPath, name); - if (pathMustExist && !isDir(fullDir)) { - fs.mkdirSync(fullDir); - } - return [...currentPath, name]; + downOne = async () => { + const options = this.getSubDirOptions(); + if (options.length == 0) { + console.log("There are no subdirectories here."); + } + + var selected = ""; + const makeSelector = () => { + + }; + + const { choice } = await inquirer + .prompt([ + { + type: "list", + name: "choice", + message: "Select an subdir:", + choices: options, + pageSize: options.length, + loop: true, + }, + ]) + .catch(() => { + return currentPath; + }); + + const subDir = choice.split(". ")[1]; + return [...currentPath, subDir]; + }; + + createSubDir = async (currentPath, pathMustExist) => { + const response = await inquirer.prompt([ + { + type: "input", + name: "name", + message: "Enter name:", + }, + ]); + + const name = response.name; + if (name.length < 1) return; + + const fullDir = combineWith(currentPath, name); + if (pathMustExist && !isDir(fullDir)) { + // fs.mkdirSync(fullDir); + } + return [...currentPath, name]; + }; }