fix: right-click apps, board keyboard shortcuts, scrollbar oscillation, Beszel JSON errors

- #6416: Skip widget context menu for app widgets (pass through native right-click)
- #6392: Add board keyboard shortcuts (mod+shift+arrows to cycle, mod+shift+1-9 for direct)
- #6080: Prevent GridStack scrollbar oscillation by thresholding resize events
- #6024: Add try-catch for Beszel response.json() calls in auth and testing paths
This commit is contained in:
Thomas Camlong committed 2026-08-03 17:03:24 +02:00
1 parent afe5f2b6b4
commit ce6a637220
8 files changed
+144 -4

No files matched your search

@@ -0,0 +1,8 @@
"use client";
import { useBoardHotkeys } from "~/components/board/use-board-hotkeys";
export const BoardHotkeys = () => {
useBoardHotkeys();
return null;
};
@@ -18,6 +18,7 @@ import type { Params } from "./(content)/_creator";
import { CustomCss } from "./(content)/_custom-css";
import { BoardReadyProvider } from "./(content)/_ready-context";
import { BoardMantineProvider } from "./(content)/_theme";
import { BoardHotkeys } from "./_board-hotkeys";
const logger = createLogger({ module: "createBoardLayout" });
@@ -69,6 +70,7 @@ export const createBoardLayout = <TParams extends Params>({
<EditModeProvider>
<BoardMantineProvider defaultColorScheme={colorScheme}>
<CustomCss />
<BoardHotkeys />
<BoardTourWrapper hasSession={withTour && !!session}>
<ClientShell hasNavigation={false}>
<MainHeader
@@ -170,6 +170,7 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont
if (!session) return <>{children}</>;
if (!isRightClickEnabled) return <>{children}</>;
if (item.kind === "app") return <>{children}</>;
const visibleWidgetActions = widgetContextActions.filter((a) => !a.hidden);
@@ -319,9 +319,24 @@ const useCssVariableConfiguration = ({
columnCount,
isDynamic,
}: UseCssVariableConfiguration) => {
const prevWidthRef = useRef<number | null>(null);
const onResize = useCallback(() => {
if (!wrapperRef.current) return;
if (!gridRef.current) return;
const currentWidth = wrapperRef.current.clientWidth;
const prevWidth = prevWidthRef.current;
// Only recalculate if the width change is significant enough (> 20px)
// This prevents infinite feedback loops when the vertical scrollbar
// appears/disappears, causing clientWidth to oscillate by ~15px
if (prevWidth !== null && Math.abs(currentWidth - prevWidth) < 20) {
return;
}
prevWidthRef.current = currentWidth;
handleResizeChange(
wrapperRef.current,
gridRef.current,
@@ -0,0 +1,73 @@
"use client";
import { useCallback } from "react";
import { useRouter } from "next/navigation";
import { clientApi } from "@homarr/api/client";
import { useRequiredBoard } from "@homarr/boards/context";
import { hotkeys } from "@homarr/definitions";
import { useHotkeys } from "@mantine/hooks";
export const useBoardHotkeys = () => {
const router = useRouter();
const currentBoard = useRequiredBoard();
const { data: boards = [] } = clientApi.board.getAllBoards.useQuery();
const navigateToBoard = useCallback(
(boardName: string) => {
if (boardName !== currentBoard.name) {
router.push(`/boards/${boardName}`);
}
},
[router, currentBoard.name],
);
const currentIndex = boards.findIndex((board) => board.id === currentBoard.id);
useHotkeys([
[hotkeys.nextBoard, () => navigateToNextBoard(boards, currentIndex, navigateToBoard)],
[hotkeys.previousBoard, () => navigateToPreviousBoard(boards, currentIndex, navigateToBoard)],
[hotkeys.switchBoard1, () => navigateToBoardByIndex(boards, 0, navigateToBoard)],
[hotkeys.switchBoard2, () => navigateToBoardByIndex(boards, 1, navigateToBoard)],
[hotkeys.switchBoard3, () => navigateToBoardByIndex(boards, 2, navigateToBoard)],
[hotkeys.switchBoard4, () => navigateToBoardByIndex(boards, 3, navigateToBoard)],
[hotkeys.switchBoard5, () => navigateToBoardByIndex(boards, 4, navigateToBoard)],
[hotkeys.switchBoard6, () => navigateToBoardByIndex(boards, 5, navigateToBoard)],
[hotkeys.switchBoard7, () => navigateToBoardByIndex(boards, 6, navigateToBoard)],
[hotkeys.switchBoard8, () => navigateToBoardByIndex(boards, 7, navigateToBoard)],
[hotkeys.switchBoard9, () => navigateToBoardByIndex(boards, 8, navigateToBoard)],
]);
};
const navigateToNextBoard = (
boards: { name: string }[],
currentIndex: number,
navigate: (name: string) => void,
) => {
if (boards.length === 0) return;
const nextIndex = (currentIndex + 1) % boards.length;
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
navigate(boards[nextIndex]!.name);
};
const navigateToPreviousBoard = (
boards: { name: string }[],
currentIndex: number,
navigate: (name: string) => void,
) => {
if (boards.length === 0) return;
const previousIndex = (currentIndex - 1 + boards.length) % boards.length;
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
navigate(boards[previousIndex]!.name);
};
const navigateToBoardByIndex = (
boards: { name: string }[],
index: number,
navigate: (name: string) => void,
) => {
if (index < boards.length) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
navigate(boards[index]!.name);
}
};
+11
View File
@@ -3,4 +3,15 @@ export const hotkeys = {
toggleColorScheme: "mod+j",
saveNotebook: "mod+s",
openSpotlight: "mod+k",
nextBoard: "mod+shift+ArrowRight",
previousBoard: "mod+shift+ArrowLeft",
switchBoard1: "mod+shift+1",
switchBoard2: "mod+shift+2",
switchBoard3: "mod+shift+3",
switchBoard4: "mod+shift+4",
switchBoard5: "mod+shift+5",
switchBoard6: "mod+shift+6",
switchBoard7: "mod+shift+7",
switchBoard8: "mod+shift+8",
switchBoard9: "mod+shift+9",
};
@@ -1,4 +1,4 @@
import { ResponseError } from "@homarr/common/server";
import { ParseError, ResponseError } from "@homarr/common/server";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
import { createCertificateAgentAsync, fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
@@ -163,7 +163,18 @@ export class BeszelIntegration extends Integration {
throw new ResponseError(response);
}
const data = (await response.json()) as BeszelAuthResponse;
let data: BeszelAuthResponse;
try {
data = (await response.json()) as BeszelAuthResponse;
} catch (error) {
logger.warn("Failed to parse Beszel auth response JSON", {
integrationId: this.integration.id,
error: error instanceof Error ? error.message : String(error),
});
throw new ErrorWithMetadata("Beszel authentication response was not valid JSON", {
integrationId: this.integration.id,
});
}
const expiresAt = parseTokenExpiration(data.token) ?? undefined;
// A token that has already lapsed by the time it reaches us means our clock and Beszel's
@@ -608,7 +619,15 @@ export class BeszelIntegration extends Integration {
return TestConnectionError.StatusResult({ status: response.status, url: response.url });
}
const data = (await response.json()) as BeszelAuthResponse;
let data: BeszelAuthResponse;
try {
data = (await response.json()) as BeszelAuthResponse;
} catch (error) {
logger.warn("Failed to parse Beszel testing response JSON", {
error: error instanceof Error ? error.message : String(error),
});
return TestConnectionError.ParseResult(new ParseError("Beszel testing response was not valid JSON"));
}
if (!data.token) {
return TestConnectionError.UnauthorizedResult(401);
}
+12 -1
View File
@@ -5272,7 +5272,18 @@
"toggleBoardEdit": "Toggle board edit mode",
"toggleColorScheme": "Toggle light/dark mode",
"saveNotebook": "Save notebook (only inside notebook widget)",
"openSpotlight": "Open search"
"openSpotlight": "Open search",
"nextBoard": "Switch to next board",
"previousBoard": "Switch to previous board",
"switchBoard1": "Switch to first board",
"switchBoard2": "Switch to second board",
"switchBoard3": "Switch to third board",
"switchBoard4": "Switch to fourth board",
"switchBoard5": "Switch to fifth board",
"switchBoard6": "Switch to sixth board",
"switchBoard7": "Switch to seventh board",
"switchBoard8": "Switch to eighth board",
"switchBoard9": "Switch to ninth board"
},
"note": "Tip: Mod refers to both Ctrl key and ⌘ key on macOS"
}