From 2cd296e38fd7d5b9f49f0e7f96c1977ee1a7d85f Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 20 Apr 2026 18:06:09 +0700 Subject: [PATCH] Fix workspace shortcut navigation --- packages/app/src/app/_layout.tsx | 10 ++ packages/app/src/components/left-sidebar.tsx | 19 +-- .../src/components/project-picker-modal.tsx | 14 +- ...space-shortcut-targets-subscriber.test.tsx | 108 ++++++++++++++ .../workspace-shortcut-targets-subscriber.tsx | 47 ++++++ .../app/src/hooks/use-active-server-id.ts | 15 ++ packages/app/src/hooks/use-command-center.ts | 24 +--- .../src/hooks/use-keyboard-shortcuts.test.tsx | 135 ++++++++++++++++++ .../app/src/hooks/use-keyboard-shortcuts.ts | 46 +++--- .../src/hooks/use-sidebar-shortcut-model.ts | 81 +++-------- .../stores/keyboard-shortcuts-store.test.ts | 1 - .../src/stores/keyboard-shortcuts-store.ts | 5 - packages/app/src/utils/active-host.test.ts | 39 +++++ packages/app/src/utils/active-host.ts | 20 +++ .../app/src/utils/sidebar-shortcuts.test.ts | 71 +++++++-- packages/app/src/utils/sidebar-shortcuts.ts | 33 ++++- 16 files changed, 513 insertions(+), 155 deletions(-) create mode 100644 packages/app/src/components/workspace-shortcut-targets-subscriber.test.tsx create mode 100644 packages/app/src/components/workspace-shortcut-targets-subscriber.tsx create mode 100644 packages/app/src/hooks/use-active-server-id.ts create mode 100644 packages/app/src/hooks/use-keyboard-shortcuts.test.tsx create mode 100644 packages/app/src/utils/active-host.test.ts create mode 100644 packages/app/src/utils/active-host.ts diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index 12f5e7f2a..ab898f32e 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -63,6 +63,8 @@ import { CommandCenter } from "@/components/command-center"; import { ProjectPickerModal } from "@/components/project-picker-modal"; import { KeyboardShortcutsDialog } from "@/components/keyboard-shortcuts-dialog"; import { WorkspaceSetupDialog } from "@/components/workspace-setup-dialog"; +import { WorkspaceShortcutTargetsSubscriber } from "@/components/workspace-shortcut-targets-subscriber"; +import { resolveActiveHost } from "@/utils/active-host"; import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts"; import { useActiveWorktreeNewAction } from "@/hooks/use-active-worktree-new-action"; import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher"; @@ -412,6 +414,10 @@ function AppContainer({ const isCompactLayout = useIsCompactFormFactor(); const chromeEnabled = chromeEnabledOverride ?? daemons.length > 0; const pathname = usePathname(); + const activeServerId = useMemo( + () => resolveActiveHost({ hosts: daemons, pathname })?.serverId ?? null, + [daemons, pathname], + ); const toggleAgentList = isCompactLayout ? toggleMobileAgentList : toggleDesktopAgentList; const toggleDesktopSidebars = useCallback(() => { const { desktop } = usePanelStore.getState(); @@ -492,6 +498,10 @@ function AppContainer({ + diff --git a/packages/app/src/components/left-sidebar.tsx b/packages/app/src/components/left-sidebar.tsx index 964390cc9..75c2b4528 100644 --- a/packages/app/src/components/left-sidebar.tsx +++ b/packages/app/src/components/left-sidebar.tsx @@ -57,10 +57,10 @@ import { buildHostSessionsRoute, buildSettingsRoute, mapPathnameToServer, - parseServerIdFromPathname, } from "@/utils/host-routes"; import { useOpenProjectPicker } from "@/hooks/use-open-project-picker"; import { isWeb } from "@/constants/platform"; +import { resolveActiveHost } from "@/utils/active-host"; const MIN_CHAT_WIDTH = 400; @@ -127,19 +127,10 @@ export const LeftSidebar = memo(function LeftSidebar({ const showMobileAgent = usePanelStore((state) => state.showMobileAgent); const pathname = usePathname(); const daemons = useHosts(); - const activeServerIdFromPath = useMemo(() => parseServerIdFromPathname(pathname), [pathname]); - const activeDaemon = useMemo(() => { - if (daemons.length === 0) { - return null; - } - if (activeServerIdFromPath) { - const routeMatch = daemons.find((entry) => entry.serverId === activeServerIdFromPath); - if (routeMatch) { - return routeMatch; - } - } - return daemons[0] ?? null; - }, [activeServerIdFromPath, daemons]); + const activeDaemon = useMemo( + () => resolveActiveHost({ hosts: daemons, pathname }), + [daemons, pathname], + ); const activeServerId = activeDaemon?.serverId ?? null; const activeHostLabel = useMemo(() => { if (!activeDaemon) return "No host"; diff --git a/packages/app/src/components/project-picker-modal.tsx b/packages/app/src/components/project-picker-modal.tsx index 7bc2e7239..bb3856f29 100644 --- a/packages/app/src/components/project-picker-modal.tsx +++ b/packages/app/src/components/project-picker-modal.tsx @@ -3,30 +3,22 @@ import { Modal, Pressable, ScrollView, Text, TextInput, View } from "react-nativ import { Folder } from "lucide-react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { useQuery } from "@tanstack/react-query"; -import { usePathname } from "expo-router"; import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store"; import { shortenPath } from "@/utils/shorten-path"; import { useRecommendedProjectPaths } from "@/stores/session-store-hooks"; -import { useHosts, useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; +import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import { useOpenProject } from "@/hooks/use-open-project"; -import { parseServerIdFromPathname } from "@/utils/host-routes"; import { buildWorkingDirectorySuggestions } from "@/utils/working-directory-suggestions"; import { isNative } from "@/constants/platform"; +import { useActiveServerId } from "@/hooks/use-active-server-id"; export function ProjectPickerModal() { const { theme } = useUnistyles(); - const pathname = usePathname(); - const daemons = useHosts(); + const serverId = useActiveServerId(); const open = useKeyboardShortcutsStore((s) => s.projectPickerOpen); const setOpen = useKeyboardShortcutsStore((s) => s.setProjectPickerOpen); - const serverId = useMemo(() => { - const fromPath = parseServerIdFromPathname(pathname); - if (fromPath) return fromPath; - return daemons[0]?.serverId ?? null; - }, [pathname, daemons]); - const client = useHostRuntimeClient(serverId ?? ""); const isConnected = useHostRuntimeIsConnected(serverId ?? ""); const recommendedPaths = useRecommendedProjectPaths(serverId); diff --git a/packages/app/src/components/workspace-shortcut-targets-subscriber.test.tsx b/packages/app/src/components/workspace-shortcut-targets-subscriber.test.tsx new file mode 100644 index 000000000..b8e3692f4 --- /dev/null +++ b/packages/app/src/components/workspace-shortcut-targets-subscriber.test.tsx @@ -0,0 +1,108 @@ +/** + * @vitest-environment jsdom + */ +import React from "react"; +import { act } from "@testing-library/react"; +import type { DaemonClient } from "@server/client/daemon-client"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store"; +import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store"; +import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store"; +import { useSidebarOrderStore } from "@/stores/sidebar-order-store"; +import { WorkspaceShortcutTargetsSubscriber } from "./workspace-shortcut-targets-subscriber"; + +vi.hoisted(() => { + (globalThis as unknown as { __DEV__: boolean }).__DEV__ = false; +}); + +function workspaceDescriptor(input: { + id: string; + name?: string; + projectId?: string; +}): WorkspaceDescriptor { + return { + id: input.id, + projectId: input.projectId ?? "project-1", + projectDisplayName: "Project 1", + projectRootPath: "/repo/main", + workspaceDirectory: `/repo/main/${input.id}`, + projectKind: "git", + workspaceKind: "worktree", + name: input.name ?? input.id, + status: "done", + diffStat: null, + scripts: [], + }; +} + +describe("WorkspaceShortcutTargetsSubscriber", () => { + let root: Root | null = null; + let container: HTMLElement | null = null; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + + useKeyboardShortcutsStore.setState({ + sidebarShortcutWorkspaceTargets: [], + }); + useSidebarCollapsedSectionsStore.setState({ + collapsedProjectKeys: new Set(), + }); + useSidebarOrderStore.setState({ + projectOrderByServerId: {}, + workspaceOrderByServerAndProject: {}, + }); + + act(() => { + useSessionStore.getState().initializeSession("srv", null as unknown as DaemonClient); + useSessionStore.getState().setWorkspaces( + "srv", + new Map([ + ["ws-1", workspaceDescriptor({ id: "ws-1", name: "Workspace 1" })], + ["ws-2", workspaceDescriptor({ id: "ws-2", name: "Workspace 2" })], + ]), + ); + useSessionStore.getState().setHasHydratedWorkspaces("srv", true); + }); + }); + + afterEach(() => { + if (root) { + act(() => { + root?.unmount(); + }); + } + root = null; + container?.remove(); + container = null; + act(() => { + useSessionStore.getState().clearSession("srv"); + }); + }); + + it("publishes workspace shortcut targets without rendering the sidebar", async () => { + await act(async () => { + root?.render(); + }); + + expect(useKeyboardShortcutsStore.getState().sidebarShortcutWorkspaceTargets).toEqual([ + { serverId: "srv", workspaceId: "ws-1" }, + { serverId: "srv", workspaceId: "ws-2" }, + ]); + }); + + it("clears targets when disabled", async () => { + await act(async () => { + root?.render(); + }); + + await act(async () => { + root?.render(); + }); + + expect(useKeyboardShortcutsStore.getState().sidebarShortcutWorkspaceTargets).toEqual([]); + }); +}); diff --git a/packages/app/src/components/workspace-shortcut-targets-subscriber.tsx b/packages/app/src/components/workspace-shortcut-targets-subscriber.tsx new file mode 100644 index 000000000..be91cd606 --- /dev/null +++ b/packages/app/src/components/workspace-shortcut-targets-subscriber.tsx @@ -0,0 +1,47 @@ +import { useEffect, useMemo } from "react"; +import { useSidebarWorkspacesList } from "@/hooks/use-sidebar-workspaces-list"; +import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store"; +import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store"; +import { buildSidebarShortcutModel } from "@/utils/sidebar-shortcuts"; + +export function WorkspaceShortcutTargetsSubscriber({ + enabled, + serverId, +}: { + enabled: boolean; + serverId: string | null; +}) { + const { projects } = useSidebarWorkspacesList({ serverId, enabled }); + const collapsedProjectKeys = useSidebarCollapsedSectionsStore( + (state) => state.collapsedProjectKeys, + ); + const setSidebarShortcutWorkspaceTargets = useKeyboardShortcutsStore( + (state) => state.setSidebarShortcutWorkspaceTargets, + ); + + const shortcutModel = useMemo( + () => + buildSidebarShortcutModel({ + projects, + collapsedProjectKeys, + }), + [collapsedProjectKeys, projects], + ); + + useEffect(() => { + if (!enabled || !serverId) { + setSidebarShortcutWorkspaceTargets([]); + return; + } + + setSidebarShortcutWorkspaceTargets(shortcutModel.shortcutTargets); + }, [enabled, serverId, setSidebarShortcutWorkspaceTargets, shortcutModel.shortcutTargets]); + + useEffect(() => { + return () => { + setSidebarShortcutWorkspaceTargets([]); + }; + }, [setSidebarShortcutWorkspaceTargets]); + + return null; +} diff --git a/packages/app/src/hooks/use-active-server-id.ts b/packages/app/src/hooks/use-active-server-id.ts new file mode 100644 index 000000000..369cd005e --- /dev/null +++ b/packages/app/src/hooks/use-active-server-id.ts @@ -0,0 +1,15 @@ +import { useMemo } from "react"; +import { usePathname } from "expo-router"; +import { useHosts } from "@/runtime/host-runtime"; +import { resolveActiveHost } from "@/utils/active-host"; + +export function useActiveHost() { + const pathname = usePathname(); + const hosts = useHosts(); + + return useMemo(() => resolveActiveHost({ hosts, pathname }), [hosts, pathname]); +} + +export function useActiveServerId(): string | null { + return useActiveHost()?.serverId ?? null; +} diff --git a/packages/app/src/hooks/use-command-center.ts b/packages/app/src/hooks/use-command-center.ts index 6f2bd9a00..33f9c2606 100644 --- a/packages/app/src/hooks/use-command-center.ts +++ b/packages/app/src/hooks/use-command-center.ts @@ -4,7 +4,6 @@ import { router, usePathname, type Href } from "expo-router"; import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store"; import { useSessionStore } from "@/stores/session-store"; import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher"; -import { useHosts } from "@/runtime/host-runtime"; import { useAllAgentsList } from "@/hooks/use-all-agents-list"; import type { AggregatedAgent } from "@/hooks/use-aggregated-agents"; import { useOpenProjectPicker } from "@/hooks/use-open-project-picker"; @@ -12,11 +11,7 @@ import { clearCommandCenterFocusRestoreElement, takeCommandCenterFocusRestoreElement, } from "@/utils/command-center-focus-restore"; -import { - buildHostAgentDetailRoute, - buildSettingsRoute, - parseServerIdFromPathname, -} from "@/utils/host-routes"; +import { buildHostAgentDetailRoute, buildSettingsRoute } from "@/utils/host-routes"; import type { ShortcutKey } from "@/utils/format-shortcut"; import { chordStringToShortcutKeys } from "@/keyboard/shortcut-string"; import { getBindingIdForAction, getDefaultKeysForAction } from "@/keyboard/keyboard-shortcuts"; @@ -26,6 +21,7 @@ import { getIsElectronRuntime } from "@/constants/layout"; import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution"; import { prepareWorkspaceTab } from "@/utils/workspace-navigation"; import { focusWithRetries } from "@/utils/web-focus"; +import { useActiveServerId } from "@/hooks/use-active-server-id"; const EMPTY_AGENTS: AggregatedAgent[] = []; const EMPTY_ACTION_ITEMS: CommandCenterActionItem[] = []; @@ -128,7 +124,7 @@ function resolveActionShortcutKeys( export function useCommandCenter() { const pathname = usePathname(); - const daemons = useHosts(); + const routeActiveServerId = useActiveServerId(); const { overrides } = useKeyboardShortcutOverrides(); const open = useKeyboardShortcutsStore((s) => s.commandCenterOpen); const setOpen = useKeyboardShortcutsStore((s) => s.setCommandCenterOpen); @@ -142,19 +138,7 @@ export function useCommandCenter() { const [query, setQuery] = useState(""); const [activeIndex, setActiveIndex] = useState(0); - const activeServerId = useMemo(() => { - if (!open) { - return null; - } - const serverIdFromPath = parseServerIdFromPathname(pathname); - if (serverIdFromPath) { - const routeMatch = daemons.find((entry) => entry.serverId === serverIdFromPath); - if (routeMatch) { - return routeMatch.serverId; - } - } - return daemons[0]?.serverId ?? null; - }, [daemons, open, pathname]); + const activeServerId = open ? routeActiveServerId : null; const { agents } = useAllAgentsList({ serverId: activeServerId, diff --git a/packages/app/src/hooks/use-keyboard-shortcuts.test.tsx b/packages/app/src/hooks/use-keyboard-shortcuts.test.tsx new file mode 100644 index 000000000..91db839ed --- /dev/null +++ b/packages/app/src/hooks/use-keyboard-shortcuts.test.tsx @@ -0,0 +1,135 @@ +/** + * @vitest-environment jsdom + */ +import React from "react"; +import { act } from "@testing-library/react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const { navigateToWorkspaceMock, routerMock, pathState } = vi.hoisted(() => ({ + navigateToWorkspaceMock: vi.fn(), + routerMock: { + back: vi.fn(), + push: vi.fn(), + }, + pathState: { + pathname: "/h/srv/workspace/ws-2", + }, +})); + +vi.hoisted(() => { + (globalThis as unknown as { __DEV__: boolean }).__DEV__ = false; +}); + +vi.mock("expo-router", () => ({ + usePathname: () => pathState.pathname, + useRouter: () => routerMock, +})); + +vi.mock("@/constants/layout", () => ({ + getIsElectronRuntime: () => true, +})); + +vi.mock("@/constants/platform", () => ({ + isNative: false, + isWeb: true, +})); + +vi.mock("@/utils/shortcut-platform", () => ({ + getShortcutOs: () => "mac", +})); + +vi.mock("@/hooks/use-active-server-id", () => ({ + useActiveServerId: () => "srv", +})); + +vi.mock("@/hooks/use-open-project-picker", () => ({ + useOpenProjectPicker: () => vi.fn(), +})); + +vi.mock("@/hooks/use-keyboard-shortcut-overrides", () => ({ + useKeyboardShortcutOverrides: () => ({ overrides: {} }), +})); + +vi.mock("@/hooks/use-workspace-navigation", () => ({ + navigateToWorkspace: navigateToWorkspaceMock, +})); + +import { useKeyboardShortcuts } from "./use-keyboard-shortcuts"; +import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store"; +import { + activateNavigationWorkspaceSelection, + syncNavigationActiveWorkspace, +} from "@/stores/navigation-active-workspace-store"; + +function Probe() { + useKeyboardShortcuts({ + enabled: true, + isMobile: false, + toggleAgentList: vi.fn(), + }); + + return null; +} + +describe("useKeyboardShortcuts", () => { + let root: Root | null = null; + let container: HTMLElement | null = null; + + beforeEach(() => { + navigateToWorkspaceMock.mockReset(); + routerMock.back.mockReset(); + routerMock.push.mockReset(); + pathState.pathname = "/h/srv/workspace/ws-2"; + syncNavigationActiveWorkspace({ current: null }); + useKeyboardShortcutsStore.setState({ + capturingShortcut: false, + commandCenterOpen: false, + sidebarShortcutWorkspaceTargets: [ + { serverId: "srv", workspaceId: "ws-1" }, + { serverId: "srv", workspaceId: "ws-2" }, + { serverId: "srv", workspaceId: "ws-3" }, + { serverId: "srv", workspaceId: "ws-4" }, + { serverId: "srv", workspaceId: "ws-5" }, + ], + }); + + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + if (root) { + act(() => { + root?.unmount(); + }); + } + root = null; + container?.remove(); + container = null; + syncNavigationActiveWorkspace({ current: null }); + }); + + it("uses the retained active workspace instead of stale pathname for bracket navigation", async () => { + activateNavigationWorkspaceSelection({ serverId: "srv", workspaceId: "ws-4" }); + + await act(async () => { + root?.render(); + }); + + const event = new KeyboardEvent("keydown", { + key: "]", + code: "BracketRight", + metaKey: true, + bubbles: true, + cancelable: true, + }); + window.dispatchEvent(event); + + expect(navigateToWorkspaceMock).toHaveBeenCalledWith("srv", "ws-5", { + currentPathname: "/h/srv/workspace/ws-2", + }); + expect(event.defaultPrevented).toBe(true); + }); +}); diff --git a/packages/app/src/hooks/use-keyboard-shortcuts.ts b/packages/app/src/hooks/use-keyboard-shortcuts.ts index 998444cab..5a0a71ee6 100644 --- a/packages/app/src/hooks/use-keyboard-shortcuts.ts +++ b/packages/app/src/hooks/use-keyboard-shortcuts.ts @@ -1,13 +1,11 @@ import { useEffect, useMemo, useRef } from "react"; import { usePathname, useRouter } from "expo-router"; import { getIsElectronRuntime } from "@/constants/layout"; -import { useHosts } from "@/runtime/host-runtime"; import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store"; import { setCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore"; import { buildSettingsRoute, parseHostAgentRouteFromPathname, - parseServerIdFromPathname, parseHostWorkspaceRouteFromPathname, } from "@/utils/host-routes"; import { navigateToWorkspace } from "@/hooks/use-workspace-navigation"; @@ -27,6 +25,9 @@ import { useOpenProjectPicker } from "@/hooks/use-open-project-picker"; import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides"; import { isNative } from "@/constants/platform"; import { isImeComposingKeyboardEvent } from "@/utils/keyboard-ime"; +import { getRelativeSidebarShortcutTarget } from "@/utils/sidebar-shortcuts"; +import { useActiveServerId } from "@/hooks/use-active-server-id"; +import { getNavigationActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store"; export function useKeyboardShortcuts({ enabled, @@ -45,7 +46,6 @@ export function useKeyboardShortcuts({ }) { const pathname = usePathname(); const router = useRouter(); - const hosts = useHosts(); const resetModifiers = useKeyboardShortcutsStore((s) => s.resetModifiers); const { overrides } = useKeyboardShortcutOverrides(); const bindings = useMemo(() => buildEffectiveBindings(overrides), [overrides]); @@ -54,11 +54,7 @@ export function useKeyboardShortcuts({ step: 0, timeoutId: null, }); - const activeServerIdFromPath = parseServerIdFromPathname(pathname); - const activeServerId = - hosts.find((host) => host.serverId === activeServerIdFromPath)?.serverId ?? - hosts[0]?.serverId ?? - null; + const activeServerId = useActiveServerId(); const openProjectPickerAction = useOpenProjectPicker(activeServerId); useEffect(() => { @@ -87,31 +83,23 @@ export function useKeyboardShortcuts({ }; const navigateRelativeWorkspace = (delta: 1 | -1): boolean => { const state = useKeyboardShortcutsStore.getState(); - const targets = state.visibleWorkspaceTargets; + const targets = state.sidebarShortcutWorkspaceTargets; if (targets.length === 0) { return false; } - const workspaceRoute = parseHostWorkspaceRouteFromPathname(pathname); - if (!workspaceRoute) { - const fallback = targets[delta > 0 ? 0 : targets.length - 1] ?? null; - if (!fallback) { - return false; - } - navigateToWorkspace(fallback.serverId, fallback.workspaceId, { - currentPathname: pathname, - }); - return true; - } - - const currentIndex = targets.findIndex( - (target) => - target.serverId === workspaceRoute.serverId && - target.workspaceId === workspaceRoute.workspaceId, - ); - const fromIndex = currentIndex >= 0 ? currentIndex : delta > 0 ? -1 : 0; - const nextIndex = (fromIndex + delta + targets.length) % targets.length; - const target = targets[nextIndex] ?? null; + const workspaceRoute = + getNavigationActiveWorkspaceSelection() ?? parseHostWorkspaceRouteFromPathname(pathname); + const target = getRelativeSidebarShortcutTarget({ + targets, + currentTarget: workspaceRoute + ? { + serverId: workspaceRoute.serverId, + workspaceId: workspaceRoute.workspaceId, + } + : null, + delta, + }); if (!target) { return false; } diff --git a/packages/app/src/hooks/use-sidebar-shortcut-model.ts b/packages/app/src/hooks/use-sidebar-shortcut-model.ts index 63602c314..ec06085f4 100644 --- a/packages/app/src/hooks/use-sidebar-shortcut-model.ts +++ b/packages/app/src/hooks/use-sidebar-shortcut-model.ts @@ -1,16 +1,18 @@ -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo } from "react"; import type { SidebarProjectEntry } from "@/hooks/use-sidebar-workspaces-list"; -import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store"; import { buildSidebarShortcutModel } from "@/utils/sidebar-shortcuts"; import { isSidebarProjectFlattened } from "@/utils/sidebar-project-row-model"; +import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store"; export function useSidebarShortcutModel(projects: SidebarProjectEntry[]) { - const [collapsedProjectKeys, setCollapsedProjectKeys] = useState>(new Set()); - const setSidebarShortcutWorkspaceTargets = useKeyboardShortcutsStore( - (state) => state.setSidebarShortcutWorkspaceTargets, + const collapsedProjectKeys = useSidebarCollapsedSectionsStore( + (state) => state.collapsedProjectKeys, ); - const setVisibleWorkspaceTargets = useKeyboardShortcutsStore( - (state) => state.setVisibleWorkspaceTargets, + const setProjectCollapsed = useSidebarCollapsedSectionsStore( + (state) => state.setProjectCollapsed, + ); + const toggleProjectCollapsed = useSidebarCollapsedSectionsStore( + (state) => state.toggleProjectCollapsed, ); const shortcutModel = useMemo( @@ -23,62 +25,17 @@ export function useSidebarShortcutModel(projects: SidebarProjectEntry[]) { ); useEffect(() => { - setCollapsedProjectKeys((prev) => { - const collapsibleProjectKeys = new Set( - projects - .filter((project) => !isSidebarProjectFlattened(project)) - .map((project) => project.projectKey), - ); - const next = new Set(); - for (const key of prev) { - if (collapsibleProjectKeys.has(key)) { - next.add(key); - } + const collapsibleProjectKeys = new Set( + projects + .filter((project) => !isSidebarProjectFlattened(project)) + .map((project) => project.projectKey), + ); + for (const key of collapsedProjectKeys) { + if (!collapsibleProjectKeys.has(key)) { + setProjectCollapsed(key, false); } - return next; - }); - }, [projects]); - - useEffect(() => { - setSidebarShortcutWorkspaceTargets(shortcutModel.shortcutTargets); - setVisibleWorkspaceTargets(shortcutModel.visibleTargets); - }, [ - setSidebarShortcutWorkspaceTargets, - setVisibleWorkspaceTargets, - shortcutModel.shortcutTargets, - shortcutModel.visibleTargets, - ]); - - useEffect(() => { - return () => { - setSidebarShortcutWorkspaceTargets([]); - setVisibleWorkspaceTargets([]); - }; - }, [setSidebarShortcutWorkspaceTargets, setVisibleWorkspaceTargets]); - - const toggleProjectCollapsed = useCallback((projectKey: string) => { - setCollapsedProjectKeys((prev) => { - const next = new Set(prev); - if (next.has(projectKey)) { - next.delete(projectKey); - } else { - next.add(projectKey); - } - return next; - }); - }, []); - - const setProjectCollapsed = useCallback((projectKey: string, collapsed: boolean) => { - setCollapsedProjectKeys((prev) => { - const next = new Set(prev); - if (collapsed) { - next.add(projectKey); - } else { - next.delete(projectKey); - } - return next; - }); - }, []); + } + }, [collapsedProjectKeys, projects, setProjectCollapsed]); return { collapsedProjectKeys, diff --git a/packages/app/src/stores/keyboard-shortcuts-store.test.ts b/packages/app/src/stores/keyboard-shortcuts-store.test.ts index 57a505237..d0291dad5 100644 --- a/packages/app/src/stores/keyboard-shortcuts-store.test.ts +++ b/packages/app/src/stores/keyboard-shortcuts-store.test.ts @@ -10,7 +10,6 @@ beforeEach(() => { altDown: false, cmdOrCtrlDown: false, sidebarShortcutWorkspaceTargets: [], - visibleWorkspaceTargets: [], }); }); diff --git a/packages/app/src/stores/keyboard-shortcuts-store.ts b/packages/app/src/stores/keyboard-shortcuts-store.ts index a3b2e2eca..d47084a58 100644 --- a/packages/app/src/stores/keyboard-shortcuts-store.ts +++ b/packages/app/src/stores/keyboard-shortcuts-store.ts @@ -13,8 +13,6 @@ interface KeyboardShortcutsState { showShortcutBadges: boolean; /** Sidebar-visible workspace targets (up to 9), in top-to-bottom visual order. */ sidebarShortcutWorkspaceTargets: SidebarShortcutWorkspaceTarget[]; - /** All visible workspace targets in top-to-bottom visual order. */ - visibleWorkspaceTargets: SidebarShortcutWorkspaceTarget[]; setCommandCenterOpen: (open: boolean) => void; setProjectPickerOpen: (open: boolean) => void; @@ -23,7 +21,6 @@ interface KeyboardShortcutsState { setAltDown: (down: boolean) => void; setCmdOrCtrlDown: (down: boolean) => void; setSidebarShortcutWorkspaceTargets: (targets: SidebarShortcutWorkspaceTarget[]) => void; - setVisibleWorkspaceTargets: (targets: SidebarShortcutWorkspaceTarget[]) => void; resetModifiers: () => void; } @@ -59,7 +56,6 @@ export const useKeyboardShortcutsStore = create((set, ge cmdOrCtrlDown: false, showShortcutBadges: false, sidebarShortcutWorkspaceTargets: [], - visibleWorkspaceTargets: [], setCommandCenterOpen: (open) => set({ commandCenterOpen: open }), setProjectPickerOpen: (open) => set({ projectPickerOpen: open }), @@ -75,7 +71,6 @@ export const useKeyboardShortcutsStore = create((set, ge }, setSidebarShortcutWorkspaceTargets: (targets) => set({ sidebarShortcutWorkspaceTargets: targets }), - setVisibleWorkspaceTargets: (targets) => set({ visibleWorkspaceTargets: targets }), resetModifiers: () => { set({ altDown: false, cmdOrCtrlDown: false }); updateBadgeTimer(set, get); diff --git a/packages/app/src/utils/active-host.test.ts b/packages/app/src/utils/active-host.test.ts new file mode 100644 index 000000000..b791f6e74 --- /dev/null +++ b/packages/app/src/utils/active-host.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; +import type { HostProfile } from "@/types/host-connection"; +import { resolveActiveHost } from "./active-host"; + +function host(serverId: string): HostProfile { + return { + serverId, + label: serverId, + lifecycle: {}, + connections: [], + preferredConnectionId: null, + createdAt: "", + updatedAt: "", + }; +} + +describe("resolveActiveHost", () => { + it("uses the route host when it exists", () => { + expect( + resolveActiveHost({ + hosts: [host("srv-1"), host("srv-2")], + pathname: "/h/srv-2/workspace/ws-main", + })?.serverId, + ).toBe("srv-2"); + }); + + it("falls back to the first host when the route host is missing", () => { + expect( + resolveActiveHost({ + hosts: [host("srv-1"), host("srv-2")], + pathname: "/h/missing/workspace/ws-main", + })?.serverId, + ).toBe("srv-1"); + }); + + it("returns null when no hosts exist", () => { + expect(resolveActiveHost({ hosts: [], pathname: "/settings" })).toBeNull(); + }); +}); diff --git a/packages/app/src/utils/active-host.ts b/packages/app/src/utils/active-host.ts new file mode 100644 index 000000000..9662d9048 --- /dev/null +++ b/packages/app/src/utils/active-host.ts @@ -0,0 +1,20 @@ +import type { HostProfile } from "@/types/host-connection"; +import { parseServerIdFromPathname } from "@/utils/host-routes"; + +export function resolveActiveHost({ + hosts, + pathname, +}: { + hosts: readonly HostProfile[]; + pathname: string; +}): HostProfile | null { + const serverIdFromPath = parseServerIdFromPathname(pathname); + if (serverIdFromPath) { + const routeMatch = hosts.find((host) => host.serverId === serverIdFromPath); + if (routeMatch) { + return routeMatch; + } + } + + return hosts[0] ?? null; +} diff --git a/packages/app/src/utils/sidebar-shortcuts.test.ts b/packages/app/src/utils/sidebar-shortcuts.test.ts index 3fe0d20eb..19e6d3464 100644 --- a/packages/app/src/utils/sidebar-shortcuts.test.ts +++ b/packages/app/src/utils/sidebar-shortcuts.test.ts @@ -4,7 +4,7 @@ import type { SidebarWorkspaceEntry, } from "@/hooks/use-sidebar-workspaces-list"; -import { buildSidebarShortcutModel } from "./sidebar-shortcuts"; +import { buildSidebarShortcutModel, getRelativeSidebarShortcutTarget } from "./sidebar-shortcuts"; function workspace(input: { serverId: string; @@ -75,10 +75,6 @@ describe("buildSidebarShortcutModel", () => { collapsedProjectKeys: new Set(["p2"]), }); - expect(model.visibleTargets).toEqual([ - { serverId: "s1", workspaceId: "ws-main" }, - { serverId: "s1", workspaceId: "ws-feat-a" }, - ]); expect(model.shortcutTargets).toEqual([ { serverId: "s1", workspaceId: "ws-main" }, { serverId: "s1", workspaceId: "ws-feat-a" }, @@ -104,8 +100,6 @@ describe("buildSidebarShortcutModel", () => { collapsedProjectKeys: new Set(), }); - expect(model.visibleTargets).toHaveLength(20); - expect(model.visibleTargets[19]).toEqual({ serverId: "s", workspaceId: "ws-20" }); expect(model.shortcutTargets).toHaveLength(9); expect(model.shortcutTargets[0]).toEqual({ serverId: "s", workspaceId: "ws-1" }); expect(model.shortcutTargets[8]).toEqual({ serverId: "s", workspaceId: "ws-9" }); @@ -128,7 +122,68 @@ describe("buildSidebarShortcutModel", () => { collapsedProjectKeys: new Set(["p1"]), }); - expect(model.visibleTargets).toEqual([]); expect(model.shortcutTargets).toEqual([]); }); }); + +describe("getRelativeSidebarShortcutTarget", () => { + const targets = [ + { serverId: "s1", workspaceId: "ws-1" }, + { serverId: "s1", workspaceId: "ws-2" }, + { serverId: "s1", workspaceId: "ws-3" }, + ]; + + it("moves backward and forward through the numbered shortcut target list", () => { + expect( + getRelativeSidebarShortcutTarget({ + targets, + currentTarget: { serverId: "s1", workspaceId: "ws-2" }, + delta: -1, + }), + ).toEqual({ serverId: "s1", workspaceId: "ws-1" }); + + expect( + getRelativeSidebarShortcutTarget({ + targets, + currentTarget: { serverId: "s1", workspaceId: "ws-2" }, + delta: 1, + }), + ).toEqual({ serverId: "s1", workspaceId: "ws-3" }); + }); + + it("wraps around the numbered shortcut target list", () => { + expect( + getRelativeSidebarShortcutTarget({ + targets, + currentTarget: { serverId: "s1", workspaceId: "ws-1" }, + delta: -1, + }), + ).toEqual({ serverId: "s1", workspaceId: "ws-3" }); + + expect( + getRelativeSidebarShortcutTarget({ + targets, + currentTarget: { serverId: "s1", workspaceId: "ws-3" }, + delta: 1, + }), + ).toEqual({ serverId: "s1", workspaceId: "ws-1" }); + }); + + it("falls back to the nearest edge when the current route is not in the numbered list", () => { + expect( + getRelativeSidebarShortcutTarget({ + targets, + currentTarget: { serverId: "s1", workspaceId: "ws-hidden" }, + delta: 1, + }), + ).toEqual({ serverId: "s1", workspaceId: "ws-1" }); + + expect( + getRelativeSidebarShortcutTarget({ + targets, + currentTarget: { serverId: "s1", workspaceId: "ws-hidden" }, + delta: -1, + }), + ).toEqual({ serverId: "s1", workspaceId: "ws-3" }); + }); +}); diff --git a/packages/app/src/utils/sidebar-shortcuts.ts b/packages/app/src/utils/sidebar-shortcuts.ts index be2193892..22237756c 100644 --- a/packages/app/src/utils/sidebar-shortcuts.ts +++ b/packages/app/src/utils/sidebar-shortcuts.ts @@ -10,7 +10,6 @@ export interface SidebarShortcutWorkspaceTarget { } export interface SidebarShortcutModel { - visibleTargets: SidebarShortcutWorkspaceTarget[]; shortcutTargets: SidebarShortcutWorkspaceTarget[]; shortcutIndexByWorkspaceKey: Map; } @@ -28,7 +27,6 @@ export function buildSidebarShortcutModel(input: { shortcutLimit?: number; }): SidebarShortcutModel { const maxShortcuts = Math.max(0, Math.floor(input.shortcutLimit ?? 9)); - const visibleTargets: SidebarShortcutWorkspaceTarget[] = []; const shortcutTargets: SidebarShortcutWorkspaceTarget[] = []; const shortcutIndexByWorkspaceKey = new Map(); @@ -38,8 +36,6 @@ export function buildSidebarShortcutModel(input: { } for (const workspace of project.workspaces) { - visibleTargets.push(createShortcutTarget(workspace)); - if (shortcutTargets.length >= maxShortcuts) { continue; } @@ -50,5 +46,32 @@ export function buildSidebarShortcutModel(input: { } } - return { visibleTargets, shortcutTargets, shortcutIndexByWorkspaceKey }; + return { shortcutTargets, shortcutIndexByWorkspaceKey }; +} + +export function getRelativeSidebarShortcutTarget(input: { + targets: readonly SidebarShortcutWorkspaceTarget[]; + currentTarget: SidebarShortcutWorkspaceTarget | null; + delta: 1 | -1; +}): SidebarShortcutWorkspaceTarget | null { + if (input.targets.length === 0) { + return null; + } + + if (!input.currentTarget) { + return input.targets[input.delta > 0 ? 0 : input.targets.length - 1] ?? null; + } + + const currentTarget = input.currentTarget; + const currentIndex = input.targets.findIndex( + (target) => + target.serverId === currentTarget.serverId && + target.workspaceId === currentTarget.workspaceId, + ); + if (currentIndex < 0) { + return input.targets[input.delta > 0 ? 0 : input.targets.length - 1] ?? null; + } + + const nextIndex = (currentIndex + input.delta + input.targets.length) % input.targets.length; + return input.targets[nextIndex] ?? null; }