Fix workspace shortcut navigation

This commit is contained in:
Mohamed Boudra
2026-04-20 18:06:09 +07:00
parent 95c1d1fe0f
commit 2cd296e38f
16 changed files with 513 additions and 155 deletions
+10
View File
@@ -63,6 +63,8 @@ import { CommandCenter } from "@/components/command-center";
import { ProjectPickerModal } from "@/components/project-picker-modal"; import { ProjectPickerModal } from "@/components/project-picker-modal";
import { KeyboardShortcutsDialog } from "@/components/keyboard-shortcuts-dialog"; import { KeyboardShortcutsDialog } from "@/components/keyboard-shortcuts-dialog";
import { WorkspaceSetupDialog } from "@/components/workspace-setup-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 { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
import { useActiveWorktreeNewAction } from "@/hooks/use-active-worktree-new-action"; import { useActiveWorktreeNewAction } from "@/hooks/use-active-worktree-new-action";
import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher"; import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher";
@@ -412,6 +414,10 @@ function AppContainer({
const isCompactLayout = useIsCompactFormFactor(); const isCompactLayout = useIsCompactFormFactor();
const chromeEnabled = chromeEnabledOverride ?? daemons.length > 0; const chromeEnabled = chromeEnabledOverride ?? daemons.length > 0;
const pathname = usePathname(); const pathname = usePathname();
const activeServerId = useMemo(
() => resolveActiveHost({ hosts: daemons, pathname })?.serverId ?? null,
[daemons, pathname],
);
const toggleAgentList = isCompactLayout ? toggleMobileAgentList : toggleDesktopAgentList; const toggleAgentList = isCompactLayout ? toggleMobileAgentList : toggleDesktopAgentList;
const toggleDesktopSidebars = useCallback(() => { const toggleDesktopSidebars = useCallback(() => {
const { desktop } = usePanelStore.getState(); const { desktop } = usePanelStore.getState();
@@ -492,6 +498,10 @@ function AppContainer({
<UpdateBanner /> <UpdateBanner />
<CommandCenter /> <CommandCenter />
<ProjectPickerModal /> <ProjectPickerModal />
<WorkspaceShortcutTargetsSubscriber
enabled={keyboardShortcutsEnabled}
serverId={activeServerId}
/>
<WorkspaceSetupDialog /> <WorkspaceSetupDialog />
<KeyboardShortcutsDialog /> <KeyboardShortcutsDialog />
</View> </View>
+5 -14
View File
@@ -57,10 +57,10 @@ import {
buildHostSessionsRoute, buildHostSessionsRoute,
buildSettingsRoute, buildSettingsRoute,
mapPathnameToServer, mapPathnameToServer,
parseServerIdFromPathname,
} from "@/utils/host-routes"; } from "@/utils/host-routes";
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker"; import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
import { isWeb } from "@/constants/platform"; import { isWeb } from "@/constants/platform";
import { resolveActiveHost } from "@/utils/active-host";
const MIN_CHAT_WIDTH = 400; const MIN_CHAT_WIDTH = 400;
@@ -127,19 +127,10 @@ export const LeftSidebar = memo(function LeftSidebar({
const showMobileAgent = usePanelStore((state) => state.showMobileAgent); const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
const pathname = usePathname(); const pathname = usePathname();
const daemons = useHosts(); const daemons = useHosts();
const activeServerIdFromPath = useMemo(() => parseServerIdFromPathname(pathname), [pathname]); const activeDaemon = useMemo(
const activeDaemon = useMemo(() => { () => resolveActiveHost({ hosts: daemons, pathname }),
if (daemons.length === 0) { [daemons, pathname],
return null; );
}
if (activeServerIdFromPath) {
const routeMatch = daemons.find((entry) => entry.serverId === activeServerIdFromPath);
if (routeMatch) {
return routeMatch;
}
}
return daemons[0] ?? null;
}, [activeServerIdFromPath, daemons]);
const activeServerId = activeDaemon?.serverId ?? null; const activeServerId = activeDaemon?.serverId ?? null;
const activeHostLabel = useMemo(() => { const activeHostLabel = useMemo(() => {
if (!activeDaemon) return "No host"; if (!activeDaemon) return "No host";
@@ -3,30 +3,22 @@ import { Modal, Pressable, ScrollView, Text, TextInput, View } from "react-nativ
import { Folder } from "lucide-react-native"; import { Folder } from "lucide-react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { usePathname } from "expo-router";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store"; import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { shortenPath } from "@/utils/shorten-path"; import { shortenPath } from "@/utils/shorten-path";
import { useRecommendedProjectPaths } from "@/stores/session-store-hooks"; 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 { useOpenProject } from "@/hooks/use-open-project";
import { parseServerIdFromPathname } from "@/utils/host-routes";
import { buildWorkingDirectorySuggestions } from "@/utils/working-directory-suggestions"; import { buildWorkingDirectorySuggestions } from "@/utils/working-directory-suggestions";
import { isNative } from "@/constants/platform"; import { isNative } from "@/constants/platform";
import { useActiveServerId } from "@/hooks/use-active-server-id";
export function ProjectPickerModal() { export function ProjectPickerModal() {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const pathname = usePathname(); const serverId = useActiveServerId();
const daemons = useHosts();
const open = useKeyboardShortcutsStore((s) => s.projectPickerOpen); const open = useKeyboardShortcutsStore((s) => s.projectPickerOpen);
const setOpen = useKeyboardShortcutsStore((s) => s.setProjectPickerOpen); 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 client = useHostRuntimeClient(serverId ?? "");
const isConnected = useHostRuntimeIsConnected(serverId ?? ""); const isConnected = useHostRuntimeIsConnected(serverId ?? "");
const recommendedPaths = useRecommendedProjectPaths(serverId); const recommendedPaths = useRecommendedProjectPaths(serverId);
@@ -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(<WorkspaceShortcutTargetsSubscriber enabled={true} serverId="srv" />);
});
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(<WorkspaceShortcutTargetsSubscriber enabled={true} serverId="srv" />);
});
await act(async () => {
root?.render(<WorkspaceShortcutTargetsSubscriber enabled={false} serverId="srv" />);
});
expect(useKeyboardShortcutsStore.getState().sidebarShortcutWorkspaceTargets).toEqual([]);
});
});
@@ -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;
}
@@ -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;
}
+4 -20
View File
@@ -4,7 +4,6 @@ import { router, usePathname, type Href } from "expo-router";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store"; import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { useSessionStore } from "@/stores/session-store"; import { useSessionStore } from "@/stores/session-store";
import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher"; import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher";
import { useHosts } from "@/runtime/host-runtime";
import { useAllAgentsList } from "@/hooks/use-all-agents-list"; import { useAllAgentsList } from "@/hooks/use-all-agents-list";
import type { AggregatedAgent } from "@/hooks/use-aggregated-agents"; import type { AggregatedAgent } from "@/hooks/use-aggregated-agents";
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker"; import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
@@ -12,11 +11,7 @@ import {
clearCommandCenterFocusRestoreElement, clearCommandCenterFocusRestoreElement,
takeCommandCenterFocusRestoreElement, takeCommandCenterFocusRestoreElement,
} from "@/utils/command-center-focus-restore"; } from "@/utils/command-center-focus-restore";
import { import { buildHostAgentDetailRoute, buildSettingsRoute } from "@/utils/host-routes";
buildHostAgentDetailRoute,
buildSettingsRoute,
parseServerIdFromPathname,
} from "@/utils/host-routes";
import type { ShortcutKey } from "@/utils/format-shortcut"; import type { ShortcutKey } from "@/utils/format-shortcut";
import { chordStringToShortcutKeys } from "@/keyboard/shortcut-string"; import { chordStringToShortcutKeys } from "@/keyboard/shortcut-string";
import { getBindingIdForAction, getDefaultKeysForAction } from "@/keyboard/keyboard-shortcuts"; import { getBindingIdForAction, getDefaultKeysForAction } from "@/keyboard/keyboard-shortcuts";
@@ -26,6 +21,7 @@ import { getIsElectronRuntime } from "@/constants/layout";
import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution"; import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution";
import { prepareWorkspaceTab } from "@/utils/workspace-navigation"; import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
import { focusWithRetries } from "@/utils/web-focus"; import { focusWithRetries } from "@/utils/web-focus";
import { useActiveServerId } from "@/hooks/use-active-server-id";
const EMPTY_AGENTS: AggregatedAgent[] = []; const EMPTY_AGENTS: AggregatedAgent[] = [];
const EMPTY_ACTION_ITEMS: CommandCenterActionItem[] = []; const EMPTY_ACTION_ITEMS: CommandCenterActionItem[] = [];
@@ -128,7 +124,7 @@ function resolveActionShortcutKeys(
export function useCommandCenter() { export function useCommandCenter() {
const pathname = usePathname(); const pathname = usePathname();
const daemons = useHosts(); const routeActiveServerId = useActiveServerId();
const { overrides } = useKeyboardShortcutOverrides(); const { overrides } = useKeyboardShortcutOverrides();
const open = useKeyboardShortcutsStore((s) => s.commandCenterOpen); const open = useKeyboardShortcutsStore((s) => s.commandCenterOpen);
const setOpen = useKeyboardShortcutsStore((s) => s.setCommandCenterOpen); const setOpen = useKeyboardShortcutsStore((s) => s.setCommandCenterOpen);
@@ -142,19 +138,7 @@ export function useCommandCenter() {
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [activeIndex, setActiveIndex] = useState(0); const [activeIndex, setActiveIndex] = useState(0);
const activeServerId = useMemo(() => { const activeServerId = open ? routeActiveServerId : null;
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 { agents } = useAllAgentsList({ const { agents } = useAllAgentsList({
serverId: activeServerId, serverId: activeServerId,
@@ -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(<Probe />);
});
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);
});
});
@@ -1,13 +1,11 @@
import { useEffect, useMemo, useRef } from "react"; import { useEffect, useMemo, useRef } from "react";
import { usePathname, useRouter } from "expo-router"; import { usePathname, useRouter } from "expo-router";
import { getIsElectronRuntime } from "@/constants/layout"; import { getIsElectronRuntime } from "@/constants/layout";
import { useHosts } from "@/runtime/host-runtime";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store"; import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { setCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore"; import { setCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore";
import { import {
buildSettingsRoute, buildSettingsRoute,
parseHostAgentRouteFromPathname, parseHostAgentRouteFromPathname,
parseServerIdFromPathname,
parseHostWorkspaceRouteFromPathname, parseHostWorkspaceRouteFromPathname,
} from "@/utils/host-routes"; } from "@/utils/host-routes";
import { navigateToWorkspace } from "@/hooks/use-workspace-navigation"; 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 { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides";
import { isNative } from "@/constants/platform"; import { isNative } from "@/constants/platform";
import { isImeComposingKeyboardEvent } from "@/utils/keyboard-ime"; 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({ export function useKeyboardShortcuts({
enabled, enabled,
@@ -45,7 +46,6 @@ export function useKeyboardShortcuts({
}) { }) {
const pathname = usePathname(); const pathname = usePathname();
const router = useRouter(); const router = useRouter();
const hosts = useHosts();
const resetModifiers = useKeyboardShortcutsStore((s) => s.resetModifiers); const resetModifiers = useKeyboardShortcutsStore((s) => s.resetModifiers);
const { overrides } = useKeyboardShortcutOverrides(); const { overrides } = useKeyboardShortcutOverrides();
const bindings = useMemo(() => buildEffectiveBindings(overrides), [overrides]); const bindings = useMemo(() => buildEffectiveBindings(overrides), [overrides]);
@@ -54,11 +54,7 @@ export function useKeyboardShortcuts({
step: 0, step: 0,
timeoutId: null, timeoutId: null,
}); });
const activeServerIdFromPath = parseServerIdFromPathname(pathname); const activeServerId = useActiveServerId();
const activeServerId =
hosts.find((host) => host.serverId === activeServerIdFromPath)?.serverId ??
hosts[0]?.serverId ??
null;
const openProjectPickerAction = useOpenProjectPicker(activeServerId); const openProjectPickerAction = useOpenProjectPicker(activeServerId);
useEffect(() => { useEffect(() => {
@@ -87,31 +83,23 @@ export function useKeyboardShortcuts({
}; };
const navigateRelativeWorkspace = (delta: 1 | -1): boolean => { const navigateRelativeWorkspace = (delta: 1 | -1): boolean => {
const state = useKeyboardShortcutsStore.getState(); const state = useKeyboardShortcutsStore.getState();
const targets = state.visibleWorkspaceTargets; const targets = state.sidebarShortcutWorkspaceTargets;
if (targets.length === 0) { if (targets.length === 0) {
return false; return false;
} }
const workspaceRoute = parseHostWorkspaceRouteFromPathname(pathname); const workspaceRoute =
if (!workspaceRoute) { getNavigationActiveWorkspaceSelection() ?? parseHostWorkspaceRouteFromPathname(pathname);
const fallback = targets[delta > 0 ? 0 : targets.length - 1] ?? null; const target = getRelativeSidebarShortcutTarget({
if (!fallback) { targets,
return false; currentTarget: workspaceRoute
} ? {
navigateToWorkspace(fallback.serverId, fallback.workspaceId, { serverId: workspaceRoute.serverId,
currentPathname: pathname, workspaceId: workspaceRoute.workspaceId,
}); }
return true; : null,
} delta,
});
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;
if (!target) { if (!target) {
return false; return false;
} }
@@ -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 type { SidebarProjectEntry } from "@/hooks/use-sidebar-workspaces-list";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { buildSidebarShortcutModel } from "@/utils/sidebar-shortcuts"; import { buildSidebarShortcutModel } from "@/utils/sidebar-shortcuts";
import { isSidebarProjectFlattened } from "@/utils/sidebar-project-row-model"; import { isSidebarProjectFlattened } from "@/utils/sidebar-project-row-model";
import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store";
export function useSidebarShortcutModel(projects: SidebarProjectEntry[]) { export function useSidebarShortcutModel(projects: SidebarProjectEntry[]) {
const [collapsedProjectKeys, setCollapsedProjectKeys] = useState<Set<string>>(new Set()); const collapsedProjectKeys = useSidebarCollapsedSectionsStore(
const setSidebarShortcutWorkspaceTargets = useKeyboardShortcutsStore( (state) => state.collapsedProjectKeys,
(state) => state.setSidebarShortcutWorkspaceTargets,
); );
const setVisibleWorkspaceTargets = useKeyboardShortcutsStore( const setProjectCollapsed = useSidebarCollapsedSectionsStore(
(state) => state.setVisibleWorkspaceTargets, (state) => state.setProjectCollapsed,
);
const toggleProjectCollapsed = useSidebarCollapsedSectionsStore(
(state) => state.toggleProjectCollapsed,
); );
const shortcutModel = useMemo( const shortcutModel = useMemo(
@@ -23,62 +25,17 @@ export function useSidebarShortcutModel(projects: SidebarProjectEntry[]) {
); );
useEffect(() => { useEffect(() => {
setCollapsedProjectKeys((prev) => { const collapsibleProjectKeys = new Set(
const collapsibleProjectKeys = new Set( projects
projects .filter((project) => !isSidebarProjectFlattened(project))
.filter((project) => !isSidebarProjectFlattened(project)) .map((project) => project.projectKey),
.map((project) => project.projectKey), );
); for (const key of collapsedProjectKeys) {
const next = new Set<string>(); if (!collapsibleProjectKeys.has(key)) {
for (const key of prev) { setProjectCollapsed(key, false);
if (collapsibleProjectKeys.has(key)) {
next.add(key);
}
} }
return next; }
}); }, [collapsedProjectKeys, projects, setProjectCollapsed]);
}, [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;
});
}, []);
return { return {
collapsedProjectKeys, collapsedProjectKeys,
@@ -10,7 +10,6 @@ beforeEach(() => {
altDown: false, altDown: false,
cmdOrCtrlDown: false, cmdOrCtrlDown: false,
sidebarShortcutWorkspaceTargets: [], sidebarShortcutWorkspaceTargets: [],
visibleWorkspaceTargets: [],
}); });
}); });
@@ -13,8 +13,6 @@ interface KeyboardShortcutsState {
showShortcutBadges: boolean; showShortcutBadges: boolean;
/** Sidebar-visible workspace targets (up to 9), in top-to-bottom visual order. */ /** Sidebar-visible workspace targets (up to 9), in top-to-bottom visual order. */
sidebarShortcutWorkspaceTargets: SidebarShortcutWorkspaceTarget[]; sidebarShortcutWorkspaceTargets: SidebarShortcutWorkspaceTarget[];
/** All visible workspace targets in top-to-bottom visual order. */
visibleWorkspaceTargets: SidebarShortcutWorkspaceTarget[];
setCommandCenterOpen: (open: boolean) => void; setCommandCenterOpen: (open: boolean) => void;
setProjectPickerOpen: (open: boolean) => void; setProjectPickerOpen: (open: boolean) => void;
@@ -23,7 +21,6 @@ interface KeyboardShortcutsState {
setAltDown: (down: boolean) => void; setAltDown: (down: boolean) => void;
setCmdOrCtrlDown: (down: boolean) => void; setCmdOrCtrlDown: (down: boolean) => void;
setSidebarShortcutWorkspaceTargets: (targets: SidebarShortcutWorkspaceTarget[]) => void; setSidebarShortcutWorkspaceTargets: (targets: SidebarShortcutWorkspaceTarget[]) => void;
setVisibleWorkspaceTargets: (targets: SidebarShortcutWorkspaceTarget[]) => void;
resetModifiers: () => void; resetModifiers: () => void;
} }
@@ -59,7 +56,6 @@ export const useKeyboardShortcutsStore = create<KeyboardShortcutsState>((set, ge
cmdOrCtrlDown: false, cmdOrCtrlDown: false,
showShortcutBadges: false, showShortcutBadges: false,
sidebarShortcutWorkspaceTargets: [], sidebarShortcutWorkspaceTargets: [],
visibleWorkspaceTargets: [],
setCommandCenterOpen: (open) => set({ commandCenterOpen: open }), setCommandCenterOpen: (open) => set({ commandCenterOpen: open }),
setProjectPickerOpen: (open) => set({ projectPickerOpen: open }), setProjectPickerOpen: (open) => set({ projectPickerOpen: open }),
@@ -75,7 +71,6 @@ export const useKeyboardShortcutsStore = create<KeyboardShortcutsState>((set, ge
}, },
setSidebarShortcutWorkspaceTargets: (targets) => setSidebarShortcutWorkspaceTargets: (targets) =>
set({ sidebarShortcutWorkspaceTargets: targets }), set({ sidebarShortcutWorkspaceTargets: targets }),
setVisibleWorkspaceTargets: (targets) => set({ visibleWorkspaceTargets: targets }),
resetModifiers: () => { resetModifiers: () => {
set({ altDown: false, cmdOrCtrlDown: false }); set({ altDown: false, cmdOrCtrlDown: false });
updateBadgeTimer(set, get); updateBadgeTimer(set, get);
@@ -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();
});
});
+20
View File
@@ -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;
}
@@ -4,7 +4,7 @@ import type {
SidebarWorkspaceEntry, SidebarWorkspaceEntry,
} from "@/hooks/use-sidebar-workspaces-list"; } from "@/hooks/use-sidebar-workspaces-list";
import { buildSidebarShortcutModel } from "./sidebar-shortcuts"; import { buildSidebarShortcutModel, getRelativeSidebarShortcutTarget } from "./sidebar-shortcuts";
function workspace(input: { function workspace(input: {
serverId: string; serverId: string;
@@ -75,10 +75,6 @@ describe("buildSidebarShortcutModel", () => {
collapsedProjectKeys: new Set<string>(["p2"]), collapsedProjectKeys: new Set<string>(["p2"]),
}); });
expect(model.visibleTargets).toEqual([
{ serverId: "s1", workspaceId: "ws-main" },
{ serverId: "s1", workspaceId: "ws-feat-a" },
]);
expect(model.shortcutTargets).toEqual([ expect(model.shortcutTargets).toEqual([
{ serverId: "s1", workspaceId: "ws-main" }, { serverId: "s1", workspaceId: "ws-main" },
{ serverId: "s1", workspaceId: "ws-feat-a" }, { serverId: "s1", workspaceId: "ws-feat-a" },
@@ -104,8 +100,6 @@ describe("buildSidebarShortcutModel", () => {
collapsedProjectKeys: new Set<string>(), collapsedProjectKeys: new Set<string>(),
}); });
expect(model.visibleTargets).toHaveLength(20);
expect(model.visibleTargets[19]).toEqual({ serverId: "s", workspaceId: "ws-20" });
expect(model.shortcutTargets).toHaveLength(9); expect(model.shortcutTargets).toHaveLength(9);
expect(model.shortcutTargets[0]).toEqual({ serverId: "s", workspaceId: "ws-1" }); expect(model.shortcutTargets[0]).toEqual({ serverId: "s", workspaceId: "ws-1" });
expect(model.shortcutTargets[8]).toEqual({ serverId: "s", workspaceId: "ws-9" }); expect(model.shortcutTargets[8]).toEqual({ serverId: "s", workspaceId: "ws-9" });
@@ -128,7 +122,68 @@ describe("buildSidebarShortcutModel", () => {
collapsedProjectKeys: new Set<string>(["p1"]), collapsedProjectKeys: new Set<string>(["p1"]),
}); });
expect(model.visibleTargets).toEqual([]);
expect(model.shortcutTargets).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" });
});
});
+28 -5
View File
@@ -10,7 +10,6 @@ export interface SidebarShortcutWorkspaceTarget {
} }
export interface SidebarShortcutModel { export interface SidebarShortcutModel {
visibleTargets: SidebarShortcutWorkspaceTarget[];
shortcutTargets: SidebarShortcutWorkspaceTarget[]; shortcutTargets: SidebarShortcutWorkspaceTarget[];
shortcutIndexByWorkspaceKey: Map<string, number>; shortcutIndexByWorkspaceKey: Map<string, number>;
} }
@@ -28,7 +27,6 @@ export function buildSidebarShortcutModel(input: {
shortcutLimit?: number; shortcutLimit?: number;
}): SidebarShortcutModel { }): SidebarShortcutModel {
const maxShortcuts = Math.max(0, Math.floor(input.shortcutLimit ?? 9)); const maxShortcuts = Math.max(0, Math.floor(input.shortcutLimit ?? 9));
const visibleTargets: SidebarShortcutWorkspaceTarget[] = [];
const shortcutTargets: SidebarShortcutWorkspaceTarget[] = []; const shortcutTargets: SidebarShortcutWorkspaceTarget[] = [];
const shortcutIndexByWorkspaceKey = new Map<string, number>(); const shortcutIndexByWorkspaceKey = new Map<string, number>();
@@ -38,8 +36,6 @@ export function buildSidebarShortcutModel(input: {
} }
for (const workspace of project.workspaces) { for (const workspace of project.workspaces) {
visibleTargets.push(createShortcutTarget(workspace));
if (shortcutTargets.length >= maxShortcuts) { if (shortcutTargets.length >= maxShortcuts) {
continue; 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;
} }