Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
79a75a4
feat(desktop): add modifier key state store for workspace number badges
tvrmsmith Apr 8, 2026
a73bac6
feat(desktop): add hooks for modifier key detection from workspace sh…
tvrmsmith Apr 8, 2026
91015c6
feat: add showWorkspaceNumbersOnModifier setting to local-db and tRPC
tvrmsmith Apr 8, 2026
1a7c0cf
feat(desktop): add WorkspaceShortcutBadge component
tvrmsmith Apr 8, 2026
deeed6a
feat: add hidden prop to WorkspaceDiffStats for modifier-hold badge swap
tvrmsmith Apr 8, 2026
2ca9693
feat: show workspace number badges on modifier hold, enlarge hover hint
tvrmsmith Apr 8, 2026
f9096a6
feat: mount modifier key listener in WorkspaceSidebar
tvrmsmith Apr 8, 2026
3744926
feat: show shortcut badges on collapsed project headers when modifier…
tvrmsmith Apr 8, 2026
1c3a923
feat: add settings toggle for workspace number badges on modifier hold
tvrmsmith Apr 8, 2026
61e2e74
chore: lint and format fixes for workspace number badges feature
tvrmsmith Apr 8, 2026
0df5633
fix(desktop): use dynamic shortcut labels instead of hardcoded ⌘ symbol
tvrmsmith Apr 8, 2026
c1b1136
fix(desktop): address PR review feedback for workspace number badges
tvrmsmith Apr 9, 2026
d31b3a1
fix(desktop): update hotkey recorder test to match Mac alt modifier b…
tvrmsmith Apr 16, 2026
d41957d
fix(desktop): adapt to upstream ShortcutBinding object form
TrevorSmith-Wellsky Apr 29, 2026
0c9aa37
fix(desktop): guard SUPERSET_WORKSPACE_NAME in prod builds
tvrmsmith Apr 30, 2026
1701d39
fix(desktop): add missing BEHAVIOR_WORKSPACE_NUMBERS variant to setti…
tvrmsmith May 5, 2026
7ea79b5
fix(desktop): wire modifier-hold badges into v2 DashboardSidebar
TrevorSmith-Wellsky May 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 13 additions & 6 deletions apps/desktop/electron.vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,9 +93,14 @@ export default defineConfig({
process.env.DESKTOP_NOTIFICATIONS_PORT,
),
"process.env.ELECTRIC_PORT": defineEnv(process.env.ELECTRIC_PORT),
"process.env.SUPERSET_WORKSPACE_NAME": defineEnv(
process.env.SUPERSET_WORKSPACE_NAME,
),
// Workspace isolation only applies in dev (each worktree's setup.sh writes a
// per-workspace SUPERSET_WORKSPACE_NAME to .env). In production builds the env
// var must NOT be baked in — a stray value (e.g. inherited from a dev shell)
// would silently redirect ~/.superset → ~/.superset-<value>, hiding all user data.
"process.env.SUPERSET_WORKSPACE_NAME":
process.env.NODE_ENV === "development"
? defineEnv(process.env.SUPERSET_WORKSPACE_NAME)
: JSON.stringify("superset"),
},

build: {
Expand Down Expand Up @@ -210,9 +215,11 @@ export default defineConfig({
process.env.DESKTOP_NOTIFICATIONS_PORT,
),
"process.env.ELECTRIC_PORT": defineEnv(process.env.ELECTRIC_PORT),
"process.env.SUPERSET_WORKSPACE_NAME": defineEnv(
process.env.SUPERSET_WORKSPACE_NAME,
),
// See main define block above for rationale.
"process.env.SUPERSET_WORKSPACE_NAME":
process.env.NODE_ENV === "development"
? defineEnv(process.env.SUPERSET_WORKSPACE_NAME)
: JSON.stringify("superset"),
},

server: {
Expand Down
20 changes: 20 additions & 0 deletions apps/desktop/src/lib/trpc/routers/settings/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1030,5 +1030,25 @@ export const createSettingsRouter = () => {
.mutation(() => {
return { success: true };
}),

getShowWorkspaceNumbersOnModifier: publicProcedure.query(() => {
const row = getSettings();
return row.showWorkspaceNumbersOnModifier ?? false;
}),

setShowWorkspaceNumbersOnModifier: publicProcedure
.input(z.object({ enabled: z.boolean() }))
.mutation(({ input }) => {
localDb
.insert(settings)
.values({ id: 1, showWorkspaceNumbersOnModifier: input.enabled })
.onConflictDoUpdate({
target: settings.id,
set: { showWorkspaceNumbersOnModifier: input.enabled },
})
.run();

return { success: true };
}),
});
};
61 changes: 61 additions & 0 deletions apps/desktop/src/renderer/hooks/useModifierKeyListener.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { useEffect } from "react";
import { useModifierKeyStateStore } from "renderer/stores/modifier-key-state";
import { useWorkspaceShortcutModifiers } from "./useWorkspaceShortcutModifiers";

export function useModifierKeyListener(enabled: boolean) {
const { allModifierKeys, comboToIndices } = useWorkspaceShortcutModifiers();
const { pressKey, releaseKey, clearAll } = useModifierKeyStateStore();

useEffect(() => {
if (!enabled) {
clearAll();
return;
}

function updateIsModifierHeld() {
const held = useModifierKeyStateStore.getState().heldKeys;
for (const combo of comboToIndices.keys()) {
const comboKeys = combo.split("+");
if (comboKeys.length > 0 && comboKeys.every((k) => held.has(k))) {
useModifierKeyStateStore.setState({ isModifierHeld: true });
return;
}
}
useModifierKeyStateStore.setState({ isModifierHeld: false });
}

function handleKeyDown(e: KeyboardEvent) {
if (allModifierKeys.has(e.key)) {
pressKey(e.key);
updateIsModifierHeld();
}
}
function handleKeyUp(e: KeyboardEvent) {
if (allModifierKeys.has(e.key)) {
releaseKey(e.key);
updateIsModifierHeld();
}
}
function handleBlur() {
clearAll();
}

window.addEventListener("keydown", handleKeyDown);
window.addEventListener("keyup", handleKeyUp);
window.addEventListener("blur", handleBlur);

return () => {
window.removeEventListener("keydown", handleKeyDown);
window.removeEventListener("keyup", handleKeyUp);
window.removeEventListener("blur", handleBlur);
clearAll();
};
}, [
enabled,
allModifierKeys,
comboToIndices,
pressKey,
releaseKey,
clearAll,
]);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { describe, expect, test } from "bun:test";
import { parseBinding } from "./useWorkspaceShortcutModifiers";

describe("parseBinding", () => {
test("parses a simple binding like 'meta+1'", () => {
const result = parseBinding("meta+1");
expect(result.modifierKeys).toEqual(["Meta"]);
expect(result.triggerKey).toBe("1");
});

test("parses a multi-modifier binding like 'ctrl+shift+3'", () => {
const result = parseBinding("ctrl+shift+3");
expect(result.modifierKeys).toEqual(["Control", "Shift"]);
expect(result.triggerKey).toBe("3");
});

test("handles unknown modifier gracefully (passes through as-is)", () => {
const result = parseBinding("hyper+x");
expect(result.modifierKeys).toEqual(["hyper"]);
expect(result.triggerKey).toBe("x");
});
});
76 changes: 76 additions & 0 deletions apps/desktop/src/renderer/hooks/useWorkspaceShortcutModifiers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { useMemo } from "react";
import {
formatHotkeyDisplay,
getBinding,
type HotkeyId,
PLATFORM,
useHotkeyOverridesStore,
} from "renderer/hotkeys";
import { parseBinding as parseShortcutBinding } from "renderer/hotkeys/utils/binding";

const WORKSPACE_HOTKEY_IDS: HotkeyId[] = [
"JUMP_TO_WORKSPACE_1",
"JUMP_TO_WORKSPACE_2",
"JUMP_TO_WORKSPACE_3",
"JUMP_TO_WORKSPACE_4",
"JUMP_TO_WORKSPACE_5",
"JUMP_TO_WORKSPACE_6",
"JUMP_TO_WORKSPACE_7",
"JUMP_TO_WORKSPACE_8",
"JUMP_TO_WORKSPACE_9",
];

const MODIFIER_KEY_MAP: Record<string, string> = {
meta: "Meta",
ctrl: "Control",
shift: "Shift",
alt: "Alt",
};

export interface WorkspaceShortcutInfo {
index: number;
triggerKey: string;
modifierKeys: string[];
}

export function parseBinding(binding: string): {
modifierKeys: string[];
triggerKey: string;
} {
const parts = binding.split("+");
const triggerKey = parts[parts.length - 1];
const modifierKeys = parts.slice(0, -1).map((m) => MODIFIER_KEY_MAP[m] ?? m);
return { modifierKeys, triggerKey };
}

export function useWorkspaceShortcutModifiers() {
const overrides = useHotkeyOverridesStore((s) => s.overrides);

return useMemo(() => {
// `overrides` isn't read directly here but `getBinding` reads the
// override store imperatively. Referencing the value forces the
// memo to recompute when the user changes hotkey bindings.
void overrides;

const allModifierKeys = new Set<string>();
const shortcuts: WorkspaceShortcutInfo[] = [];
const comboToIndices = new Map<string, number[]>();
const shortcutLabels = new Map<number, string>();

for (let i = 0; i < WORKSPACE_HOTKEY_IDS.length; i++) {
const rawBinding = getBinding(WORKSPACE_HOTKEY_IDS[i]);
if (!rawBinding) continue;
const chord = parseShortcutBinding(rawBinding).chord;
const { modifierKeys, triggerKey } = parseBinding(chord);
for (const key of modifierKeys) allModifierKeys.add(key);
shortcuts.push({ index: i, triggerKey, modifierKeys });
const comboKey = [...modifierKeys].sort().join("+");
const existing = comboToIndices.get(comboKey) ?? [];
existing.push(i);
comboToIndices.set(comboKey, existing);
shortcutLabels.set(i, formatHotkeyDisplay(chord, PLATFORM).text);
}

return { allModifierKeys, shortcuts, comboToIndices, shortcutLabels };
}, [overrides]);
}
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,17 @@ describe("captureHotkeyFromEvent — codeChord uses event.code, not event.key",
).toBe("f12");
});

it("requires ctrl, meta, or alt (Mac) for non-F-keys", () => {
expect(
captureHotkeyFromEvent(ev({ code: "KeyA", key: "a", shiftKey: true })),
).toBeNull();
// On Mac (test runtime PLATFORM), alt is a valid app modifier
expect(
captureHotkeyFromEvent(ev({ code: "KeyA", key: "a", altKey: true }))
?.codeChord,
).toBe("alt+a");
});

it("returns null when event.code is undefined", () => {
expect(
captureHotkeyFromEvent(ev({ code: undefined, ctrlKey: true })),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,9 @@ import { memo, useCallback, useEffect, useMemo, useState } from "react";
import { createPortal } from "react-dom";
import { HiOutlineCog6Tooth } from "react-icons/hi2";
import { V2AvailableBanner } from "renderer/components/V2AvailableBanner";
import { useModifierKeyListener } from "renderer/hooks/useModifierKeyListener";
import { useHotkeyDisplay } from "renderer/hotkeys";
import { electronTrpc } from "renderer/lib/electron-trpc";
import { useDashboardSidebarState } from "renderer/routes/_authenticated/hooks/useDashboardSidebarState";
import { useLocalHostService } from "renderer/routes/_authenticated/providers/LocalHostServiceProvider";
import { DashboardSidebarHeader } from "./components/DashboardSidebarHeader";
Expand Down Expand Up @@ -96,6 +98,10 @@ const SortableProjectWrapper = memo(function SortableProjectWrapper({
export function DashboardSidebar({
isCollapsed = false,
}: DashboardSidebarProps) {
const { data: showNumbersOnModifier = false } =
electronTrpc.settings.getShowWorkspaceNumbersOnModifier.useQuery();
useModifierKeyListener(showNumbersOnModifier);

const { groups, refreshWorkspacePullRequest, toggleProjectCollapsed } =
useDashboardSidebarData();
const { reorderProjects } = useDashboardSidebarState();
Expand Down
Loading