Skip to content

Commit 1be5e2e

Browse files
authored
Merge pull request #480 from Hyperloop-UPV/testing-view/always-accessible-keybindings
feat: disable numeric keybindings and allow the rest to be run in almost any moment,…
2 parents c069130 + 089a7f6 commit 1be5e2e

File tree

12 files changed

+132
-55
lines changed

12 files changed

+132
-55
lines changed

.github/workflows/electron-tests.yaml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,6 @@ jobs:
2525
- uses: actions/setup-node@v4
2626
with:
2727
node-version: "20"
28-
cache: "pnpm"
2928

3029
- name: Install dependencies
3130
run: pnpm install --frozen-lockfile --filter=hyperloop-control-station

frontend/testing-view/config.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,4 +31,10 @@ export const config = {
3131

3232
/** Timeout applied after settings save to give enough time for backend to restart. */
3333
SETTINGS_RESPONSE_TIMEOUT: 1000,
34+
35+
/** GitHub repository used to fetch available branches for ADJ configuration. */
36+
ADJ_GITHUB_REPO: "hyperloop-upv/adj",
37+
38+
/** Timeout for fetching branches from GitHub API. */
39+
BRANCHES_FETCH_TIMEOUT: 5000,
3440
} as const;

frontend/testing-view/src/components/settings/ComboboxField.tsx

Lines changed: 34 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,45 +6,71 @@ import {
66
ComboboxItem,
77
ComboboxList,
88
} from "@workspace/ui/components";
9+
import { Button } from "@workspace/ui/components/shadcn/button";
910
import { Label } from "@workspace/ui/components/shadcn/label";
10-
import { useState } from "react";
11+
import { RefreshCw } from "@workspace/ui/icons";
12+
import { useEffect, useState } from "react";
13+
import type { BranchesFetchState } from "../../hooks/useBranches";
1114
import type { FieldProps } from "../../types/common/settings";
1215

1316
export const ComboboxField = ({
1417
field,
1518
value,
1619
onChange,
1720
loading,
18-
}: FieldProps<string>) => {
21+
fetchState,
22+
}: FieldProps<string> & { fetchState?: BranchesFetchState }) => {
1923
const predefined = field.options ?? [];
2024
const items =
2125
value && !predefined.includes(value) ? [value, ...predefined] : predefined;
2226

2327
const [inputValue, setInputValue] = useState(value ?? "");
28+
const [selectedValue, setSelectedValue] = useState<string | null>(value ?? null);
29+
30+
// Sync when value prop changes (e.g. after external save)
31+
useEffect(() => {
32+
setInputValue(value ?? "");
33+
setSelectedValue(value ?? null);
34+
}, [value]);
2435

2536
const commitInput = () => {
26-
if (inputValue && inputValue !== value) onChange(inputValue);
37+
if (inputValue !== value) onChange(inputValue);
2738
};
2839

2940
return (
3041
<div className="w-70 space-y-2">
31-
<Label>{field.label}</Label>
42+
<div className="flex items-center gap-2">
43+
<Label>{field.label}</Label>
44+
{fetchState && (
45+
<Button
46+
variant="ghost"
47+
size="sm"
48+
onClick={fetchState.refetch}
49+
disabled={loading}
50+
className={`h-5 w-5 p-0 ${fetchState.error ? "text-destructive hover:text-destructive" : ""}`}
51+
>
52+
<RefreshCw className={`h-3 w-3 ${loading ? "animate-spin" : ""}`} />
53+
</Button>
54+
)}
55+
</div>
3256
<Combobox
3357
items={items}
34-
value={value ?? null}
58+
value={selectedValue}
3559
onValueChange={(v) => {
36-
onChange(v ?? "");
60+
setSelectedValue(v);
3761
setInputValue(v ?? "");
62+
onChange(v ?? "");
3863
}}
3964
>
4065
<ComboboxInput
4166
placeholder={
4267
loading ? "Loading..." : (field.placeholder ?? "Choose or type...")
4368
}
44-
disabled={loading}
4569
value={inputValue}
4670
onChange={(e) => {
47-
if (e.target.value !== value) setInputValue(e.target.value);
71+
setInputValue(e.target.value);
72+
// Clear selection so base-ui doesn't reset the input to the selected value
73+
setSelectedValue(null);
4874
}}
4975
onBlur={commitInput}
5076
onKeyDown={(e) => {

frontend/testing-view/src/components/settings/SettingsDialog.tsx

Lines changed: 3 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { AlertTriangle, CheckCircle2, Loader2, X } from "@workspace/ui/icons";
55
import { useEffect, useState, useTransition } from "react";
66
import { config } from "../../../config";
77
import { DEFAULT_CONFIG } from "../../constants/defaultConfig";
8+
import { useBranches } from "../../hooks/useBranches";
89
import { useStore } from "../../store/store";
910
import type { ConfigData } from "../../types/common/config";
1011
import { SettingsForm } from "./SettingsForm";
@@ -17,8 +18,7 @@ export const SettingsDialog = () => {
1718
const [localConfig, setLocalConfig] = useState<ConfigData | null>(null);
1819
const [isSynced, setIsSynced] = useState(false);
1920
const [isSaving, startSaving] = useTransition();
20-
const [isBranchesLoading, startBranchesTransition] = useTransition();
21-
const [branches, setBranches] = useState<string[]>([]);
21+
const branchesFetch = useBranches(isSettingsOpen);
2222

2323
const loadConfig = async () => {
2424
if (window.electronAPI) {
@@ -39,33 +39,9 @@ export const SettingsDialog = () => {
3939
}
4040
};
4141

42-
const loadBranches = (signal: AbortSignal) => {
43-
startBranchesTransition(async () => {
44-
try {
45-
const res = await fetch(
46-
"https://api.github.com/repos/hyperloop-upv/adj/branches?per_page=100",
47-
{ signal: AbortSignal.any([signal, AbortSignal.timeout(2000)]) },
48-
);
49-
const data = await res.json();
50-
setBranches(data.map((b: { name: string }) => b.name));
51-
} catch (error) {
52-
if (
53-
error instanceof Error &&
54-
error.name !== "AbortError" &&
55-
error.name !== "TimeoutError"
56-
) {
57-
console.error("Error loading branches:", error);
58-
}
59-
}
60-
});
61-
};
62-
6342
useEffect(() => {
6443
if (isSettingsOpen) {
65-
const controller = new AbortController();
6644
loadConfig();
67-
loadBranches(controller.signal);
68-
return () => controller.abort();
6945
}
7046
}, [isSettingsOpen]);
7147

@@ -120,8 +96,7 @@ export const SettingsDialog = () => {
12096
<SettingsForm
12197
config={localConfig}
12298
onChange={setLocalConfig}
123-
branches={branches}
124-
branchesLoading={isBranchesLoading}
99+
branchesFetch={branchesFetch}
125100
/>
126101
)}
127102
</div>

frontend/testing-view/src/components/settings/SettingsForm.tsx

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { get, set } from "lodash";
22
import { useMemo } from "react";
33
import { getSettingsSchema } from "../../constants/settingsSchema";
4+
import type { BranchesFetchState } from "../../hooks/useBranches";
45
import { useStore } from "../../store/store";
56
import type { ConfigData } from "../../types/common/config";
67
import type { SettingField } from "../../types/common/settings";
@@ -14,11 +15,10 @@ import { TextField } from "./TextField";
1415
interface SettingsFormProps {
1516
config: ConfigData;
1617
onChange: (newConfig: ConfigData) => void;
17-
branches: string[];
18-
branchesLoading: boolean;
18+
branchesFetch: BranchesFetchState;
1919
}
2020

21-
export const SettingsForm = ({ config, onChange, branches, branchesLoading }: SettingsFormProps) => {
21+
export const SettingsForm = ({ config, onChange, branchesFetch }: SettingsFormProps) => {
2222
const handleFieldChange = (
2323
path: string,
2424
value: string | number | boolean | string[],
@@ -30,7 +30,7 @@ export const SettingsForm = ({ config, onChange, branches, branchesLoading }: Se
3030

3131
const boards = useStore((s) => s.boards);
3232
const sortedBoard = boards.sort();
33-
const schema = useMemo(() => getSettingsSchema(sortedBoard, branches), [sortedBoard, branches]);
33+
const schema = useMemo(() => getSettingsSchema(sortedBoard, branchesFetch.branches), [sortedBoard, branchesFetch.branches]);
3434

3535
const renderField = (field: SettingField) => {
3636
const currentValue = get(config, field.path);
@@ -103,7 +103,8 @@ export const SettingsForm = ({ config, onChange, branches, branchesLoading }: Se
103103
field={field}
104104
value={currentValue as unknown as string}
105105
onChange={(value) => handleFieldChange(field.path, value)}
106-
loading={branchesLoading}
106+
loading={branchesFetch.isLoading}
107+
fetchState={field.refetchable ? branchesFetch : undefined}
107108
/>
108109
);
109110

frontend/testing-view/src/constants/settingsSchema.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ export const getSettingsSchema = (boards: BoardName[], branches: string[] = []):
2222
path: "adj.branch",
2323
type: "combobox",
2424
options: branches,
25+
refetchable: true,
2526
},
2627
],
2728
},

frontend/testing-view/src/features/keyBindings/components/AddKeyBindingDialog.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,11 @@ export const AddKeyBindingDialog = ({
8787
return;
8888
}
8989

90+
// Disallow number keys (0-9) and Backspace
91+
if (/^\d$/.test(e.key) || e.key === "Backspace") {
92+
return;
93+
}
94+
9095
let key: string;
9196
if (SPECIAL_KEY_BINDINGS[e.key]) {
9297
key = SPECIAL_KEY_BINDINGS[e.key];

frontend/testing-view/src/features/keyBindings/constants/specialKeyBindings.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,5 @@ export const SPECIAL_KEY_BINDINGS: Record<string, string> = {
77
ArrowDown: "ArrowDown",
88
ArrowLeft: "ArrowLeft",
99
ArrowRight: "ArrowRight",
10-
Backspace: "Backspace",
1110
Delete: "Delete",
1211
};

frontend/testing-view/src/features/keyBindings/hooks/useGlobalKeyBindings.ts

Lines changed: 0 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -18,18 +18,6 @@ export const useGlobalKeyBindings = () => {
1818

1919
useEffect(() => {
2020
const handleKeyPress = (e: KeyboardEvent) => {
21-
// Skip if a dialog is open
22-
if (document.querySelector('[role="dialog"]')) return;
23-
24-
// Skip if user is typing in an input/textarea/contenteditable
25-
if (
26-
e.target instanceof HTMLInputElement ||
27-
e.target instanceof HTMLTextAreaElement ||
28-
(e.target as HTMLElement).isContentEditable
29-
) {
30-
return;
31-
}
32-
3321
// Build key string (matching the format from AddKeyBindingDialog)
3422
let key: string;
3523
if (SPECIAL_KEY_BINDINGS[e.key]) {
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { useEffect, useState, useTransition } from "react";
2+
import { config } from "../../config";
3+
4+
export interface BranchesFetchState {
5+
branches: string[];
6+
isLoading: boolean;
7+
error: boolean;
8+
refetch: () => void;
9+
}
10+
11+
export const useBranches = (enabled: boolean): BranchesFetchState => {
12+
const [branches, setBranches] = useState<string[]>([]);
13+
const [isLoading, startTransition] = useTransition();
14+
const [error, setError] = useState(false);
15+
16+
const load = (signal: AbortSignal) => {
17+
startTransition(async () => {
18+
try {
19+
setError(false);
20+
const res = await fetch(
21+
`https://api.github.com/repos/${config.ADJ_GITHUB_REPO}/branches?per_page=100`,
22+
{ signal: AbortSignal.any([signal, AbortSignal.timeout(config.BRANCHES_FETCH_TIMEOUT)]) },
23+
);
24+
const data = await res.json();
25+
setBranches(data.map((b: { name: string }) => b.name));
26+
} catch (err) {
27+
if (err instanceof Error && err.name !== "AbortError") {
28+
setError(true);
29+
}
30+
}
31+
});
32+
};
33+
34+
const refetch = () => {
35+
const controller = new AbortController();
36+
load(controller.signal);
37+
};
38+
39+
useEffect(() => {
40+
if (enabled) {
41+
const controller = new AbortController();
42+
load(controller.signal);
43+
return () => controller.abort();
44+
}
45+
}, [enabled]);
46+
47+
return { branches, isLoading, error, refetch };
48+
};

0 commit comments

Comments
 (0)