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