From a459595ec600facd655a519560ccf7402e884d0b Mon Sep 17 00:00:00 2001 From: Maxim <74974283+maximka76667@users.noreply.github.com> Date: Thu, 5 Mar 2026 16:00:46 +0100 Subject: [PATCH 01/33] fix: add resolve to promise --- electron-app/src/processes/backend.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/electron-app/src/processes/backend.js b/electron-app/src/processes/backend.js index 3c2c9720d..8e49079f5 100644 --- a/electron-app/src/processes/backend.js +++ b/electron-app/src/processes/backend.js @@ -173,8 +173,9 @@ async function stopBackend() { fallbackTimer.unref(); } else { logger.backend.warning("Backend process not found, skipping stop..."); - resolve(); } + + resolve(); }); } From 59b8f0998dc9a3ca1404b4f93b38f9709ac47515 Mon Sep 17 00:00:00 2001 From: Maxim <74974283+maximka76667@users.noreply.github.com> Date: Thu, 5 Mar 2026 16:19:44 +0100 Subject: [PATCH 02/33] go back --- electron-app/src/processes/backend.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/electron-app/src/processes/backend.js b/electron-app/src/processes/backend.js index 8e49079f5..3c2c9720d 100644 --- a/electron-app/src/processes/backend.js +++ b/electron-app/src/processes/backend.js @@ -173,9 +173,8 @@ async function stopBackend() { fallbackTimer.unref(); } else { logger.backend.warning("Backend process not found, skipping stop..."); + resolve(); } - - resolve(); }); } From b832fc473d2db63b7e011f858060f933c26d0f48 Mon Sep 17 00:00:00 2001 From: Maxim <74974283+maximka76667@users.noreply.github.com> Date: Thu, 5 Mar 2026 17:05:28 +0100 Subject: [PATCH 03/33] feat: remove pacman and rpm distributives --- .github/workflows/release.yaml | 3 --- electron-app/package.json | 4 +--- 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 01f375565..a8b3f41dd 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -90,7 +90,6 @@ jobs: if: runner.os == 'Linux' run: | sudo apt-get update - sudo apt-get install -y rpm libarchive-tools # Download ONLY the appropriate backend for this platform - name: Download Linux backend @@ -188,8 +187,6 @@ jobs: electron-app/dist/*.exe electron-app/dist/*.AppImage electron-app/dist/*.deb - electron-app/dist/*.rpm - electron-app/dist/*.pacman electron-app/dist/*.dmg electron-app/dist/*.zip electron-app/dist/*.yml diff --git a/electron-app/package.json b/electron-app/package.json index c5957648d..a64c47b0f 100644 --- a/electron-app/package.json +++ b/electron-app/package.json @@ -107,9 +107,7 @@ "linux": { "target": [ "AppImage", - "deb", - "rpm", - "pacman" + "deb" ], "icon": "icons/512x512.png", "category": "Utility", From 7100c993370194ca535a3d2030c612b8da43fa9e Mon Sep 17 00:00:00 2001 From: Maxim <74974283+maximka76667@users.noreply.github.com> Date: Sat, 7 Mar 2026 12:30:54 +0100 Subject: [PATCH 04/33] feat: add loading when saving settings and fix backend panic --- README.md | 25 ++++++------ electron-app/build.mjs | 5 ++- electron-app/main.js | 2 - electron-app/package.json | 2 +- electron-app/src/processes/backend.js | 12 +++--- .../components/settings/SettingsDialog.tsx | 40 ++++++++++++------- .../src/components/settings/SettingsForm.tsx | 2 +- package.json | 7 ++-- 8 files changed, 56 insertions(+), 39 deletions(-) diff --git a/README.md b/README.md index c7bff8ea9..6f2466a18 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,6 @@ # Hyperloop Control Station H11 - ![Testing View](https://raw.githubusercontent.com/Hyperloop-UPV/webpage/5c1c827d82d380689856ee61af43da30da22e0fc/src/assets/backgrounds/testing-view.png) + +![Testing View](https://raw.githubusercontent.com/Hyperloop-UPV/webpage/5c1c827d82d380689856ee61af43da30da22e0fc/src/assets/backgrounds/testing-view.png) ## Monorepo usage @@ -20,17 +21,17 @@ Before starting, ensure you have the following installed: Our `pnpm-workspace.yaml` defines the following workspaces: -| Workspace | Language | Description | -| :----------------------------- | :------- | :--------------------------------------------- | -| `testing-view` | TS/React | Web interface for telemetry testing | -| `competition-view` | TS/React | UI for the competition | -| `backend` | Go | Data ingestion and pod communication server | -| `packet-sender` | Rust | Utility for simulating vehicle packets | -| `electron-app` | JS | The main Control Station desktop application | -| `@workspace/ui` | TS/React | Shared UI component library (frontend-kit) | -| `@workspace/core` | TS | Shared business logic and types (frontend-kit) | -| `@workspace/eslint-config` | ESLint | Common ESLint configuration (frontend-kit) | -| `@workspace/typescript-config` | TS | Common TypeScript configuration (frontend-kit) | +| Workspace | Language | Description | +| :----------------------------- | :------- | :---------------------------------------------------- | +| `testing-view` | TS/React | Web interface for telemetry testing | +| `competition-view` | TS/React | UI for the competition | +| `backend` | Go | Data ingestion and pod communication server | +| `packet-sender` | Rust | Utility for simulating vehicle packets | +| `hyperloop-control-station` | JS | The main Control Station electron desktop application | +| `@workspace/ui` | TS/React | Shared UI component library (frontend-kit) | +| `@workspace/core` | TS | Shared business logic and types (frontend-kit) | +| `@workspace/eslint-config` | ESLint | Common ESLint configuration (frontend-kit) | +| `@workspace/typescript-config` | TS | Common TypeScript configuration (frontend-kit) | --- diff --git a/electron-app/build.mjs b/electron-app/build.mjs index b4dff1753..49f3ee98a 100644 --- a/electron-app/build.mjs +++ b/electron-app/build.mjs @@ -254,7 +254,10 @@ logger.header("Hyperloop Control Station Build"); if (frontendBuilt && !process.env.CI) { logger.info("Finalizing Electron..."); - run("pnpm --filter electron-app install --frozen-lockfile", __dirname); + run( + "pnpm --filter hyperloop-control-station install --frozen-lockfile", + __dirname + ); } if (allSuccess) { diff --git a/electron-app/main.js b/electron-app/main.js index c468fbbe3..771e0ab1d 100644 --- a/electron-app/main.js +++ b/electron-app/main.js @@ -36,8 +36,6 @@ if (process.platform === "linux") { // Setup IPC handlers for renderer process communication setupIpcHandlers(); -app.setName("hyperloop-control-station"); - // App lifecycle: wait for Electron to be ready app.whenReady().then(async () => { // Get the screen width and height diff --git a/electron-app/package.json b/electron-app/package.json index a64c47b0f..9b95bd2bf 100644 --- a/electron-app/package.json +++ b/electron-app/package.json @@ -1,5 +1,5 @@ { - "name": "electron-app", + "name": "hyperloop-control-station", "version": "1.0.0", "description": "Hyperloop UPV Control Station", "main": "main.js", diff --git a/electron-app/src/processes/backend.js b/electron-app/src/processes/backend.js index 3c2c9720d..acd99ab78 100644 --- a/electron-app/src/processes/backend.js +++ b/electron-app/src/processes/backend.js @@ -108,11 +108,6 @@ async function startBackend(logWindow = null) { return reject(new Error(`Failed to start backend: ${error.message}`)); }); - // If the backend didn't fail in this period of time, resolve the promise - setTimeout(() => { - resolve(backendProcess); - }, 2000); - // Handle process exit backendProcess.on("close", (code) => { logger.backend.info(`Backend process exited with code ${code}`); @@ -131,7 +126,14 @@ async function startBackend(logWindow = null) { // Clear error message after showing lastBackendError = null; } + + backendProcess = null; }); + + // If the backend didn't fail in this period of time, resolve the promise + setTimeout(() => { + resolve(backendProcess); + }, 2000); }); } diff --git a/frontend/testing-view/src/components/settings/SettingsDialog.tsx b/frontend/testing-view/src/components/settings/SettingsDialog.tsx index f62b29d31..9eb6b7e36 100644 --- a/frontend/testing-view/src/components/settings/SettingsDialog.tsx +++ b/frontend/testing-view/src/components/settings/SettingsDialog.tsx @@ -7,7 +7,8 @@ import { DialogHeader, DialogTitle, } from "@workspace/ui/components/shadcn/dialog"; -import { useEffect, useState } from "react"; +import { Loader2 } from "@workspace/ui/icons"; +import { useEffect, useState, useTransition } from "react"; import { AlertTriangle, CheckCircle2, @@ -24,6 +25,7 @@ export const SettingsDialog = () => { const setRestarting = useStore((s) => s.setRestarting); const [localConfig, setLocalConfig] = useState(null); const [isSynced, setIsSynced] = useState(false); + const [isSaving, startSaving] = useTransition(); const loadConfig = async () => { if (window.electronAPI) { @@ -54,24 +56,26 @@ export const SettingsDialog = () => { }, [isSettingsOpen]); const handleSave = async () => { - if (window.electronAPI) { - await window.electronAPI.saveConfig(localConfig); - } else { - console.log("Electron API not available. Using default config."); - } + startSaving(async () => { + if (window.electronAPI) { + await window.electronAPI.saveConfig(localConfig); + } else { + console.log("Electron API not available. Using default config."); + } - setRestarting(true); + setRestarting(true); - setTimeout(() => { - socketService.connect(); - setSettingsOpen(false); - setRestarting(false); - }, config.SETTINGS_RESPONSE_TIMEOUT); + setTimeout(() => { + socketService.connect(); + setSettingsOpen(false); + setRestarting(false); + }, config.SETTINGS_RESPONSE_TIMEOUT); + }); }; return ( - +
System Configuration @@ -101,7 +105,15 @@ export const SettingsDialog = () => { - +
diff --git a/frontend/testing-view/src/components/settings/SettingsForm.tsx b/frontend/testing-view/src/components/settings/SettingsForm.tsx index c69512c55..c0d3df189 100644 --- a/frontend/testing-view/src/components/settings/SettingsForm.tsx +++ b/frontend/testing-view/src/components/settings/SettingsForm.tsx @@ -101,7 +101,7 @@ export const SettingsForm = ({ config, onChange }: SettingsFormProps) => {
{schema.map((section) => (
-

+

{section.title}

diff --git a/package.json b/package.json index 942aad474..80383d0b3 100644 --- a/package.json +++ b/package.json @@ -5,9 +5,10 @@ "dev": "turbo dev", "dev:main": "turbo dev:main", "build": "turbo build", - "build:win": "pnpm --filter electron-app build:win", - "build:linux": "pnpm --filter electron-app build:linux", - "build:mac": "pnpm --filter electron-app build:mac", + "build:win": "pnpm --filter hyperloop-control-station build:win", + "build:linux": "pnpm --filter hyperloop-control-station build:linux", + "build:mac": "pnpm --filter hyperloop-control-station build:mac", + "start": "pnpm --filter hyperloop-control-station start", "lint": "turbo lint", "preview": "turbo preview", "test": "turbo test", From 2599adeb34dd5b5e76dbcb9333bd2c447ea16233 Mon Sep 17 00:00:00 2001 From: Maxim <74974283+maximka76667@users.noreply.github.com> Date: Sat, 7 Mar 2026 17:26:02 +0100 Subject: [PATCH 05/33] fix: apply some fixes --- electron-app/main.js | 10 ++--- electron-app/preload.js | 8 +++- .../config/__tests__/updateTomlValue.test.js | 41 +++++++++++++++++++ electron-app/src/config/configManager.js | 14 ++++++- electron-app/src/processes/backend.js | 12 +++--- electron-app/src/windows/mainWindow.js | 2 + .../charts/components/ChartLegend.tsx | 15 +++---- .../charts/components/ChartSurface.tsx | 10 ++--- .../charts/components/TelemetryChart.tsx | 23 ++++++----- .../src/features/charts/store/chartsSlice.ts | 2 + .../tabs/commands/CommandItem.tsx | 21 ++++++++-- .../tabs/commands/CommandParameters.tsx | 4 +- .../workspace/store/workspacesSlice.ts | 6 +-- frontend/testing-view/src/hooks/useAppMode.ts | 16 ++------ .../testing-view/src/hooks/useBoardData.ts | 2 - .../testing-view/src/hooks/useConnections.ts | 11 ++--- 16 files changed, 129 insertions(+), 68 deletions(-) diff --git a/electron-app/main.js b/electron-app/main.js index 771e0ab1d..07167f22c 100644 --- a/electron-app/main.js +++ b/electron-app/main.js @@ -116,11 +116,11 @@ app.on("window-all-closed", () => { }); // Cleanup before app quits -app.on("before-quit", () => { - // Stop backend process gracefully - stopBackend(); - // Stop packet sender process gracefully - stopPacketSender(); +app.on("before-quit", (e) => { + e.preventDefault(); + Promise.all([stopBackend(), stopPacketSender()]) + .catch((error) => logger.electron.error("Error during shutdown:", error)) + .finally(() => app.exit()); }); // Handle uncaught exceptions globally diff --git a/electron-app/preload.js b/electron-app/preload.js index 2bc27909c..5c3d5c023 100644 --- a/electron-app/preload.js +++ b/electron-app/preload.js @@ -37,6 +37,10 @@ contextBridge.exposeInMainWorld("electronAPI", { // Open folder selection dialog selectFolder: () => ipcRenderer.invoke("select-folder"), // Receive log message from backend - onLog: (callback) => - ipcRenderer.on("log", (_event, value) => callback(value)), + onLog: (callback) => { + const listener = (_event, value) => callback(value); + ipcRenderer.removeAllListeners("log"); + ipcRenderer.on("log", listener); + return () => ipcRenderer.removeListener("log", listener); + }, }); diff --git a/electron-app/src/config/__tests__/updateTomlValue.test.js b/electron-app/src/config/__tests__/updateTomlValue.test.js index 24e400934..a7aa92e54 100644 --- a/electron-app/src/config/__tests__/updateTomlValue.test.js +++ b/electron-app/src/config/__tests__/updateTomlValue.test.js @@ -164,4 +164,45 @@ key2 = "value2"`; 0 ); }); + + it("should not corrupt a [section]-like line inside a multiline string", () => { + const toml = `[app] +note = """ +[not-a-section] +just text +""" +name = "old"`; + + const result = updateTomlValue(toml, "app", "name", "new"); + + expect(result).toContain('name = "new"'); + expect(result).toContain("[not-a-section]"); + }); + + it("should not update a key inside a multiline string", () => { + const toml = `[app] +note = """ +name = "inside multiline" +""" +name = "real"`; + + const result = updateTomlValue(toml, "app", "name", "updated"); + + expect(result).toContain('name = "updated"'); + expect(result).toContain('name = "inside multiline"'); + expect(result.indexOf('name = "inside multiline"')).toBeLessThan( + result.indexOf('name = "updated"') + ); + }); + + it("should handle a multiline string that opens and closes on the same line", () => { + const toml = `[app] +note = """single line multiline""" +name = "old"`; + + const result = updateTomlValue(toml, "app", "name", "new"); + + expect(result).toContain('name = "new"'); + expect(result).toContain('note = """single line multiline"""'); + }); }); diff --git a/electron-app/src/config/configManager.js b/electron-app/src/config/configManager.js index adfeed3b9..9b237c673 100644 --- a/electron-app/src/config/configManager.js +++ b/electron-app/src/config/configManager.js @@ -21,18 +21,30 @@ import { logger } from "../utils/logger.js"; * const updated = updateTomlValue(content, "database", "host", "192.168.1.1"); */ function updateTomlValue(tomlContent, section, key, newValue) { + const lineEnding = tomlContent.includes("\r\n") ? "\r\n" : "\n"; // Split content into lines for processing const lines = tomlContent.split(/\r?\n/); // Track current section while iterating let currentSection = null; // Flag to track if update was successful let updated = false; + // Track if we're inside a multiline string + let inMultilineString = false; // Process each line const result = lines.map((line) => { // Get trimmed version for parsing const trimmed = line.trim(); + // Track multiline string boundaries (""" or ''') + const tripleDoubleQuotes = (line.match(/"""/g) || []).length; + const tripleSingleQuotes = (line.match(/'''/g) || []).length; + if (tripleDoubleQuotes % 2 !== 0 || tripleSingleQuotes % 2 !== 0) { + inMultilineString = !inMultilineString; + return line; + } + if (inMultilineString) return line; + // Track current section const sectionMatch = trimmed.match(/^\[([^\]]+)\]$/); if (sectionMatch) { @@ -104,7 +116,7 @@ function updateTomlValue(tomlContent, section, key, newValue) { } // Join lines back into string - return result.join("\n"); + return result.join(lineEnding); } /** diff --git a/electron-app/src/processes/backend.js b/electron-app/src/processes/backend.js index acd99ab78..d0c77ee99 100644 --- a/electron-app/src/processes/backend.js +++ b/electron-app/src/processes/backend.js @@ -88,8 +88,7 @@ async function startBackend(logWindow = null) { backendProcess.stderr.on("data", (data) => { const errorMsg = data.toString().trim(); logger.backend.error(errorMsg); - // Store the last error message - lastBackendError = errorMsg; + if (errorMsg) lastBackendError = errorMsg; // Send error message to log window if (currentLogWindow && !currentLogWindow.isDestroyed()) { @@ -111,9 +110,9 @@ async function startBackend(logWindow = null) { // Handle process exit backendProcess.on("close", (code) => { logger.backend.info(`Backend process exited with code ${code}`); - // Show error dialog if process crashed (non-zero exit code) + clearTimeout(startupTimer); + if (code !== 0 && code !== null) { - // Build error message with actual error details let errorMessage = `Backend exited with code ${code}`; if (lastBackendError) { @@ -123,15 +122,16 @@ async function startBackend(logWindow = null) { } dialog.showErrorBox("Backend Crashed", errorMessage); - // Clear error message after showing lastBackendError = null; + backendProcess = null; + return reject(new Error(errorMessage)); } backendProcess = null; }); // If the backend didn't fail in this period of time, resolve the promise - setTimeout(() => { + const startupTimer = setTimeout(() => { resolve(backendProcess); }, 2000); }); diff --git a/electron-app/src/windows/mainWindow.js b/electron-app/src/windows/mainWindow.js index ae5fc29b2..0e994eeac 100644 --- a/electron-app/src/windows/mainWindow.js +++ b/electron-app/src/windows/mainWindow.js @@ -81,6 +81,8 @@ function loadView(view) { // Construct path to view HTML file const viewPath = path.join(appPath, "renderer", view, "index.html"); + if (!mainWindow || mainWindow.isDestroyed()) return; + // Check if view file exists if (fs.existsSync(viewPath)) { // Load the view HTML file diff --git a/frontend/testing-view/src/features/charts/components/ChartLegend.tsx b/frontend/testing-view/src/features/charts/components/ChartLegend.tsx index 576c0d74a..eab336026 100644 --- a/frontend/testing-view/src/features/charts/components/ChartLegend.tsx +++ b/frontend/testing-view/src/features/charts/components/ChartLegend.tsx @@ -6,15 +6,15 @@ import { ChartSettings } from "./ChartSettings"; interface ChartLegendProps { chartId: string; series: WorkspaceChartSeries[]; - disabledIndices: Set; - onToggle: (index: number) => void; - onRemove: (variable: string, index: number) => void; + disabledVariables: Set; + onToggle: (seriesKey: string) => void; + onRemove: (variable: string) => void; } export const ChartLegend = ({ chartId, series, - disabledIndices, + disabledVariables, onToggle, onRemove, }: ChartLegendProps) => ( @@ -25,9 +25,9 @@ export const ChartLegend = ({ className="border-border flex items-center overflow-hidden rounded-md border shadow-sm transition-transform active:scale-95" >
); diff --git a/frontend/testing-view/src/features/charts/store/chartsSlice.ts b/frontend/testing-view/src/features/charts/store/chartsSlice.ts index b74827008..ae5ca8427 100644 --- a/frontend/testing-view/src/features/charts/store/chartsSlice.ts +++ b/frontend/testing-view/src/features/charts/store/chartsSlice.ts @@ -33,6 +33,8 @@ export interface ChartsSlice { variable: string, ) => void; + clearCharts: () => void; + /** Sets new buffer size or history limit for a chart. */ setChartHistoryLimit: ( workspaceId: string, diff --git a/frontend/testing-view/src/features/workspace/components/rightSidebar/tabs/commands/CommandItem.tsx b/frontend/testing-view/src/features/workspace/components/rightSidebar/tabs/commands/CommandItem.tsx index 6de562468..7b0374313 100644 --- a/frontend/testing-view/src/features/workspace/components/rightSidebar/tabs/commands/CommandItem.tsx +++ b/frontend/testing-view/src/features/workspace/components/rightSidebar/tabs/commands/CommandItem.tsx @@ -33,8 +33,15 @@ export const CommandItem = ({ item: commandCatalogItem }: CommandItemProps) => { const hasParameters = Object.keys(commandCatalogItem.fields).length > 0; const paramCount = Object.keys(commandCatalogItem.fields).length; + const hasInvalidNumeric = Object.entries(commandCatalogItem.fields).some( + ([key, field]) => + field.kind === "numeric" && + !Number.isFinite(parseFloat(parameterValues[key])), + ); + const handleRun = (e: React.MouseEvent) => { e.stopPropagation(); + if (hasInvalidNumeric) return; const payload = { id: commandCatalogItem.id, @@ -80,8 +87,14 @@ export const CommandItem = ({ item: commandCatalogItem }: CommandItemProps) => {
@@ -128,7 +141,8 @@ export const CommandItem = ({ item: commandCatalogItem }: CommandItemProps) => { {/* Send button at bottom */} diff --git a/frontend/testing-view/src/features/workspace/components/rightSidebar/tabs/commands/CommandParameters.tsx b/frontend/testing-view/src/features/workspace/components/rightSidebar/tabs/commands/CommandParameters.tsx index 52dd8454b..ec3f19cfb 100644 --- a/frontend/testing-view/src/features/workspace/components/rightSidebar/tabs/commands/CommandParameters.tsx +++ b/frontend/testing-view/src/features/workspace/components/rightSidebar/tabs/commands/CommandParameters.tsx @@ -45,7 +45,7 @@ export const CommandParameters = ({ id={field.id} type="number" placeholder={field.type} - value={values[field.id] || ""} + value={values[field.id] ?? ""} onChange={(e) => onChange(field.id, e.target.value)} className="h-8 text-xs" /> @@ -74,7 +74,7 @@ export const CommandParameters = ({ {field.name} activeWorkspace?.id && From 65739de053ac231f40fae40ef4ff172d363af214 Mon Sep 17 00:00:00 2001 From: Maxim <74974283+maximka76667@users.noreply.github.com> Date: Sat, 7 Mar 2026 17:37:43 +0100 Subject: [PATCH 07/33] style: change product name --- electron-app/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/electron-app/package.json b/electron-app/package.json index 9b95bd2bf..2d936c068 100644 --- a/electron-app/package.json +++ b/electron-app/package.json @@ -57,7 +57,7 @@ "owner": "Hyperloop-UPV", "repo": "software" }, - "productName": "Hyperloop-Control-Station", + "productName": "Hyperloop-Ctrl", "directories": { "output": "dist" }, From 1262777cd20146fd1def8b87a91f1cfa0aa0ec2a Mon Sep 17 00:00:00 2001 From: Maxim <74974283+maximka76667@users.noreply.github.com> Date: Wed, 11 Mar 2026 20:32:44 +0100 Subject: [PATCH 08/33] feat: implement versioning --- electron-app/.gitignore | 3 +- electron-app/src/config/configInstance.js | 17 ++++++- electron-app/src/config/configManager.js | 62 +++++++++++++++++------ electron-app/src/utils/paths.js | 31 ++++++++++-- 4 files changed, 92 insertions(+), 21 deletions(-) diff --git a/electron-app/.gitignore b/electron-app/.gitignore index 0c9c87a1f..ce4204b7b 100644 --- a/electron-app/.gitignore +++ b/electron-app/.gitignore @@ -81,4 +81,5 @@ coverage # Config and config backups config.toml -config.toml.backup-* \ No newline at end of file +config.toml.backup-* +version.toml \ No newline at end of file diff --git a/electron-app/src/config/configInstance.js b/electron-app/src/config/configInstance.js index ae8068252..33467fb80 100644 --- a/electron-app/src/config/configInstance.js +++ b/electron-app/src/config/configInstance.js @@ -4,8 +4,13 @@ * Provides async wrappers for ConfigManager operations with lazy initialization. */ +import { app } from "electron"; import { logger } from "../utils/logger.js"; -import { getTemplatePath, getUserConfigPath } from "../utils/paths.js"; +import { + getTemplatePath, + getUserConfigPath, + getVersionFilePath, +} from "../utils/paths.js"; // Store the singleton ConfigManager instance let configManager = null; @@ -26,11 +31,19 @@ async function getConfigManager() { // Get paths for user config and template const userConfigPath = getUserConfigPath(); const templatePath = getTemplatePath(); + const versionFilePath = getVersionFilePath(); // Create new ConfigManager instance - configManager = new ConfigManager(userConfigPath, templatePath); + configManager = new ConfigManager( + userConfigPath, + templatePath, + versionFilePath, + app.getVersion(), + ); + logger.config.info("ConfigManager initialized"); logger.config.path("User config", userConfigPath); + logger.config.path("User version config", versionFilePath); logger.config.path("Template path", templatePath); } diff --git a/electron-app/src/config/configManager.js b/electron-app/src/config/configManager.js index 9b237c673..422fa3c3e 100644 --- a/electron-app/src/config/configManager.js +++ b/electron-app/src/config/configManager.js @@ -4,9 +4,9 @@ * Handles reading, writing, and updating configuration files while maintaining formatting and comments. */ +import TOML from "@iarna/toml"; import fs from "fs"; import path from "path"; -import TOML from "@iarna/toml"; import { logger } from "../utils/logger.js"; /** @@ -69,7 +69,7 @@ function updateTomlValue(tomlContent, section, key, newValue) { // Parse the line: key = value # comment const match = line.match( - /^(\s*)([a-zA-Z_][a-zA-Z0-9_-]*)(\s*=\s*)([^#]+?)((?:\s*#.*)?)$/ + /^(\s*)([a-zA-Z_][a-zA-Z0-9_-]*)(\s*=\s*)([^#]+?)((?:\s*#.*)?)$/, ); // Check if this line matches the key we're looking for @@ -89,7 +89,7 @@ function updateTomlValue(tomlContent, section, key, newValue) { } else if (Array.isArray(newValue)) { // Simple array formatting const items = newValue.map((v) => - typeof v === "string" ? `"${v}"` : v + typeof v === "string" ? `"${v}"` : v, ); formattedValue = `[${items.join(", ")}]`; } else if (newValue === null || newValue === undefined) { @@ -111,7 +111,7 @@ function updateTomlValue(tomlContent, section, key, newValue) { // Warn if key was not found if (!updated) { console.warn( - `Warning: Key "${key}" in section "${section || "root"}" not found` + `Warning: Key "${key}" in section "${section || "root"}" not found`, ); } @@ -165,13 +165,17 @@ class ConfigManager { * Creates a new ConfigManager instance. * @param {string} userConfigPath - Path to the user configuration file. * @param {string} templatePath - Path to the template configuration file. + * @param {string} versionFilePath - Path to the version.toml (app version) + * @param {string} appVersion - Current electron bundle version from package.json * @example - * const manager = new ConfigManager("/path/to/config.toml", "/path/to/template.toml"); + * const manager = new ConfigManager("/path/to/config.toml", "/path/to/template.toml", "/path/to/version.toml" app.getVersion()); */ - constructor(userConfigPath, templatePath) { + constructor(userConfigPath, templatePath, versionFilePath, appVersion) { // Store paths this.userConfigPath = userConfigPath; this.templatePath = templatePath; + this.versionFilePath = versionFilePath; + this.appVersion = appVersion; // Ensure user config exists (copy from template on first run) this.ensureConfigExists(); @@ -192,16 +196,44 @@ class ConfigManager { // Copy template if user config doesn't exist if (!fs.existsSync(this.userConfigPath)) { - if (fs.existsSync(this.templatePath)) { - // Copy template to user config location - fs.copyFileSync(this.templatePath, this.userConfigPath); - logger.config.info( - `Created config from template: ${this.userConfigPath}` - ); - } else { - // Throw error if template is missing + if (!fs.existsSync(this.templatePath)) { throw new Error(`Template not found: ${this.templatePath}`); } + + // Copy template to user config location + fs.copyFileSync(this.templatePath, this.userConfigPath); + logger.config.info( + `Created config from template: ${this.userConfigPath}`, + ); + + fs.writeFileSync( + this.versionFilePath, + `version = "${this.appVersion}"`, + "utf-8", + ); + logger.config.info(`Created app version file: ${this.versionFilePath}`); + return; + } + + // If config does exist, get app's version + // In case version.toml doesn't exists it returns null + const storedVersion = fs.existsSync(this.versionFilePath) + ? (fs + .readFileSync(this.versionFilePath, "utf-8") + .trim() + .match(/^version\s*=\s*"(.+)"$/)?.[1] ?? null) + : null; + + if (storedVersion !== this.appVersion) { + fs.copyFileSync(this.templatePath, this.userConfigPath); + fs.writeFileSync( + this.versionFilePath, + `version = "${this.appVersion}"`, + "utf-8", + ); + logger.config.info( + `Config updated from template (from version ${storedVersion ?? "unknown"} to ${this.appVersion})`, + ); } } @@ -360,4 +392,4 @@ class ConfigManager { } } -export { ConfigManager, updateTomlValue, updateTomlFromObject }; +export { ConfigManager, updateTomlFromObject, updateTomlValue }; diff --git a/electron-app/src/utils/paths.js b/electron-app/src/utils/paths.js index 1d44ceb39..a4b48b79c 100644 --- a/electron-app/src/utils/paths.js +++ b/electron-app/src/utils/paths.js @@ -59,14 +59,14 @@ function getBinaryPath(name) { return path.join( getAppPath(), "binaries", - `${name}-${goos}-${goarch}${ext}` + `${name}-${goos}-${goarch}${ext}`, ); } return path.join( process.resourcesPath, "binaries", - `${name}-${goos}-${goarch}${ext}` + `${name}-${goos}-${goarch}${ext}`, ); } @@ -90,6 +90,25 @@ function getUserConfigPath() { return path.join(configsDir, "config.toml"); } +/** + * Gets the path to the user app version file. + * @returns {string} The absolute path to the user's version.toml file. + * @example + * const configVersionPath = getVersionFilePath(); + * // Development: returns "electron-app/version.toml" + * // Production: returns "userData/configs/version.toml" + */ +function getVersionFilePath() { + if (!app.isPackaged) { + // Development: use local config.toml in project root + return path.join(getAppPath(), "version.toml"); + } + + // Production: user config in userData directory + const userConfigDir = app.getPath("userData"); + return path.join(userConfigDir, "version.toml"); +} + /** * Gets the path to the configuration template file. * @returns {string} The absolute path to the configuration template file. @@ -108,4 +127,10 @@ function getTemplatePath() { return path.join(process.resourcesPath, "config.toml"); } -export { getAppPath, getBinaryPath, getTemplatePath, getUserConfigPath }; +export { + getAppPath, + getBinaryPath, + getTemplatePath, + getUserConfigPath, + getVersionFilePath, +}; From 8d4528ad7e8615ece19f53f4699ea25448c97880 Mon Sep 17 00:00:00 2001 From: Maxim <74974283+maximka76667@users.noreply.github.com> Date: Wed, 11 Mar 2026 20:56:16 +0100 Subject: [PATCH 09/33] fix: make tests pass --- .../ConfigManager.initialization.test.js | 24 +++-- .../ConfigManager.read-write.test.js | 67 ++++++++++++-- .../__tests__/ConfigManager.utilities.test.js | 88 ++++++++++++++++--- 3 files changed, 151 insertions(+), 28 deletions(-) diff --git a/electron-app/src/config/__tests__/ConfigManager.initialization.test.js b/electron-app/src/config/__tests__/ConfigManager.initialization.test.js index 23ec77586..013507407 100644 --- a/electron-app/src/config/__tests__/ConfigManager.initialization.test.js +++ b/electron-app/src/config/__tests__/ConfigManager.initialization.test.js @@ -1,5 +1,5 @@ -import { describe, it, expect, beforeEach, vi } from "vitest"; import fs from "fs"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { ConfigManager } from "../configManager.js"; // Mock fs module @@ -8,6 +8,9 @@ vi.mock("fs"); describe("ConfigManager - Initialization", () => { const templatePath = "/path/to/template.toml"; const userConfigPath = "/path/to/user.toml"; + const versionFilePath = "/path/to/version.toml"; + const appVersion = "1.0.0"; + const appVersionReturnValue = `version = "${appVersion}"`; beforeEach(() => { vi.clearAllMocks(); @@ -15,8 +18,14 @@ describe("ConfigManager - Initialization", () => { it("should create config manager instance", () => { fs.existsSync.mockReturnValue(true); + fs.readFileSync.mockReturnValue(appVersionReturnValue); - const manager = new ConfigManager(userConfigPath, templatePath); + const manager = new ConfigManager( + userConfigPath, + templatePath, + versionFilePath, + appVersion, + ); expect(manager.userConfigPath).toBe(userConfigPath); expect(manager.templatePath).toBe(templatePath); @@ -28,8 +37,9 @@ describe("ConfigManager - Initialization", () => { return true; }); fs.mkdirSync.mockImplementation(() => {}); + fs.readFileSync.mockReturnValue(appVersionReturnValue); - new ConfigManager(userConfigPath, templatePath); + new ConfigManager(userConfigPath, templatePath, versionFilePath, appVersion); expect(fs.mkdirSync).toHaveBeenCalledWith("/path/to", { recursive: true, @@ -39,17 +49,17 @@ describe("ConfigManager - Initialization", () => { it("should copy template if user config does not exist", () => { fs.existsSync.mockImplementation((path) => { if (path === userConfigPath) return false; - if (path === templatePath) return true; return true; }); fs.copyFileSync.mockImplementation(() => {}); + fs.writeFileSync.mockImplementation(() => {}); const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {}); - new ConfigManager(userConfigPath, templatePath); + new ConfigManager(userConfigPath, templatePath, versionFilePath, appVersion); expect(fs.copyFileSync).toHaveBeenCalledWith(templatePath, userConfigPath); expect(consoleSpy).toHaveBeenCalledWith( - expect.stringContaining("Created config from template") + expect.stringContaining("Created config from template"), ); consoleSpy.mockRestore(); @@ -59,7 +69,7 @@ describe("ConfigManager - Initialization", () => { fs.existsSync.mockReturnValue(false); expect(() => { - new ConfigManager(userConfigPath, templatePath); + new ConfigManager(userConfigPath, templatePath, versionFilePath, appVersion); }).toThrow("Template not found"); }); }); diff --git a/electron-app/src/config/__tests__/ConfigManager.read-write.test.js b/electron-app/src/config/__tests__/ConfigManager.read-write.test.js index 9d554f238..5caabd60f 100644 --- a/electron-app/src/config/__tests__/ConfigManager.read-write.test.js +++ b/electron-app/src/config/__tests__/ConfigManager.read-write.test.js @@ -9,6 +9,9 @@ import fs from "fs"; describe("ConfigManager - Read/Write Operations", () => { const templatePath = "/path/to/template.toml"; const userConfigPath = "/path/to/user.toml"; + const versionFilePath = "/path/to/version.toml"; + const appVersion = "1.0.0"; + const appVersionReturnValue = `version = "${appVersion}"`; const mockTomlContent = `# User Config name = "test" enabled = true @@ -23,9 +26,15 @@ host = "localhost"`; describe("read", () => { it("should read and parse TOML config", () => { fs.existsSync.mockReturnValue(true); + fs.readFileSync.mockReturnValueOnce(appVersionReturnValue); fs.readFileSync.mockReturnValue(mockTomlContent); - const manager = new ConfigManager(userConfigPath, templatePath); + const manager = new ConfigManager( + userConfigPath, + templatePath, + versionFilePath, + appVersion, + ); const config = manager.read(); expect(config).toHaveProperty("name", "test"); @@ -35,20 +44,32 @@ host = "localhost"`; it("should throw error on invalid TOML", () => { fs.existsSync.mockReturnValue(true); + fs.readFileSync.mockReturnValueOnce(appVersionReturnValue); fs.readFileSync.mockReturnValue("invalid toml [[["); - const manager = new ConfigManager(userConfigPath, templatePath); + const manager = new ConfigManager( + userConfigPath, + templatePath, + versionFilePath, + appVersion, + ); expect(() => manager.read()).toThrow("Failed to read config"); }); it("should throw error on file read failure", () => { fs.existsSync.mockReturnValue(true); + fs.readFileSync.mockReturnValueOnce(appVersionReturnValue); fs.readFileSync.mockImplementation(() => { throw new Error("Permission denied"); }); - const manager = new ConfigManager(userConfigPath, templatePath); + const manager = new ConfigManager( + userConfigPath, + templatePath, + versionFilePath, + appVersion, + ); expect(() => manager.read()).toThrow("Failed to read config"); }); @@ -57,9 +78,15 @@ host = "localhost"`; describe("readRaw", () => { it("should return raw TOML content", () => { fs.existsSync.mockReturnValue(true); + fs.readFileSync.mockReturnValueOnce(appVersionReturnValue); fs.readFileSync.mockReturnValue(mockTomlContent); - const manager = new ConfigManager(userConfigPath, templatePath); + const manager = new ConfigManager( + userConfigPath, + templatePath, + versionFilePath, + appVersion, + ); const raw = manager.readRaw(); expect(raw).toBe(mockTomlContent); @@ -70,11 +97,17 @@ host = "localhost"`; describe("update", () => { it("should update config from object", () => { fs.existsSync.mockReturnValue(true); + fs.readFileSync.mockReturnValueOnce(appVersionReturnValue); fs.readFileSync.mockReturnValue(mockTomlContent); fs.writeFileSync.mockImplementation(() => {}); const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {}); - const manager = new ConfigManager(userConfigPath, templatePath); + const manager = new ConfigManager( + userConfigPath, + templatePath, + versionFilePath, + appVersion, + ); const result = manager.update({ name: "updated", enabled: false, @@ -91,12 +124,18 @@ host = "localhost"`; it("should throw error on update failure", () => { fs.existsSync.mockReturnValue(true); + fs.readFileSync.mockReturnValueOnce(appVersionReturnValue); fs.readFileSync.mockReturnValue(mockTomlContent); fs.writeFileSync.mockImplementation(() => { throw new Error("Write failed"); }); - const manager = new ConfigManager(userConfigPath, templatePath); + const manager = new ConfigManager( + userConfigPath, + templatePath, + versionFilePath, + appVersion, + ); expect(() => manager.update({ name: "test" })).toThrow( "Failed to update config" @@ -107,11 +146,17 @@ host = "localhost"`; describe("updateValue", () => { it("should update a single value", () => { fs.existsSync.mockReturnValue(true); + fs.readFileSync.mockReturnValueOnce(appVersionReturnValue); fs.readFileSync.mockReturnValue(mockTomlContent); fs.writeFileSync.mockImplementation(() => {}); const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {}); - const manager = new ConfigManager(userConfigPath, templatePath); + const manager = new ConfigManager( + userConfigPath, + templatePath, + versionFilePath, + appVersion, + ); const result = manager.updateValue("database", "host", "192.168.1.1"); expect(result).toBe(true); @@ -125,11 +170,17 @@ host = "localhost"`; it("should throw error on value update failure", () => { fs.existsSync.mockReturnValue(true); + fs.readFileSync.mockReturnValueOnce(appVersionReturnValue); fs.readFileSync.mockImplementation(() => { throw new Error("Read failed"); }); - const manager = new ConfigManager(userConfigPath, templatePath); + const manager = new ConfigManager( + userConfigPath, + templatePath, + versionFilePath, + appVersion, + ); expect(() => manager.updateValue("section", "key", "value")).toThrow( "Failed to update value" diff --git a/electron-app/src/config/__tests__/ConfigManager.utilities.test.js b/electron-app/src/config/__tests__/ConfigManager.utilities.test.js index f5b00ea0d..90e49fa8b 100644 --- a/electron-app/src/config/__tests__/ConfigManager.utilities.test.js +++ b/electron-app/src/config/__tests__/ConfigManager.utilities.test.js @@ -1,5 +1,5 @@ -import { describe, it, expect, beforeEach, vi } from "vitest"; import fs from "fs"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { ConfigManager } from "../configManager.js"; // Mock fs module @@ -8,6 +8,9 @@ vi.mock("fs"); describe("ConfigManager - Utility Methods", () => { const templatePath = "/path/to/template.toml"; const userConfigPath = "/path/to/user.toml"; + const versionFilePath = "/path/to/version.toml"; + const appVersion = "1.0.0"; + const appVersionReturnValue = `version = "${appVersion}"`; const mockTomlContent = `# User Config name = "test" enabled = true @@ -22,19 +25,26 @@ host = "localhost"`; describe("resetToTemplate", () => { it("should reset config to template", () => { fs.existsSync.mockReturnValue(true); + fs.readFileSync.mockReturnValue(appVersionReturnValue); fs.copyFileSync.mockImplementation(() => {}); const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {}); - const manager = new ConfigManager(userConfigPath, templatePath); + const manager = new ConfigManager( + userConfigPath, + templatePath, + versionFilePath, + appVersion, + ); + const result = manager.resetToTemplate(); expect(result).toBe(true); expect(fs.copyFileSync).toHaveBeenCalledWith( templatePath, - userConfigPath + userConfigPath, ); expect(consoleSpy).toHaveBeenCalledWith( - expect.stringContaining("Config reset to template") + expect.stringContaining("Config reset to template"), ); consoleSpy.mockRestore(); @@ -45,19 +55,31 @@ host = "localhost"`; if (path === templatePath) return false; return true; }); + fs.readFileSync.mockReturnValue(appVersionReturnValue); - const manager = new ConfigManager(userConfigPath, templatePath); + const manager = new ConfigManager( + userConfigPath, + templatePath, + versionFilePath, + appVersion, + ); expect(() => manager.resetToTemplate()).toThrow("Failed to reset config"); }); it("should throw error on copy failure", () => { fs.existsSync.mockReturnValue(true); + fs.readFileSync.mockReturnValue(appVersionReturnValue); fs.copyFileSync.mockImplementation(() => { throw new Error("Copy failed"); }); - const manager = new ConfigManager(userConfigPath, templatePath); + const manager = new ConfigManager( + userConfigPath, + templatePath, + versionFilePath, + appVersion, + ); expect(() => manager.resetToTemplate()).toThrow("Failed to reset config"); }); @@ -66,16 +88,22 @@ host = "localhost"`; describe("backup", () => { it("should create backup with timestamp", () => { fs.existsSync.mockReturnValue(true); + fs.readFileSync.mockReturnValue(appVersionReturnValue); fs.copyFileSync.mockImplementation(() => {}); const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {}); - const manager = new ConfigManager(userConfigPath, templatePath); + const manager = new ConfigManager( + userConfigPath, + templatePath, + versionFilePath, + appVersion, + ); const backupPath = manager.backup(); expect(backupPath).toContain(".backup-"); expect(fs.copyFileSync).toHaveBeenCalledWith(userConfigPath, backupPath); expect(consoleSpy).toHaveBeenCalledWith( - expect.stringContaining("Config backed up to") + expect.stringContaining("Config backed up to"), ); consoleSpy.mockRestore(); @@ -83,20 +111,33 @@ host = "localhost"`; it("should throw error on backup failure", () => { fs.existsSync.mockReturnValue(true); + fs.readFileSync.mockReturnValue(appVersionReturnValue); fs.copyFileSync.mockImplementation(() => { throw new Error("Backup failed"); }); - const manager = new ConfigManager(userConfigPath, templatePath); + const manager = new ConfigManager( + userConfigPath, + templatePath, + versionFilePath, + appVersion, + ); expect(() => manager.backup()).toThrow("Failed to backup config"); }); it("should generate unique backup names", async () => { fs.existsSync.mockReturnValue(true); + fs.readFileSync.mockReturnValue(appVersionReturnValue); fs.copyFileSync.mockImplementation(() => {}); - const manager = new ConfigManager(userConfigPath, templatePath); + const manager = new ConfigManager( + userConfigPath, + templatePath, + versionFilePath, + appVersion, + ); + const backup1 = manager.backup(); await new Promise((resolve) => setTimeout(resolve, 100)); @@ -111,9 +152,16 @@ host = "localhost"`; describe("validate", () => { it("should return valid for correct TOML", () => { fs.existsSync.mockReturnValue(true); + fs.readFileSync.mockReturnValueOnce(appVersionReturnValue); fs.readFileSync.mockReturnValue(mockTomlContent); - const manager = new ConfigManager(userConfigPath, templatePath); + const manager = new ConfigManager( + userConfigPath, + templatePath, + versionFilePath, + appVersion, + ); + const result = manager.validate(); expect(result).toEqual({ valid: true }); @@ -121,9 +169,16 @@ host = "localhost"`; it("should return error for invalid TOML", () => { fs.existsSync.mockReturnValue(true); + fs.readFileSync.mockReturnValueOnce(appVersionReturnValue); fs.readFileSync.mockReturnValue("invalid [[["); - const manager = new ConfigManager(userConfigPath, templatePath); + const manager = new ConfigManager( + userConfigPath, + templatePath, + versionFilePath, + appVersion, + ); + const result = manager.validate(); expect(result.valid).toBe(false); @@ -132,11 +187,18 @@ host = "localhost"`; it("should handle file read errors during validation", () => { fs.existsSync.mockReturnValue(true); + fs.readFileSync.mockReturnValueOnce(appVersionReturnValue); fs.readFileSync.mockImplementation(() => { throw new Error("Read failed"); }); - const manager = new ConfigManager(userConfigPath, templatePath); + const manager = new ConfigManager( + userConfigPath, + templatePath, + versionFilePath, + appVersion, + ); + const result = manager.validate(); expect(result.valid).toBe(false); From a2e9d45120ff798122dd499cd6a48c8d3b702135 Mon Sep 17 00:00:00 2001 From: Maxim <74974283+maximka76667@users.noreply.github.com> Date: Wed, 11 Mar 2026 21:36:06 +0100 Subject: [PATCH 10/33] fix: some errors --- electron-app/main.js | 1 - electron-app/src/processes/backend.js | 10 +++++----- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/electron-app/main.js b/electron-app/main.js index 07167f22c..ae651860a 100644 --- a/electron-app/main.js +++ b/electron-app/main.js @@ -57,7 +57,6 @@ app.whenReady().then(async () => { logger.electron.header("Backend process spawned"); } catch (error) { // Start backend already shows these errors - return; } // Create main application window diff --git a/electron-app/src/processes/backend.js b/electron-app/src/processes/backend.js index d0c77ee99..92dffe7eb 100644 --- a/electron-app/src/processes/backend.js +++ b/electron-app/src/processes/backend.js @@ -54,13 +54,13 @@ async function startBackend(logWindow = null) { logger.backend.error(`Backend binary not found: ${backendBin}`); dialog.showErrorBox( "Error", - `Backend binary not found at: ${backendBin}` + `Backend binary not found at: ${backendBin}`, ); return reject(new Error(`Backend binary not found: ${backendBin}`)); } logger.backend.info( - `Starting backend: ${backendBin}, config: ${configPath}` + `Starting backend: ${backendBin}, config: ${configPath}`, ); // Set working directory to backend/cmd in development, or resources in production @@ -88,7 +88,7 @@ async function startBackend(logWindow = null) { backendProcess.stderr.on("data", (data) => { const errorMsg = data.toString().trim(); logger.backend.error(errorMsg); - if (errorMsg) lastBackendError = errorMsg; + lastBackendError = errorMsg; // Send error message to log window if (currentLogWindow && !currentLogWindow.isDestroyed()) { @@ -102,7 +102,7 @@ async function startBackend(logWindow = null) { logger.backend.error(`Failed to start backend: ${error.message}`); dialog.showErrorBox( "Backend Error", - `Failed to start backend: ${error.message}` + `Failed to start backend: ${error.message}`, ); return reject(new Error(`Failed to start backend: ${error.message}`)); }); @@ -166,7 +166,7 @@ async function stopBackend() { const fallbackTimer = setTimeout(() => { if (localBackendProcess && !localBackendProcess.killed) { logger.backend.warning( - "Backend did not exit gracefully, force killing..." + "Backend did not exit gracefully, force killing...", ); localBackendProcess.kill("SIGKILL"); } From 18840713342ea21336609e2e68735e760f32a353 Mon Sep 17 00:00:00 2001 From: Maxim <74974283+maximka76667@users.noreply.github.com> Date: Thu, 12 Mar 2026 11:48:18 +0100 Subject: [PATCH 11/33] bump: icons-master --- frontend/frontend-kit/ui/package.json | 2 +- pnpm-lock.yaml | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/frontend/frontend-kit/ui/package.json b/frontend/frontend-kit/ui/package.json index 78d4fb424..7e7c613eb 100644 --- a/frontend/frontend-kit/ui/package.json +++ b/frontend/frontend-kit/ui/package.json @@ -36,7 +36,7 @@ "zustand": "^5.0.11" }, "devDependencies": { - "@maximka76667/icons-master": "^1.0.1", + "@maximka76667/icons-master": "^1.0.2", "@tailwindcss/postcss": "^4.1.18", "@turbo/gen": "^2.8.3", "@types/node": "^25.2.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0597f9802..b1ce03d81 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -196,8 +196,8 @@ importers: version: 5.0.11(@types/react@19.2.11)(immer@11.1.0)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)) devDependencies: '@maximka76667/icons-master': - specifier: ^1.0.1 - version: 1.0.1 + specifier: ^1.0.2 + version: 1.0.2 '@tailwindcss/postcss': specifier: ^4.1.18 version: 4.1.18 @@ -759,8 +759,8 @@ packages: resolution: {integrity: sha512-9QOtNffcOF/c1seMCDnjckb3R9WHcG34tky+FHpNKKCW0wc/scYLwMtO+ptyGUfMW0/b/n4qRiALlaFHc9Oj7Q==} engines: {node: '>= 10.0.0'} - '@maximka76667/icons-master@1.0.1': - resolution: {integrity: sha512-2mQG0k3p3c2b8KctuH/1KGgy0nlBMTyzC0hguEDNNA3bYzwiehwGJ63bspbQvAbNHxENJ8YhdJFvWX4tDK+W/g==} + '@maximka76667/icons-master@1.0.2': + resolution: {integrity: sha512-Dc81jga69pHqXx3iS0uYmYSaoZGQtisGkfoTH7pyP+GZ8FvZ1jHzUU4B55sTW1Z0XvsUegLppXDW8e3ES7TNVg==} hasBin: true '@nodelib/fs.scandir@2.1.5': @@ -5197,7 +5197,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@maximka76667/icons-master@1.0.1': {} + '@maximka76667/icons-master@1.0.2': {} '@nodelib/fs.scandir@2.1.5': dependencies: From 9ee2364a887cbd16adb9abdfc068192a72debcd0 Mon Sep 17 00:00:00 2001 From: Maxim <74974283+maximka76667@users.noreply.github.com> Date: Thu, 12 Mar 2026 11:59:17 +0100 Subject: [PATCH 12/33] bump: bump icons master --- frontend/frontend-kit/ui/package.json | 2 +- pnpm-lock.yaml | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/frontend/frontend-kit/ui/package.json b/frontend/frontend-kit/ui/package.json index 7e7c613eb..f6c5e533e 100644 --- a/frontend/frontend-kit/ui/package.json +++ b/frontend/frontend-kit/ui/package.json @@ -36,7 +36,7 @@ "zustand": "^5.0.11" }, "devDependencies": { - "@maximka76667/icons-master": "^1.0.2", + "@maximka76667/icons-master": "^1.0.3", "@tailwindcss/postcss": "^4.1.18", "@turbo/gen": "^2.8.3", "@types/node": "^25.2.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b1ce03d81..41644e296 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -196,8 +196,8 @@ importers: version: 5.0.11(@types/react@19.2.11)(immer@11.1.0)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)) devDependencies: '@maximka76667/icons-master': - specifier: ^1.0.2 - version: 1.0.2 + specifier: ^1.0.3 + version: 1.0.3 '@tailwindcss/postcss': specifier: ^4.1.18 version: 4.1.18 @@ -759,8 +759,8 @@ packages: resolution: {integrity: sha512-9QOtNffcOF/c1seMCDnjckb3R9WHcG34tky+FHpNKKCW0wc/scYLwMtO+ptyGUfMW0/b/n4qRiALlaFHc9Oj7Q==} engines: {node: '>= 10.0.0'} - '@maximka76667/icons-master@1.0.2': - resolution: {integrity: sha512-Dc81jga69pHqXx3iS0uYmYSaoZGQtisGkfoTH7pyP+GZ8FvZ1jHzUU4B55sTW1Z0XvsUegLppXDW8e3ES7TNVg==} + '@maximka76667/icons-master@1.0.3': + resolution: {integrity: sha512-aYeETtotNichct/7lRBwTEuPDyPko/QJ+5QsnDGK9lraie7CDC4ofw1TqJxfk44BtzD+Eg/IVvHl45b20RyAHA==} hasBin: true '@nodelib/fs.scandir@2.1.5': @@ -5197,7 +5197,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@maximka76667/icons-master@1.0.2': {} + '@maximka76667/icons-master@1.0.3': {} '@nodelib/fs.scandir@2.1.5': dependencies: From fb8f6d6fe49322455f3b41ad21f2a0b904b88b6a Mon Sep 17 00:00:00 2001 From: Maxim <74974283+maximka76667@users.noreply.github.com> Date: Thu, 12 Mar 2026 12:00:03 +0100 Subject: [PATCH 13/33] docs: icons master does support all systems now --- frontend/frontend-kit/ui/README.md | 4 ---- 1 file changed, 4 deletions(-) diff --git a/frontend/frontend-kit/ui/README.md b/frontend/frontend-kit/ui/README.md index 713357c0a..7b5a9e4f9 100644 --- a/frontend/frontend-kit/ui/README.md +++ b/frontend/frontend-kit/ui/README.md @@ -17,10 +17,6 @@ This package is the main UI and React shared component library for the Hyperloop We use a custom Rust-based tool, **icons-master**, to manage our Lucide icon exports. This tool helps keep our icons organized by category and ensures we don't have duplicate exports. -> **⚠️ Windows Support Only** -> -> The `icons-master` CLI currently only supports **Windows**. If you are on macOS or Linux, you must manually update the `.ts` files in `src/icons/`. - ### Usage Here are the scripts you can run: From e08b570ba59103fb5af49c145eb6b154872a7eb4 Mon Sep 17 00:00:00 2001 From: Maxim <74974283+maximka76667@users.noreply.github.com> Date: Thu, 12 Mar 2026 12:59:07 +0100 Subject: [PATCH 14/33] docs: fix comment --- electron-app/src/utils/paths.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/electron-app/src/utils/paths.js b/electron-app/src/utils/paths.js index a4b48b79c..e6ba2b5ca 100644 --- a/electron-app/src/utils/paths.js +++ b/electron-app/src/utils/paths.js @@ -100,11 +100,11 @@ function getUserConfigPath() { */ function getVersionFilePath() { if (!app.isPackaged) { - // Development: use local config.toml in project root + // Development: use local version.toml in project root return path.join(getAppPath(), "version.toml"); } - // Production: user config in userData directory + // Production: user version in userData directory const userConfigDir = app.getPath("userData"); return path.join(userConfigDir, "version.toml"); } From ccf6f54c991882cbbeec73256a39039da957e7db Mon Sep 17 00:00:00 2001 From: Maxim <74974283+maximka76667@users.noreply.github.com> Date: Thu, 12 Mar 2026 13:03:08 +0100 Subject: [PATCH 15/33] docs: fix docs --- electron-app/BUILD.md | 3 --- 1 file changed, 3 deletions(-) diff --git a/electron-app/BUILD.md b/electron-app/BUILD.md index 855ba3ff2..f681a9452 100644 --- a/electron-app/BUILD.md +++ b/electron-app/BUILD.md @@ -34,9 +34,6 @@ node build.mjs --backend # Build only the Testing View node build.mjs --testing-view - -# Build only the Packet Sender -node build.mjs --packet-sender ``` ## Platform Targeting From 4ea880ea1cf46540087f6c5766d09f82823bc5e0 Mon Sep 17 00:00:00 2001 From: Maxim <74974283+maximka76667@users.noreply.github.com> Date: Thu, 12 Mar 2026 13:16:35 +0100 Subject: [PATCH 16/33] docs: remove packet-sender mentions --- electron-app/BUILD.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/electron-app/BUILD.md b/electron-app/BUILD.md index f681a9452..e001a1397 100644 --- a/electron-app/BUILD.md +++ b/electron-app/BUILD.md @@ -1,19 +1,18 @@ # Hyperloop Control Station Build System -The project uses a unified, modular build script (`electron-app/build.mjs`) to handle building the backend (Go), packet sender (Rust), and frontends (React/Vite) for the Electron application. +The project uses a unified, modular build script (`electron-app/build.mjs`) to handle building the backend (Go), and frontends (React/Vite) for the Electron application. ## Prerequisites - **Node.js** & **pnpm** - **Go** (1.21+) -- **Rust/Cargo** (for Packet Sender) ## Basic Usage Run the build script from the `electron-app` directory (or via npm scripts). ```sh -# Build EVERYTHING (Backend, Packet Sender, Frontends) +# Build EVERYTHING (Backend, Frontends) pnpm build # OR From cfbbd6042a9e43be7a3a59dd61c8f00f079e3d00 Mon Sep 17 00:00:00 2001 From: Maxim <74974283+maximka76667@users.noreply.github.com> Date: Fri, 13 Mar 2026 15:36:33 +0100 Subject: [PATCH 17/33] feat: replace branch input with combobox --- frontend/frontend-kit/ui/package.json | 2 + .../ui/src/components/shadcn/button.tsx | 38 +- .../ui/src/components/shadcn/combobox.tsx | 310 ++++++ .../ui/src/components/shadcn/index.ts | 2 + .../ui/src/components/shadcn/input-group.tsx | 170 ++++ .../ui/src/components/shadcn/input.tsx | 6 +- .../ui/src/components/shadcn/textarea.tsx | 2 +- .../ui/src/icons/notifications.ts | 8 +- frontend/testing-view/package.json | 1 + .../src/components/settings/ComboboxField.tsx | 69 ++ .../components/settings/SettingsDialog.tsx | 128 ++- .../src/components/settings/SettingsForm.tsx | 19 +- .../src/constants/settingsSchema.ts | 6 +- .../filtering/components/FilterDialog.tsx | 15 +- .../testing-view/src/types/common/settings.ts | 5 +- pnpm-lock.yaml | 908 +++++++++++++++++- 16 files changed, 1564 insertions(+), 125 deletions(-) create mode 100644 frontend/frontend-kit/ui/src/components/shadcn/combobox.tsx create mode 100644 frontend/frontend-kit/ui/src/components/shadcn/input-group.tsx create mode 100644 frontend/testing-view/src/components/settings/ComboboxField.tsx diff --git a/frontend/frontend-kit/ui/package.json b/frontend/frontend-kit/ui/package.json index f6c5e533e..bd2791edc 100644 --- a/frontend/frontend-kit/ui/package.json +++ b/frontend/frontend-kit/ui/package.json @@ -10,6 +10,7 @@ "icon:remove": "icons-master remove" }, "dependencies": { + "@base-ui/react": "^1.3.0", "@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", @@ -26,6 +27,7 @@ "clsx": "^2.1.1", "lucide-react": "^0.563.0", "next-themes": "^0.4.6", + "radix-ui": "^1.4.3", "react": "^19.2.4", "react-dom": "^19.2.4", "react-resizable-panels": "^4.6.0", diff --git a/frontend/frontend-kit/ui/src/components/shadcn/button.tsx b/frontend/frontend-kit/ui/src/components/shadcn/button.tsx index b25fb8e68..53fc38d33 100644 --- a/frontend/frontend-kit/ui/src/components/shadcn/button.tsx +++ b/frontend/frontend-kit/ui/src/components/shadcn/button.tsx @@ -1,30 +1,32 @@ -import { Slot } from "@radix-ui/react-slot"; -import { cva, type VariantProps } from "class-variance-authority"; -import * as React from "react"; +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" +import { Slot } from "radix-ui" -import { cn } from "@workspace/ui/lib/utils"; +import { cn } from "@workspace/ui/lib/utils" const buttonVariants = cva( - "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", + "inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", { variants: { variant: { default: "bg-primary text-primary-foreground hover:bg-primary/90", destructive: - "bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", + "bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40", outline: - "border bg-background text-foreground shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50", + "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50", secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80", ghost: - "text-foreground hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", + "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", link: "text-primary underline-offset-4 hover:underline", }, size: { default: "h-9 px-4 py-2 has-[>svg]:px-3", - sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5", + xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3", + sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5", lg: "h-10 rounded-md px-6 has-[>svg]:px-4", icon: "size-9", + "icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3", "icon-sm": "size-8", "icon-lg": "size-10", }, @@ -33,28 +35,30 @@ const buttonVariants = cva( variant: "default", size: "default", }, - }, -); + } +) function Button({ className, - variant, - size, + variant = "default", + size = "default", asChild = false, ...props }: React.ComponentProps<"button"> & VariantProps & { - asChild?: boolean; + asChild?: boolean }) { - const Comp = asChild ? Slot : "button"; + const Comp = asChild ? Slot.Root : "button" return ( - ); + ) } -export { Button, buttonVariants }; +export { Button, buttonVariants } diff --git a/frontend/frontend-kit/ui/src/components/shadcn/combobox.tsx b/frontend/frontend-kit/ui/src/components/shadcn/combobox.tsx new file mode 100644 index 000000000..36b00ad18 --- /dev/null +++ b/frontend/frontend-kit/ui/src/components/shadcn/combobox.tsx @@ -0,0 +1,310 @@ +"use client" + +import * as React from "react" +import { Combobox as ComboboxPrimitive } from "@base-ui/react" +import { CheckIcon, ChevronDownIcon, XIcon } from "lucide-react" + +import { cn } from "@workspace/ui/lib/utils" +import { Button } from "@workspace/ui/components/shadcn/button" +import { + InputGroup, + InputGroupAddon, + InputGroupButton, + InputGroupInput, +} from "@workspace/ui/components/shadcn/input-group" + +const Combobox = ComboboxPrimitive.Root + +function ComboboxValue({ ...props }: ComboboxPrimitive.Value.Props) { + return +} + +function ComboboxTrigger({ + className, + children, + ...props +}: ComboboxPrimitive.Trigger.Props) { + return ( + + {children} + + + ) +} + +function ComboboxClear({ className, ...props }: ComboboxPrimitive.Clear.Props) { + return ( + } + className={cn(className)} + {...props} + > + + + ) +} + +function ComboboxInput({ + className, + children, + disabled = false, + showTrigger = true, + showClear = false, + ...props +}: ComboboxPrimitive.Input.Props & { + showTrigger?: boolean + showClear?: boolean +}) { + return ( + + } + {...props} + /> + + {showTrigger && ( + + + + )} + {showClear && } + + {children} + + ) +} + +function ComboboxContent({ + className, + side = "bottom", + sideOffset = 6, + align = "start", + alignOffset = 0, + anchor, + ...props +}: ComboboxPrimitive.Popup.Props & + Pick< + ComboboxPrimitive.Positioner.Props, + "side" | "align" | "sideOffset" | "alignOffset" | "anchor" + >) { + return ( + + + + + + ) +} + +function ComboboxList({ className, ...props }: ComboboxPrimitive.List.Props) { + return ( + + ) +} + +function ComboboxItem({ + className, + children, + ...props +}: ComboboxPrimitive.Item.Props) { + return ( + + {children} + + } + > + + + + ) +} + +function ComboboxGroup({ className, ...props }: ComboboxPrimitive.Group.Props) { + return ( + + ) +} + +function ComboboxLabel({ + className, + ...props +}: ComboboxPrimitive.GroupLabel.Props) { + return ( + + ) +} + +function ComboboxCollection({ ...props }: ComboboxPrimitive.Collection.Props) { + return ( + + ) +} + +function ComboboxEmpty({ className, ...props }: ComboboxPrimitive.Empty.Props) { + return ( + + ) +} + +function ComboboxSeparator({ + className, + ...props +}: ComboboxPrimitive.Separator.Props) { + return ( + + ) +} + +function ComboboxChips({ + className, + ...props +}: React.ComponentPropsWithRef & + ComboboxPrimitive.Chips.Props) { + return ( + + ) +} + +function ComboboxChip({ + className, + children, + showRemove = true, + ...props +}: ComboboxPrimitive.Chip.Props & { + showRemove?: boolean +}) { + return ( + + {children} + {showRemove && ( + } + className="-ml-1 opacity-50 hover:opacity-100" + data-slot="combobox-chip-remove" + > + + + )} + + ) +} + +function ComboboxChipsInput({ + className, + children, + ...props +}: ComboboxPrimitive.Input.Props) { + return ( + + ) +} + +function useComboboxAnchor() { + return React.useRef(null) +} + +export { + Combobox, + ComboboxInput, + ComboboxContent, + ComboboxList, + ComboboxItem, + ComboboxGroup, + ComboboxLabel, + ComboboxCollection, + ComboboxEmpty, + ComboboxSeparator, + ComboboxChips, + ComboboxChip, + ComboboxChipsInput, + ComboboxTrigger, + ComboboxValue, + useComboboxAnchor, +} diff --git a/frontend/frontend-kit/ui/src/components/shadcn/index.ts b/frontend/frontend-kit/ui/src/components/shadcn/index.ts index 9f5397a79..d2e4f43b5 100644 --- a/frontend/frontend-kit/ui/src/components/shadcn/index.ts +++ b/frontend/frontend-kit/ui/src/components/shadcn/index.ts @@ -1,4 +1,6 @@ export * from "./badge"; +export * from "./combobox"; +export * from "./input-group"; export * from "./button"; export * from "./card"; export * from "./checkbox"; diff --git a/frontend/frontend-kit/ui/src/components/shadcn/input-group.tsx b/frontend/frontend-kit/ui/src/components/shadcn/input-group.tsx new file mode 100644 index 000000000..3b8f6fece --- /dev/null +++ b/frontend/frontend-kit/ui/src/components/shadcn/input-group.tsx @@ -0,0 +1,170 @@ +"use client" + +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@workspace/ui/lib/utils" +import { Button } from "@workspace/ui/components/shadcn/button" +import { Input } from "@workspace/ui/components/shadcn/input" +import { Textarea } from "@workspace/ui/components/shadcn/textarea" + +function InputGroup({ className, ...props }: React.ComponentProps<"div">) { + return ( +
textarea]:h-auto", + + // Variants based on alignment. + "has-[>[data-align=inline-start]]:[&>input]:pl-2", + "has-[>[data-align=inline-end]]:[&>input]:pr-2", + "has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-start]]:[&>input]:pb-3", + "has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3", + + // Focus state. + "has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-[3px] has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50", + + // Error state. + "has-[[data-slot][aria-invalid=true]]:border-destructive has-[[data-slot][aria-invalid=true]]:ring-destructive/20 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40", + + className + )} + {...props} + /> + ) +} + +const inputGroupAddonVariants = cva( + "flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium text-muted-foreground select-none group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-4", + { + variants: { + align: { + "inline-start": + "order-first pl-3 has-[>button]:ml-[-0.45rem] has-[>kbd]:ml-[-0.35rem]", + "inline-end": + "order-last pr-3 has-[>button]:mr-[-0.45rem] has-[>kbd]:mr-[-0.35rem]", + "block-start": + "order-first w-full justify-start px-3 pt-3 group-has-[>input]/input-group:pt-2.5 [.border-b]:pb-3", + "block-end": + "order-last w-full justify-start px-3 pb-3 group-has-[>input]/input-group:pb-2.5 [.border-t]:pt-3", + }, + }, + defaultVariants: { + align: "inline-start", + }, + } +) + +function InputGroupAddon({ + className, + align = "inline-start", + ...props +}: React.ComponentProps<"div"> & VariantProps) { + return ( +
{ + if ((e.target as HTMLElement).closest("button")) { + return + } + e.currentTarget.parentElement?.querySelector("input")?.focus() + }} + {...props} + /> + ) +} + +const inputGroupButtonVariants = cva( + "flex items-center gap-2 text-sm shadow-none", + { + variants: { + size: { + xs: "h-6 gap-1 rounded-[calc(var(--radius)-5px)] px-2 has-[>svg]:px-2 [&>svg:not([class*='size-'])]:size-3.5", + sm: "h-8 gap-1.5 rounded-md px-2.5 has-[>svg]:px-2.5", + "icon-xs": + "size-6 rounded-[calc(var(--radius)-5px)] p-0 has-[>svg]:p-0", + "icon-sm": "size-8 p-0 has-[>svg]:p-0", + }, + }, + defaultVariants: { + size: "xs", + }, + } +) + +function InputGroupButton({ + className, + type = "button", + variant = "ghost", + size = "xs", + ...props +}: Omit, "size"> & + VariantProps) { + return ( +