Fix workspace shortcut navigation
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
@@ -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,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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user