diff --git a/.github/workflows/electron-tests.yaml b/.github/workflows/electron-tests.yaml index 0e5c1f1f0..f7b14eaf5 100644 --- a/.github/workflows/electron-tests.yaml +++ b/.github/workflows/electron-tests.yaml @@ -25,7 +25,6 @@ jobs: - uses: actions/setup-node@v4 with: node-version: "20" - cache: "pnpm" - name: Install dependencies run: pnpm install --frozen-lockfile --filter=hyperloop-control-station diff --git a/frontend/testing-view/config.ts b/frontend/testing-view/config.ts index a64641ef9..87f5bfa2e 100644 --- a/frontend/testing-view/config.ts +++ b/frontend/testing-view/config.ts @@ -31,4 +31,10 @@ export const config = { /** Timeout applied after settings save to give enough time for backend to restart. */ SETTINGS_RESPONSE_TIMEOUT: 1000, + + /** GitHub repository used to fetch available branches for ADJ configuration. */ + ADJ_GITHUB_REPO: "hyperloop-upv/adj", + + /** Timeout for fetching branches from GitHub API. */ + BRANCHES_FETCH_TIMEOUT: 5000, } as const; diff --git a/frontend/testing-view/src/components/settings/ComboboxField.tsx b/frontend/testing-view/src/components/settings/ComboboxField.tsx index cb1687e91..8c14abf1f 100644 --- a/frontend/testing-view/src/components/settings/ComboboxField.tsx +++ b/frontend/testing-view/src/components/settings/ComboboxField.tsx @@ -6,8 +6,11 @@ import { ComboboxItem, ComboboxList, } from "@workspace/ui/components"; +import { Button } from "@workspace/ui/components/shadcn/button"; import { Label } from "@workspace/ui/components/shadcn/label"; -import { useState } from "react"; +import { RefreshCw } from "@workspace/ui/icons"; +import { useEffect, useState } from "react"; +import type { BranchesFetchState } from "../../hooks/useBranches"; import type { FieldProps } from "../../types/common/settings"; export const ComboboxField = ({ @@ -15,36 +18,59 @@ export const ComboboxField = ({ value, onChange, loading, -}: FieldProps) => { + fetchState, +}: FieldProps & { fetchState?: BranchesFetchState }) => { const predefined = field.options ?? []; const items = value && !predefined.includes(value) ? [value, ...predefined] : predefined; const [inputValue, setInputValue] = useState(value ?? ""); + const [selectedValue, setSelectedValue] = useState(value ?? null); + + // Sync when value prop changes (e.g. after external save) + useEffect(() => { + setInputValue(value ?? ""); + setSelectedValue(value ?? null); + }, [value]); const commitInput = () => { - if (inputValue && inputValue !== value) onChange(inputValue); + if (inputValue !== value) onChange(inputValue); }; return (
- +
+ + {fetchState && ( + + )} +
{ - onChange(v ?? ""); + setSelectedValue(v); setInputValue(v ?? ""); + onChange(v ?? ""); }} > { - if (e.target.value !== value) setInputValue(e.target.value); + setInputValue(e.target.value); + // Clear selection so base-ui doesn't reset the input to the selected value + setSelectedValue(null); }} onBlur={commitInput} onKeyDown={(e) => { diff --git a/frontend/testing-view/src/components/settings/SettingsDialog.tsx b/frontend/testing-view/src/components/settings/SettingsDialog.tsx index 33db5109d..2ffda360c 100644 --- a/frontend/testing-view/src/components/settings/SettingsDialog.tsx +++ b/frontend/testing-view/src/components/settings/SettingsDialog.tsx @@ -5,6 +5,7 @@ import { AlertTriangle, CheckCircle2, Loader2, X } from "@workspace/ui/icons"; import { useEffect, useState, useTransition } from "react"; import { config } from "../../../config"; import { DEFAULT_CONFIG } from "../../constants/defaultConfig"; +import { useBranches } from "../../hooks/useBranches"; import { useStore } from "../../store/store"; import type { ConfigData } from "../../types/common/config"; import { SettingsForm } from "./SettingsForm"; @@ -17,8 +18,7 @@ export const SettingsDialog = () => { const [localConfig, setLocalConfig] = useState(null); const [isSynced, setIsSynced] = useState(false); const [isSaving, startSaving] = useTransition(); - const [isBranchesLoading, startBranchesTransition] = useTransition(); - const [branches, setBranches] = useState([]); + const branchesFetch = useBranches(isSettingsOpen); const loadConfig = async () => { if (window.electronAPI) { @@ -39,33 +39,9 @@ export const SettingsDialog = () => { } }; - const loadBranches = (signal: AbortSignal) => { - startBranchesTransition(async () => { - try { - const res = await fetch( - "https://api.github.com/repos/hyperloop-upv/adj/branches?per_page=100", - { signal: AbortSignal.any([signal, AbortSignal.timeout(2000)]) }, - ); - const data = await res.json(); - setBranches(data.map((b: { name: string }) => b.name)); - } catch (error) { - if ( - error instanceof Error && - error.name !== "AbortError" && - error.name !== "TimeoutError" - ) { - console.error("Error loading branches:", error); - } - } - }); - }; - useEffect(() => { if (isSettingsOpen) { - const controller = new AbortController(); loadConfig(); - loadBranches(controller.signal); - return () => controller.abort(); } }, [isSettingsOpen]); @@ -120,8 +96,7 @@ export const SettingsDialog = () => { )}
diff --git a/frontend/testing-view/src/components/settings/SettingsForm.tsx b/frontend/testing-view/src/components/settings/SettingsForm.tsx index 8e0bc768a..543e301df 100644 --- a/frontend/testing-view/src/components/settings/SettingsForm.tsx +++ b/frontend/testing-view/src/components/settings/SettingsForm.tsx @@ -1,6 +1,7 @@ import { get, set } from "lodash"; import { useMemo } from "react"; import { getSettingsSchema } from "../../constants/settingsSchema"; +import type { BranchesFetchState } from "../../hooks/useBranches"; import { useStore } from "../../store/store"; import type { ConfigData } from "../../types/common/config"; import type { SettingField } from "../../types/common/settings"; @@ -14,11 +15,10 @@ import { TextField } from "./TextField"; interface SettingsFormProps { config: ConfigData; onChange: (newConfig: ConfigData) => void; - branches: string[]; - branchesLoading: boolean; + branchesFetch: BranchesFetchState; } -export const SettingsForm = ({ config, onChange, branches, branchesLoading }: SettingsFormProps) => { +export const SettingsForm = ({ config, onChange, branchesFetch }: SettingsFormProps) => { const handleFieldChange = ( path: string, value: string | number | boolean | string[], @@ -30,7 +30,7 @@ export const SettingsForm = ({ config, onChange, branches, branchesLoading }: Se const boards = useStore((s) => s.boards); const sortedBoard = boards.sort(); - const schema = useMemo(() => getSettingsSchema(sortedBoard, branches), [sortedBoard, branches]); + const schema = useMemo(() => getSettingsSchema(sortedBoard, branchesFetch.branches), [sortedBoard, branchesFetch.branches]); const renderField = (field: SettingField) => { const currentValue = get(config, field.path); @@ -103,7 +103,8 @@ export const SettingsForm = ({ config, onChange, branches, branchesLoading }: Se field={field} value={currentValue as unknown as string} onChange={(value) => handleFieldChange(field.path, value)} - loading={branchesLoading} + loading={branchesFetch.isLoading} + fetchState={field.refetchable ? branchesFetch : undefined} /> ); diff --git a/frontend/testing-view/src/constants/settingsSchema.ts b/frontend/testing-view/src/constants/settingsSchema.ts index b240e1647..efcbae2e4 100644 --- a/frontend/testing-view/src/constants/settingsSchema.ts +++ b/frontend/testing-view/src/constants/settingsSchema.ts @@ -22,6 +22,7 @@ export const getSettingsSchema = (boards: BoardName[], branches: string[] = []): path: "adj.branch", type: "combobox", options: branches, + refetchable: true, }, ], }, diff --git a/frontend/testing-view/src/features/keyBindings/components/AddKeyBindingDialog.tsx b/frontend/testing-view/src/features/keyBindings/components/AddKeyBindingDialog.tsx index a8eb99928..5106f0795 100644 --- a/frontend/testing-view/src/features/keyBindings/components/AddKeyBindingDialog.tsx +++ b/frontend/testing-view/src/features/keyBindings/components/AddKeyBindingDialog.tsx @@ -87,6 +87,11 @@ export const AddKeyBindingDialog = ({ return; } + // Disallow number keys (0-9) and Backspace + if (/^\d$/.test(e.key) || e.key === "Backspace") { + return; + } + let key: string; if (SPECIAL_KEY_BINDINGS[e.key]) { key = SPECIAL_KEY_BINDINGS[e.key]; diff --git a/frontend/testing-view/src/features/keyBindings/constants/specialKeyBindings.ts b/frontend/testing-view/src/features/keyBindings/constants/specialKeyBindings.ts index cc903a5ea..a9c17b1f9 100644 --- a/frontend/testing-view/src/features/keyBindings/constants/specialKeyBindings.ts +++ b/frontend/testing-view/src/features/keyBindings/constants/specialKeyBindings.ts @@ -7,6 +7,5 @@ export const SPECIAL_KEY_BINDINGS: Record = { ArrowDown: "ArrowDown", ArrowLeft: "ArrowLeft", ArrowRight: "ArrowRight", - Backspace: "Backspace", Delete: "Delete", }; diff --git a/frontend/testing-view/src/features/keyBindings/hooks/useGlobalKeyBindings.ts b/frontend/testing-view/src/features/keyBindings/hooks/useGlobalKeyBindings.ts index 456f83cb6..bc53f6e72 100644 --- a/frontend/testing-view/src/features/keyBindings/hooks/useGlobalKeyBindings.ts +++ b/frontend/testing-view/src/features/keyBindings/hooks/useGlobalKeyBindings.ts @@ -18,18 +18,6 @@ export const useGlobalKeyBindings = () => { useEffect(() => { const handleKeyPress = (e: KeyboardEvent) => { - // Skip if a dialog is open - if (document.querySelector('[role="dialog"]')) return; - - // Skip if user is typing in an input/textarea/contenteditable - if ( - e.target instanceof HTMLInputElement || - e.target instanceof HTMLTextAreaElement || - (e.target as HTMLElement).isContentEditable - ) { - return; - } - // Build key string (matching the format from AddKeyBindingDialog) let key: string; if (SPECIAL_KEY_BINDINGS[e.key]) { diff --git a/frontend/testing-view/src/hooks/useBranches.ts b/frontend/testing-view/src/hooks/useBranches.ts new file mode 100644 index 000000000..60b78fd4c --- /dev/null +++ b/frontend/testing-view/src/hooks/useBranches.ts @@ -0,0 +1,48 @@ +import { useEffect, useState, useTransition } from "react"; +import { config } from "../../config"; + +export interface BranchesFetchState { + branches: string[]; + isLoading: boolean; + error: boolean; + refetch: () => void; +} + +export const useBranches = (enabled: boolean): BranchesFetchState => { + const [branches, setBranches] = useState([]); + const [isLoading, startTransition] = useTransition(); + const [error, setError] = useState(false); + + const load = (signal: AbortSignal) => { + startTransition(async () => { + try { + setError(false); + const res = await fetch( + `https://api.github.com/repos/${config.ADJ_GITHUB_REPO}/branches?per_page=100`, + { signal: AbortSignal.any([signal, AbortSignal.timeout(config.BRANCHES_FETCH_TIMEOUT)]) }, + ); + const data = await res.json(); + setBranches(data.map((b: { name: string }) => b.name)); + } catch (err) { + if (err instanceof Error && err.name !== "AbortError") { + setError(true); + } + } + }); + }; + + const refetch = () => { + const controller = new AbortController(); + load(controller.signal); + }; + + useEffect(() => { + if (enabled) { + const controller = new AbortController(); + load(controller.signal); + return () => controller.abort(); + } + }, [enabled]); + + return { branches, isLoading, error, refetch }; +}; diff --git a/frontend/testing-view/src/store/store.ts b/frontend/testing-view/src/store/store.ts index dfc7c58c8..264f5c370 100644 --- a/frontend/testing-view/src/store/store.ts +++ b/frontend/testing-view/src/store/store.ts @@ -41,6 +41,16 @@ export type Store = AppSlice & ChartsSlice & FilteringSlice; +type PersistedStore = { + charts: Store["charts"]; + workspaces: Store["workspaces"]; + activeWorkspace: Store["activeWorkspace"]; + colorScheme: Store["colorScheme"]; + isDarkMode: Store["isDarkMode"]; + testingPage: Store["testingPage"]; + workspaceFilters: Store["workspaceFilters"]; +}; + export const useStore = create()( // devtools( persist( @@ -58,6 +68,22 @@ export const useStore = create()( { // Partial persist name: "testing-view-storage", + version: 1, + migrate: (persistedState: unknown, version: number) => { + const state = persistedState as PersistedStore; + if (version < 1) { + // Remove keybindings that use number keys (0-9) or Backspace + const strip = (workspace: Store["workspaces"][number]): Store["workspaces"][number] => ({ + ...workspace, + keyBindings: (workspace.keyBindings ?? []).filter( + (b) => !/^\d$/.test(b.key) && b.key !== "Backspace", + ), + }); + if (state.workspaces) state.workspaces = state.workspaces.map(strip); + if (state.activeWorkspace) state.activeWorkspace = strip(state.activeWorkspace); + } + return state; + }, onRehydrateStorage: () => () => { document.documentElement.setAttribute("data-store-hydrated", "true"); }, diff --git a/frontend/testing-view/src/types/common/settings.ts b/frontend/testing-view/src/types/common/settings.ts index 9ca9f41ba..ab25c874d 100644 --- a/frontend/testing-view/src/types/common/settings.ts +++ b/frontend/testing-view/src/types/common/settings.ts @@ -41,6 +41,9 @@ export type SettingField = { /** Options of the field */ options?: string[]; + /** Whether the field supports refetching its options */ + refetchable?: boolean; + /** Placeholder of the field. The text showed inside the field when it is empty. */ placeholder?: string;