Improve workspace navigation and render isolation
This commit is contained in:
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { act } from "@testing-library/react";
|
||||
import React, { useEffect } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
buildWorkspacePaneContentModel,
|
||||
WorkspacePaneContent,
|
||||
} from "@/screens/workspace/workspace-pane-content";
|
||||
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
|
||||
import { usePaneContext, usePaneFocus, type PaneContextValue } from "@/panels/pane-context";
|
||||
|
||||
vi.mock("@/panels/register-panels", () => ({
|
||||
ensurePanelsRegistered: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/panels/panel-registry", () => ({
|
||||
getPanelRegistration: () => ({
|
||||
kind: "agent",
|
||||
component: ProbePanel,
|
||||
useDescriptor: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
interface ProbeSnapshot {
|
||||
paneContextValue: PaneContextValue;
|
||||
focus: ReturnType<typeof usePaneFocus>;
|
||||
}
|
||||
|
||||
const snapshots: ProbeSnapshot[] = [];
|
||||
const mountCount = vi.fn();
|
||||
const unmountCount = vi.fn();
|
||||
|
||||
function ProbePanel() {
|
||||
const paneContextValue = usePaneContext();
|
||||
const focus = usePaneFocus();
|
||||
snapshots.push({ paneContextValue, focus });
|
||||
|
||||
useEffect(() => {
|
||||
mountCount();
|
||||
return () => {
|
||||
unmountCount();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const tab: WorkspaceTabDescriptor = {
|
||||
key: "agent_agent-a",
|
||||
tabId: "agent_agent-a",
|
||||
kind: "agent",
|
||||
target: { kind: "agent", agentId: "agent-a" },
|
||||
};
|
||||
|
||||
function buildContent() {
|
||||
return buildWorkspacePaneContentModel({
|
||||
tab,
|
||||
normalizedServerId: "server-a",
|
||||
normalizedWorkspaceId: "workspace-a",
|
||||
onOpenTab: vi.fn(),
|
||||
onCloseCurrentTab: vi.fn(),
|
||||
onRetargetCurrentTab: vi.fn(),
|
||||
onOpenWorkspaceFile: vi.fn(),
|
||||
});
|
||||
}
|
||||
|
||||
describe("WorkspacePaneContent", () => {
|
||||
let root: Root | null = null;
|
||||
let container: HTMLElement | null = null;
|
||||
|
||||
afterEach(() => {
|
||||
if (root) {
|
||||
act(() => {
|
||||
root?.unmount();
|
||||
});
|
||||
}
|
||||
root = null;
|
||||
container?.remove();
|
||||
container = null;
|
||||
snapshots.length = 0;
|
||||
mountCount.mockClear();
|
||||
unmountCount.mockClear();
|
||||
});
|
||||
|
||||
it("updates focus without remounting panel content or replacing pane identity", () => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
const content = buildContent();
|
||||
|
||||
act(() => {
|
||||
root?.render(
|
||||
<WorkspacePaneContent content={content} isPaneFocused={false} isWorkspaceFocused={true} />,
|
||||
);
|
||||
});
|
||||
act(() => {
|
||||
root?.render(
|
||||
<WorkspacePaneContent content={content} isPaneFocused isWorkspaceFocused={true} />,
|
||||
);
|
||||
});
|
||||
|
||||
expect(mountCount).toHaveBeenCalledTimes(1);
|
||||
expect(unmountCount).not.toHaveBeenCalled();
|
||||
expect(snapshots).toHaveLength(2);
|
||||
expect(snapshots[1]?.paneContextValue).toBe(snapshots[0]?.paneContextValue);
|
||||
expect(snapshots[0]?.focus).toEqual({
|
||||
isWorkspaceFocused: true,
|
||||
isPaneFocused: false,
|
||||
isInteractive: false,
|
||||
});
|
||||
expect(snapshots[1]?.focus).toEqual({
|
||||
isWorkspaceFocused: true,
|
||||
isPaneFocused: true,
|
||||
isInteractive: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,11 @@
|
||||
import type { ComponentType } from "react";
|
||||
import React, { useMemo, type ComponentType } from "react";
|
||||
import invariant from "tiny-invariant";
|
||||
import { PaneProvider, type PaneContextValue } from "@/panels/pane-context";
|
||||
import {
|
||||
createPaneFocusContextValue,
|
||||
PaneFocusProvider,
|
||||
PaneProvider,
|
||||
type PaneContextValue,
|
||||
} from "@/panels/pane-context";
|
||||
import { getPanelRegistration } from "@/panels/panel-registry";
|
||||
import { ensurePanelsRegistered } from "@/panels/register-panels";
|
||||
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
|
||||
@@ -15,7 +20,6 @@ export interface BuildWorkspacePaneContentModelInput {
|
||||
tab: WorkspaceTabDescriptor;
|
||||
normalizedServerId: string;
|
||||
normalizedWorkspaceId: string;
|
||||
isPaneFocused: boolean;
|
||||
onOpenTab: (target: WorkspaceTabDescriptor["target"]) => void;
|
||||
onCloseCurrentTab: () => void;
|
||||
onRetargetCurrentTab: (target: WorkspaceTabDescriptor["target"]) => void;
|
||||
@@ -26,7 +30,6 @@ export function buildWorkspacePaneContentModel({
|
||||
tab,
|
||||
normalizedServerId,
|
||||
normalizedWorkspaceId,
|
||||
isPaneFocused,
|
||||
onOpenTab,
|
||||
onCloseCurrentTab,
|
||||
onRetargetCurrentTab,
|
||||
@@ -42,7 +45,6 @@ export function buildWorkspacePaneContentModel({
|
||||
serverId: normalizedServerId,
|
||||
workspaceId: normalizedWorkspaceId,
|
||||
tabId: tab.tabId,
|
||||
isPaneFocused,
|
||||
target: tab.target,
|
||||
openTab: onOpenTab,
|
||||
closeCurrentTab: onCloseCurrentTab,
|
||||
@@ -54,14 +56,30 @@ export function buildWorkspacePaneContentModel({
|
||||
|
||||
export interface WorkspacePaneContentProps {
|
||||
content: WorkspacePaneContentModel;
|
||||
isWorkspaceFocused: boolean;
|
||||
isPaneFocused: boolean;
|
||||
}
|
||||
|
||||
export function WorkspacePaneContent({ content }: WorkspacePaneContentProps) {
|
||||
export function WorkspacePaneContent({
|
||||
content,
|
||||
isWorkspaceFocused,
|
||||
isPaneFocused,
|
||||
}: WorkspacePaneContentProps) {
|
||||
const { Component, key, paneContextValue } = content;
|
||||
const paneFocusValue = useMemo(
|
||||
() =>
|
||||
createPaneFocusContextValue({
|
||||
isWorkspaceFocused,
|
||||
isPaneFocused,
|
||||
}),
|
||||
[isPaneFocused, isWorkspaceFocused],
|
||||
);
|
||||
|
||||
return (
|
||||
<PaneProvider value={paneContextValue}>
|
||||
<Component key={key} />
|
||||
<PaneFocusProvider value={paneFocusValue}>
|
||||
<Component key={key} />
|
||||
</PaneFocusProvider>
|
||||
</PaneProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -48,11 +48,8 @@ import { WorkspaceScriptsButton } from "@/screens/workspace/workspace-scripts-bu
|
||||
import { ExplorerSidebarAnimationProvider } from "@/contexts/explorer-sidebar-animation-context";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { useExplorerOpenGesture } from "@/hooks/use-explorer-open-gesture";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import {
|
||||
setActiveExplorerCheckout,
|
||||
type ExplorerCheckoutContext,
|
||||
} from "@/stores/explorer-checkout-context";
|
||||
import { selectIsFileExplorerOpen, usePanelStore } from "@/stores/panel-store";
|
||||
import { type ExplorerCheckoutContext } from "@/stores/explorer-checkout-context";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import {
|
||||
buildWorkspaceTabPersistenceKey,
|
||||
@@ -513,11 +510,11 @@ const MobileWorkspaceTabSwitcher = memo(function MobileWorkspaceTabSwitcher({
|
||||
interface MobileMountedTabSlotProps {
|
||||
tabDescriptor: WorkspaceTabDescriptor;
|
||||
isVisible: boolean;
|
||||
isWorkspaceFocused: boolean;
|
||||
isPaneFocused: boolean;
|
||||
paneId: string | null;
|
||||
buildPaneContentModel: (input: {
|
||||
paneId: string | null;
|
||||
isPaneFocused: boolean;
|
||||
tab: WorkspaceTabDescriptor;
|
||||
}) => WorkspacePaneContentModel;
|
||||
}
|
||||
@@ -525,6 +522,7 @@ interface MobileMountedTabSlotProps {
|
||||
const MobileMountedTabSlot = memo(function MobileMountedTabSlot({
|
||||
tabDescriptor,
|
||||
isVisible,
|
||||
isWorkspaceFocused,
|
||||
isPaneFocused,
|
||||
paneId,
|
||||
buildPaneContentModel,
|
||||
@@ -533,15 +531,18 @@ const MobileMountedTabSlot = memo(function MobileMountedTabSlot({
|
||||
() =>
|
||||
buildPaneContentModel({
|
||||
paneId,
|
||||
isPaneFocused,
|
||||
tab: tabDescriptor,
|
||||
}),
|
||||
[buildPaneContentModel, isPaneFocused, paneId, tabDescriptor],
|
||||
[buildPaneContentModel, paneId, tabDescriptor],
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={{ display: isVisible ? "flex" : "none", flex: 1 }}>
|
||||
<WorkspacePaneContent content={content} />
|
||||
<WorkspacePaneContent
|
||||
content={content}
|
||||
isWorkspaceFocused={isWorkspaceFocused}
|
||||
isPaneFocused={isPaneFocused}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
});
|
||||
@@ -847,19 +848,19 @@ function WorkspaceScreenContent({
|
||||
? trimNonEmpty(checkoutQuery.data.currentBranch)
|
||||
: null;
|
||||
|
||||
const mobileView = usePanelStore((state) => state.mobileView);
|
||||
const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen);
|
||||
const showMobileFileExplorer = usePanelStore((state) => state.showMobileFileExplorer);
|
||||
const toggleMobileFileExplorer = usePanelStore((state) => state.toggleMobileFileExplorer);
|
||||
const openDesktopFileExplorer = usePanelStore((state) => state.openDesktopFileExplorer);
|
||||
const toggleDesktopFileExplorer = usePanelStore((state) => state.toggleDesktopFileExplorer);
|
||||
const activateExplorerTabForCheckout = usePanelStore(
|
||||
(state) => state.activateExplorerTabForCheckout,
|
||||
const isExplorerOpen = usePanelStore((state) =>
|
||||
selectIsFileExplorerOpen(state, { isCompact: isMobile }),
|
||||
);
|
||||
const canOpenExplorerFromAgentView = usePanelStore(
|
||||
(state) =>
|
||||
state.mobileView === "agent" && !selectIsFileExplorerOpen(state, { isCompact: true }),
|
||||
);
|
||||
const openFileExplorerForCheckout = usePanelStore((state) => state.openFileExplorerForCheckout);
|
||||
const toggleFileExplorerForCheckout = usePanelStore(
|
||||
(state) => state.toggleFileExplorerForCheckout,
|
||||
);
|
||||
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
|
||||
|
||||
const isExplorerOpen = isMobile ? mobileView === "file-explorer" : desktopFileExplorerOpen;
|
||||
|
||||
const activeExplorerCheckout = useMemo<ExplorerCheckoutContext | null>(() => {
|
||||
if (!normalizedServerId || !workspaceDirectory) {
|
||||
return null;
|
||||
@@ -871,51 +872,28 @@ function WorkspaceScreenContent({
|
||||
};
|
||||
}, [isGitCheckout, normalizedServerId, workspaceDirectory]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isRouteFocused) {
|
||||
return;
|
||||
}
|
||||
setActiveExplorerCheckout(activeExplorerCheckout);
|
||||
}, [activeExplorerCheckout, isRouteFocused, setActiveExplorerCheckout]);
|
||||
|
||||
const openExplorerForWorkspace = useCallback(() => {
|
||||
if (!activeExplorerCheckout) {
|
||||
return;
|
||||
}
|
||||
activateExplorerTabForCheckout(activeExplorerCheckout);
|
||||
if (isMobile) {
|
||||
showMobileFileExplorer();
|
||||
return;
|
||||
}
|
||||
openDesktopFileExplorer();
|
||||
}, [
|
||||
activateExplorerTabForCheckout,
|
||||
activeExplorerCheckout,
|
||||
isMobile,
|
||||
openDesktopFileExplorer,
|
||||
showMobileFileExplorer,
|
||||
]);
|
||||
openFileExplorerForCheckout({
|
||||
isCompact: isMobile,
|
||||
checkout: activeExplorerCheckout,
|
||||
});
|
||||
}, [activeExplorerCheckout, isMobile, openFileExplorerForCheckout]);
|
||||
|
||||
const handleToggleExplorer = useCallback(() => {
|
||||
if (isExplorerOpen) {
|
||||
if (isMobile) {
|
||||
toggleMobileFileExplorer();
|
||||
return;
|
||||
}
|
||||
toggleDesktopFileExplorer();
|
||||
if (!activeExplorerCheckout) {
|
||||
return;
|
||||
}
|
||||
openExplorerForWorkspace();
|
||||
}, [
|
||||
isExplorerOpen,
|
||||
isMobile,
|
||||
openExplorerForWorkspace,
|
||||
toggleDesktopFileExplorer,
|
||||
toggleMobileFileExplorer,
|
||||
]);
|
||||
toggleFileExplorerForCheckout({
|
||||
isCompact: isMobile,
|
||||
checkout: activeExplorerCheckout,
|
||||
});
|
||||
}, [activeExplorerCheckout, isMobile, toggleFileExplorerForCheckout]);
|
||||
|
||||
const explorerOpenGesture = useExplorerOpenGesture({
|
||||
enabled: isMobile && mobileView === "agent",
|
||||
enabled: isMobile && canOpenExplorerFromAgentView,
|
||||
onOpen: openExplorerForWorkspace,
|
||||
});
|
||||
|
||||
@@ -1717,6 +1695,17 @@ function WorkspaceScreenContent({
|
||||
],
|
||||
);
|
||||
|
||||
const handleWorkspaceSidebarAction = useCallback(
|
||||
(action: KeyboardActionDefinition): boolean => {
|
||||
if (action.id !== "sidebar.toggle.right") {
|
||||
return false;
|
||||
}
|
||||
handleToggleExplorer();
|
||||
return true;
|
||||
},
|
||||
[handleToggleExplorer],
|
||||
);
|
||||
|
||||
const handleWorkspacePaneAction = useCallback(
|
||||
(action: KeyboardActionDefinition): boolean => {
|
||||
if (!persistenceKey || !workspaceLayout) {
|
||||
@@ -1852,6 +1841,15 @@ function WorkspaceScreenContent({
|
||||
handle: handleWorkspacePaneAction,
|
||||
});
|
||||
|
||||
useKeyboardActionHandler({
|
||||
handlerId: `workspace-sidebar-actions:${normalizedServerId}:${normalizedWorkspaceId}`,
|
||||
actions: ["sidebar.toggle.right"] as const,
|
||||
enabled: Boolean(isRouteFocused && normalizedServerId && normalizedWorkspaceId),
|
||||
priority: 100,
|
||||
isActive: () => true,
|
||||
handle: handleWorkspaceSidebarAction,
|
||||
});
|
||||
|
||||
const activeTabDescriptor = activeTab?.descriptor ?? null;
|
||||
const canRenderDesktopPaneSplits = supportsDesktopPaneSplits();
|
||||
const shouldRenderDesktopPaneFallback = !isMobile && !canRenderDesktopPaneSplits;
|
||||
@@ -1865,14 +1863,12 @@ function WorkspaceScreenContent({
|
||||
(input: {
|
||||
tab: WorkspaceTabDescriptor;
|
||||
paneId?: string | null;
|
||||
isPaneFocused: boolean;
|
||||
focusPaneBeforeOpen?: boolean;
|
||||
}) =>
|
||||
buildWorkspacePaneContentModel({
|
||||
tab: input.tab,
|
||||
normalizedServerId,
|
||||
normalizedWorkspaceId,
|
||||
isPaneFocused: isRouteFocused && input.isPaneFocused,
|
||||
onOpenTab: (target) => {
|
||||
if (!persistenceKey) {
|
||||
return;
|
||||
@@ -1908,7 +1904,6 @@ function WorkspaceScreenContent({
|
||||
[
|
||||
handleCloseTabById,
|
||||
handleOpenFileFromChat,
|
||||
isRouteFocused,
|
||||
focusWorkspacePane,
|
||||
navigateToTabId,
|
||||
normalizedServerId,
|
||||
@@ -1935,12 +1930,10 @@ function WorkspaceScreenContent({
|
||||
function buildMobilePaneContentModel(input: {
|
||||
paneId: string | null;
|
||||
tab: WorkspaceTabDescriptor;
|
||||
isPaneFocused: boolean;
|
||||
}) {
|
||||
return buildPaneContentModel({
|
||||
tab: input.tab,
|
||||
paneId: input.paneId,
|
||||
isPaneFocused: input.isPaneFocused,
|
||||
focusPaneBeforeOpen: false,
|
||||
});
|
||||
},
|
||||
@@ -1983,7 +1976,8 @@ function WorkspaceScreenContent({
|
||||
key={tabId}
|
||||
tabDescriptor={tabDescriptor}
|
||||
isVisible={isRouteFocused && tabId === activeTabDescriptor.tabId}
|
||||
isPaneFocused={isRouteFocused && tabId === activeTabDescriptor.tabId}
|
||||
isWorkspaceFocused={isRouteFocused}
|
||||
isPaneFocused={tabId === activeTabDescriptor.tabId}
|
||||
paneId={focusedPaneId}
|
||||
buildPaneContentModel={buildMobilePaneContentModel}
|
||||
/>
|
||||
@@ -1992,15 +1986,10 @@ function WorkspaceScreenContent({
|
||||
);
|
||||
|
||||
const buildDesktopPaneContentModel = useCallback(
|
||||
function buildDesktopPaneContentModel(input: {
|
||||
paneId: string;
|
||||
tab: WorkspaceTabDescriptor;
|
||||
isPaneFocused: boolean;
|
||||
}) {
|
||||
function buildDesktopPaneContentModel(input: { paneId: string; tab: WorkspaceTabDescriptor }) {
|
||||
return buildPaneContentModel({
|
||||
tab: input.tab,
|
||||
paneId: input.paneId,
|
||||
isPaneFocused: input.isPaneFocused,
|
||||
focusPaneBeforeOpen: true,
|
||||
});
|
||||
},
|
||||
@@ -2404,6 +2393,7 @@ function WorkspaceScreenContent({
|
||||
workspaceKey={persistenceKey}
|
||||
normalizedServerId={normalizedServerId}
|
||||
normalizedWorkspaceId={normalizedWorkspaceId}
|
||||
isWorkspaceFocused={isRouteFocused}
|
||||
uiTabs={uiTabs}
|
||||
hoveredCloseTabKey={hoveredCloseTabKey}
|
||||
setHoveredTabKey={setHoveredTabKey}
|
||||
|
||||
Reference in New Issue
Block a user