Make the workspace right pane a first-class tab host (#3287)
* feat(app): add persistent workspace side panes Keep the common review and file-browsing workflow beside the active agent without tearing down pane contents. Workspace tabs now share one compact chip language, assistant file links route into the retained right pane, and diff/file trees share stable geometry. * fix(app): localize pull request empty state * test(app): update explorer pane coverage * fix(app): preserve explorer pane workflows * test(app): make explorer tab selection idempotent * fix(app): persist workspace review pins * fix(app): preserve sidebar scrim drag hook * fix(app): restore companion pane interactions * test(app): follow selected sidebar surface
This commit is contained in:
@@ -13,18 +13,8 @@ import { Gesture } from "react-native-gesture-handler";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { X } from "lucide-react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
formatPrTabLabel,
|
||||
PullRequestPane,
|
||||
PullRequestPaneError,
|
||||
PullRequestPaneSkeleton,
|
||||
PullRequestTabIcon,
|
||||
usePrPaneData,
|
||||
} from "@/git/pull-request-panel";
|
||||
import { useCheckoutGitActionsStore } from "@/git/actions-store";
|
||||
import type { UsePrPaneDataResult } from "@/git/pull-request-panel/use-data";
|
||||
import { formatPrTabLabel, PullRequestTabIcon } from "@/git/pull-request-panel";
|
||||
import { usePanelStore, selectIsFileExplorerOpen, type ExplorerTab } from "@/stores/panel-store";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { useCloseFileExplorerGesture } from "@/mobile-panels/gestures";
|
||||
import { MobilePanelOverlay } from "@/mobile-panels/presentation";
|
||||
import { HEADER_INNER_HEIGHT } from "@/constants/layout";
|
||||
@@ -35,17 +25,14 @@ import { useHasOwnedWindowChromeObstruction, WindowChromeSafeArea } from "@/util
|
||||
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
|
||||
import { RetainedPanelActivity } from "@/components/retained-panel";
|
||||
import { SidebarResizeHandle } from "@/components/sidebar-resize-handle";
|
||||
import { buildWorkspaceAttachmentScopeKey } from "@/attachments/workspace-attachments-store";
|
||||
import { resolveDesktopExplorerWidth } from "@/components/desktop-sidebar-layout";
|
||||
import {
|
||||
SIDEBAR_RESIZE_ACTIVATION_OFFSET,
|
||||
SIDEBAR_RESIZE_FAIL_OFFSET,
|
||||
} from "@/components/sidebar-resize-handle-layout";
|
||||
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
|
||||
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
|
||||
import { resolveFocusedChatTarget } from "@/composer/focused-chat-target";
|
||||
import { createWorkspaceFileAttachment } from "@/attachments/workspace-file";
|
||||
import { useDraftStore } from "@/stores/draft-store";
|
||||
import { usePullRequestPanelAvailability } from "@/panels/pull-request-availability";
|
||||
import { PullRequestContent } from "@/panels/pull-request";
|
||||
import { useAddFileToChat } from "@/panels/use-add-file-to-chat";
|
||||
|
||||
function logExplorerSidebar(_event: string, _details: Record<string, unknown>): void {}
|
||||
|
||||
@@ -318,31 +305,19 @@ function ExplorerSidebarContent({
|
||||
}: SidebarContentProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const hasRightWindowControls = useHasOwnedWindowChromeObstruction("top-right");
|
||||
const canQueryPullRequest = isGit && Boolean(workspaceRoot);
|
||||
const prPane = usePrPaneData({
|
||||
const { prPane, showPullRequest: showPrTab } = usePullRequestPanelAvailability({
|
||||
serverId,
|
||||
cwd: workspaceRoot,
|
||||
enabled: canQueryPullRequest && isOpen,
|
||||
timelineEnabled: activeTab === "pr" && canQueryPullRequest && isOpen,
|
||||
isGit,
|
||||
requested: activeTab === "pr",
|
||||
enabled: isOpen,
|
||||
timelineEnabled: activeTab === "pr",
|
||||
});
|
||||
const hasPullRequest = prPane.prNumber !== null;
|
||||
const showPrTab = hasPullRequest || (activeTab === "pr" && prPane.isLoading);
|
||||
const requestedTab: ExplorerTab =
|
||||
!isGit && (activeTab === "changes" || activeTab === "pr") ? "files" : activeTab;
|
||||
const resolvedTab: ExplorerTab = requestedTab === "pr" && !showPrTab ? "changes" : requestedTab;
|
||||
const prTabLabel = formatPrTabLabel(prPane.prNumber);
|
||||
const refreshGitActions = useCheckoutGitActionsStore((s) => s.refresh);
|
||||
const handlePrRetry = useCallback(() => {
|
||||
refreshGitActions({ serverId, cwd: workspaceRoot }).catch((error) => {
|
||||
toast.error(error instanceof Error ? error.message : t("workspace.git.diff.failedRefresh"));
|
||||
});
|
||||
}, [refreshGitActions, serverId, t, toast, workspaceRoot]);
|
||||
const workspaceAttachmentScopeKey = useMemo(
|
||||
() => buildWorkspaceAttachmentScopeKey({ serverId, workspaceId, cwd: workspaceRoot }),
|
||||
[serverId, workspaceId, workspaceRoot],
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={styles.sidebarContent} pointerEvents="auto">
|
||||
@@ -436,10 +411,9 @@ function ExplorerSidebarContent({
|
||||
{resolvedTab === "pr" && (
|
||||
<PrTabContent
|
||||
serverId={serverId}
|
||||
workspaceId={workspaceId}
|
||||
cwd={workspaceRoot}
|
||||
prPane={prPane}
|
||||
workspaceAttachmentScopeKey={workspaceAttachmentScopeKey}
|
||||
onRetry={handlePrRetry}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
@@ -447,42 +421,6 @@ function ExplorerSidebarContent({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared add-to-chat state for the changes/files panes: both expose an "add file
|
||||
* to chat" action that attaches the file to the focused chat's composer.
|
||||
* Available only when a workspace with a focused chat is available.
|
||||
*/
|
||||
function useAddFileToChat({
|
||||
serverId,
|
||||
workspaceId,
|
||||
}: Pick<SidebarContentProps, "serverId" | "workspaceId">) {
|
||||
const workspaceKey = workspaceId
|
||||
? buildWorkspaceTabPersistenceKey({ serverId, workspaceId })
|
||||
: null;
|
||||
const layout = useWorkspaceLayoutStore((state) =>
|
||||
workspaceKey ? state.layoutByWorkspace[workspaceKey] : undefined,
|
||||
);
|
||||
const focusTab = useWorkspaceLayoutStore((state) => state.focusTab);
|
||||
const focusedChat = useMemo(
|
||||
() => resolveFocusedChatTarget({ serverId, layout }),
|
||||
[serverId, layout],
|
||||
);
|
||||
const addFile = useCallback(
|
||||
(filePath: string) => {
|
||||
if (!focusedChat || !workspaceKey) {
|
||||
return;
|
||||
}
|
||||
void useDraftStore.getState().attachWorkspaceFile({
|
||||
draftKey: focusedChat.draftKey,
|
||||
attachment: createWorkspaceFileAttachment({ path: filePath }),
|
||||
});
|
||||
focusTab(workspaceKey, focusedChat.tabId);
|
||||
},
|
||||
[focusTab, focusedChat, workspaceKey],
|
||||
);
|
||||
return { addFile, canAddToChat: focusedChat !== null };
|
||||
}
|
||||
|
||||
function ChangedFilesPane({
|
||||
serverId,
|
||||
workspaceId,
|
||||
@@ -496,6 +434,7 @@ function ChangedFilesPane({
|
||||
const { addFile, canAddToChat } = useAddFileToChat({ serverId, workspaceId });
|
||||
return (
|
||||
<GitDiffPane
|
||||
host="explorer"
|
||||
serverId={serverId}
|
||||
workspaceId={workspaceId}
|
||||
cwd={workspaceRoot}
|
||||
@@ -524,37 +463,7 @@ function FilesPane({
|
||||
);
|
||||
}
|
||||
|
||||
interface PrTabContentProps {
|
||||
serverId: string;
|
||||
cwd: string;
|
||||
prPane: UsePrPaneDataResult;
|
||||
workspaceAttachmentScopeKey: string;
|
||||
onRetry: () => void;
|
||||
}
|
||||
|
||||
function PrTabContent({
|
||||
serverId,
|
||||
cwd,
|
||||
prPane,
|
||||
workspaceAttachmentScopeKey,
|
||||
onRetry,
|
||||
}: PrTabContentProps) {
|
||||
if (prPane.data) {
|
||||
return (
|
||||
<PullRequestPane
|
||||
serverId={serverId}
|
||||
cwd={cwd}
|
||||
data={prPane.data}
|
||||
activityLoading={prPane.activityLoading}
|
||||
workspaceAttachmentScopeKey={workspaceAttachmentScopeKey}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (prPane.error) {
|
||||
return <PullRequestPaneError onRetry={onRetry} />;
|
||||
}
|
||||
return <PullRequestPaneSkeleton />;
|
||||
}
|
||||
const PrTabContent = PullRequestContent;
|
||||
|
||||
// Static styles for Animated.Views — must NOT use Unistyles dynamic theme to
|
||||
// avoid the "Unable to find node on an unmounted component" crash when Unistyles
|
||||
|
||||
@@ -1459,7 +1459,7 @@ export const AssistantMessage = memo(function AssistantMessage({
|
||||
|
||||
const fileLinkActions = useAssistantFileLinkActions();
|
||||
const handleMarkdownLinkPress = useStableEvent((url: string) => {
|
||||
fileLinkActions.open({ href: url }, "main");
|
||||
fileLinkActions.open({ href: url }, "side");
|
||||
// react-native-markdown-display opens the link itself when this returns true.
|
||||
// We already handled it above, so return false to avoid duplicate opens.
|
||||
return false;
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { startResizeHandleDrag } from "@/components/resize-handle-drag";
|
||||
|
||||
describe("startResizeHandleDrag", () => {
|
||||
it("previews pointer frames and commits only the final size", () => {
|
||||
const preview = vi.fn();
|
||||
const commit = vi.fn();
|
||||
const drag = startResizeHandleDrag({
|
||||
sizes: [0.5, 0.5],
|
||||
index: 0,
|
||||
preview,
|
||||
commit,
|
||||
});
|
||||
|
||||
drag.move(0.05);
|
||||
drag.move(0.1);
|
||||
|
||||
expect(preview).toHaveBeenCalledTimes(2);
|
||||
expect(preview.mock.calls[0]?.[0][0]).toBeCloseTo(0.55, 10);
|
||||
expect(preview.mock.calls[0]?.[0][1]).toBeCloseTo(0.45, 10);
|
||||
expect(preview.mock.calls[1]?.[0][0]).toBeCloseTo(0.6, 10);
|
||||
expect(preview.mock.calls[1]?.[0][1]).toBeCloseTo(0.4, 10);
|
||||
expect(commit).not.toHaveBeenCalled();
|
||||
|
||||
drag.finish();
|
||||
|
||||
expect(commit).toHaveBeenCalledOnce();
|
||||
expect(commit.mock.calls[0]?.[0][0]).toBeCloseTo(0.6, 10);
|
||||
expect(commit.mock.calls[0]?.[0][1]).toBeCloseTo(0.4, 10);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import { computeResizeHandleSizes } from "@/components/resize-handle-sizes";
|
||||
|
||||
interface StartResizeHandleDragInput {
|
||||
sizes: number[];
|
||||
index: number;
|
||||
preview: (sizes: number[]) => void;
|
||||
commit: (sizes: number[]) => void;
|
||||
}
|
||||
|
||||
export interface ResizeHandleDrag {
|
||||
move: (deltaRatio: number) => void;
|
||||
finish: () => void;
|
||||
}
|
||||
|
||||
export function startResizeHandleDrag({
|
||||
sizes,
|
||||
index,
|
||||
preview,
|
||||
commit,
|
||||
}: StartResizeHandleDragInput): ResizeHandleDrag {
|
||||
let pendingSizes: number[] | null = null;
|
||||
|
||||
return {
|
||||
move(deltaRatio) {
|
||||
pendingSizes = computeResizeHandleSizes({ sizes, index, deltaRatio });
|
||||
preview(pendingSizes);
|
||||
},
|
||||
finish() {
|
||||
if (pendingSizes) {
|
||||
commit(pendingSizes);
|
||||
pendingSizes = null;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,19 +1,22 @@
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { View, type PointerEvent as RNPointerEvent } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { computeResizeHandleSizes } from "@/components/resize-handle-sizes";
|
||||
import { startResizeHandleDrag, type ResizeHandleDrag } from "@/components/resize-handle-drag";
|
||||
|
||||
export interface ResizeHandleProps {
|
||||
testID?: string;
|
||||
direction: "horizontal" | "vertical";
|
||||
groupId: string;
|
||||
index: number;
|
||||
sizes: number[];
|
||||
onPreviewResizeSplit: (groupId: string, sizes: number[]) => void;
|
||||
onResizeSplit: (groupId: string, sizes: number[]) => void;
|
||||
}
|
||||
|
||||
interface PointerState {
|
||||
containerSize: number;
|
||||
pointerStart: number;
|
||||
drag: ResizeHandleDrag;
|
||||
}
|
||||
|
||||
function resetWindowHorizontalScroll() {
|
||||
@@ -25,10 +28,12 @@ function resetWindowHorizontalScroll() {
|
||||
}
|
||||
|
||||
export function ResizeHandle({
|
||||
testID,
|
||||
direction,
|
||||
groupId,
|
||||
index,
|
||||
sizes,
|
||||
onPreviewResizeSplit,
|
||||
onResizeSplit,
|
||||
}: ResizeHandleProps) {
|
||||
const { theme } = useUnistyles();
|
||||
@@ -68,6 +73,12 @@ export function ResizeHandle({
|
||||
containerSize,
|
||||
pointerStart:
|
||||
direction === "horizontal" ? event.nativeEvent.clientX : event.nativeEvent.clientY,
|
||||
drag: startResizeHandleDrag({
|
||||
sizes,
|
||||
index,
|
||||
preview: (nextSizes) => onPreviewResizeSplit(groupId, nextSizes),
|
||||
commit: (nextSizes) => onResizeSplit(groupId, nextSizes),
|
||||
}),
|
||||
});
|
||||
|
||||
if (pointerStatesRef.current.size === 1) {
|
||||
@@ -113,14 +124,7 @@ export function ResizeHandle({
|
||||
const deltaRatio =
|
||||
(pointerCurrent - pointerState.pointerStart) / pointerState.containerSize;
|
||||
|
||||
onResizeSplit(
|
||||
groupId,
|
||||
computeResizeHandleSizes({
|
||||
sizes,
|
||||
index,
|
||||
deltaRatio,
|
||||
}),
|
||||
);
|
||||
pointerState.drag.move(deltaRatio);
|
||||
}
|
||||
|
||||
function handlePointerUp(upEvent: PointerEvent) {
|
||||
@@ -128,6 +132,7 @@ export function ResizeHandle({
|
||||
return;
|
||||
}
|
||||
|
||||
pointerStatesRef.current.get(pointerId)?.drag.finish();
|
||||
cleanup();
|
||||
}
|
||||
|
||||
@@ -135,7 +140,7 @@ export function ResizeHandle({
|
||||
window.addEventListener("pointerup", handlePointerUp);
|
||||
window.addEventListener("pointercancel", handlePointerUp);
|
||||
},
|
||||
[direction, groupId, index, onResizeSplit, sizes],
|
||||
[direction, groupId, index, onPreviewResizeSplit, onResizeSplit, sizes],
|
||||
);
|
||||
|
||||
const handlePointerEnter = useCallback(() => {
|
||||
@@ -181,8 +186,14 @@ export function ResizeHandle({
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={handleStyle}>
|
||||
{highlighted && <View pointerEvents="none" style={highlightStyle} />}
|
||||
<View style={handleStyle} testID={testID}>
|
||||
{highlighted && (
|
||||
<View
|
||||
pointerEvents="none"
|
||||
style={highlightStyle}
|
||||
testID={testID ? `${testID}-highlight` : undefined}
|
||||
/>
|
||||
)}
|
||||
<View
|
||||
role="separator"
|
||||
aria-orientation={direction === "horizontal" ? "vertical" : "horizontal"}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { memo, useId, useMemo, useCallback, useState, type ReactNode } from "react";
|
||||
import { memo, useMemo, useCallback, useState, type ReactNode } from "react";
|
||||
import { Text, View, type ViewStyle } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import Svg, { Defs, LinearGradient as SvgLinearGradient, Rect, Stop } from "react-native-svg";
|
||||
import { CircleAlert, Folder, FolderGit2, Monitor } from "lucide-react-native";
|
||||
import { ProjectStatusIndicator } from "@/components/sidebar/project-leading-visual";
|
||||
import type { SidebarSurfaceBackdrop } from "@/styles/surface-backdrop";
|
||||
@@ -28,11 +27,7 @@ import {
|
||||
import { shouldRenderSyncedStatusLoader } from "@/utils/status-loader";
|
||||
import { StatusRing } from "@/components/status-ring";
|
||||
import { resolveSidebarWorkspacePrimaryLabel } from "@/components/sidebar/sidebar-workspace-title";
|
||||
|
||||
// The scrim spans more than the kebab so the fade starts left of the diff stat. Solid from
|
||||
// SCRIM_SOLID_OFFSET rightward, which keeps the kebab itself off the gradient entirely.
|
||||
const SCRIM_WIDTH = 48;
|
||||
const SCRIM_SOLID_OFFSET = "55%";
|
||||
import { TrailingActionScrim } from "@/components/ui/trailing-action-scrim";
|
||||
|
||||
const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
const needsInputColorMapping = (theme: Theme) => ({
|
||||
@@ -45,38 +40,6 @@ const ThemedMonitor = withUnistyles(Monitor);
|
||||
const ThemedFolder = withUnistyles(Folder);
|
||||
const ThemedFolderGit2 = withUnistyles(FolderGit2);
|
||||
|
||||
/**
|
||||
* react-native-svg's extractGradient reads stopColor off the child elements structurally,
|
||||
* without rendering them, so wrapping Stop itself in withUnistyles hides the color from it and
|
||||
* the native gradient silently falls back to black. Theme the whole SVG instead and keep real
|
||||
* Stop elements as direct children of the gradient.
|
||||
*/
|
||||
function TrailingActionScrimSvg({ gradientId, color }: { gradientId: string; color: string }) {
|
||||
return (
|
||||
<Svg width="100%" height="100%" preserveAspectRatio="none">
|
||||
<Defs>
|
||||
<SvgLinearGradient id={gradientId} x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
{/* Same color at both ends, varying only stopOpacity. Interpolating a hex toward
|
||||
`transparent` goes through black in some engines and leaves a grey fringe. */}
|
||||
<Stop offset="0%" stopColor={color} stopOpacity={0} />
|
||||
<Stop offset={SCRIM_SOLID_OFFSET} stopColor={color} stopOpacity={1} />
|
||||
<Stop offset="100%" stopColor={color} stopOpacity={1} />
|
||||
</SvgLinearGradient>
|
||||
</Defs>
|
||||
<Rect x="0" y="0" width="100%" height="100%" fill={`url(#${gradientId})`} />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
const ThemedTrailingActionScrimSvg = withUnistyles(TrailingActionScrimSvg);
|
||||
|
||||
const scrimColorMappings: Record<SidebarSurfaceBackdrop, (theme: Theme) => { color: string }> = {
|
||||
surfaceSidebar: (theme) => ({ color: theme.colors.surfaceSidebar }),
|
||||
surfaceSidebarHover: (theme) => ({ color: theme.colors.surfaceSidebarHover }),
|
||||
surfaceSidebarSelected: (theme) => ({ color: theme.colors.surfaceSidebarSelected }),
|
||||
surface2: (theme) => ({ color: theme.colors.surface2 }),
|
||||
};
|
||||
|
||||
export function SidebarWorkspaceRowFrame({
|
||||
workspace,
|
||||
isDragging = false,
|
||||
@@ -365,13 +328,6 @@ export const sidebarWorkspaceRowStyles = StyleSheet.create((theme) => ({
|
||||
top: 0,
|
||||
right: 0,
|
||||
},
|
||||
trailingActionScrim: {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
width: SCRIM_WIDTH,
|
||||
},
|
||||
}));
|
||||
|
||||
export function SidebarWorkspaceShortcutBadge({ number }: { number: number }) {
|
||||
@@ -476,40 +432,14 @@ export function SidebarWorkspaceTrailingActionOverlay({
|
||||
if (!visible || !children) return null;
|
||||
return (
|
||||
<>
|
||||
{scrimBackdrop ? <TrailingActionScrim backdrop={scrimBackdrop} /> : null}
|
||||
{scrimBackdrop ? (
|
||||
<TrailingActionScrim backdrop={scrimBackdrop} testID="sidebar-workspace-trailing-scrim" />
|
||||
) : null}
|
||||
<View style={sidebarWorkspaceRowStyles.trailingActionOverlay}>{children}</View>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The row's own background, faded in from the right, sitting between the diff stat and the
|
||||
* kebab. The kebab lands on fully opaque background while the diff dissolves underneath it
|
||||
* rather than blinking out — hiding the diff outright was the old behavior and it cost a
|
||||
* visible flicker on every hover.
|
||||
*
|
||||
* Anchored to the trailing slot, which is position:relative. Wider than the slot on purpose:
|
||||
* the fade has to start before the diff stat does or the diff's left edge cuts off hard.
|
||||
*/
|
||||
function TrailingActionScrim({ backdrop }: { backdrop: SidebarSurfaceBackdrop }) {
|
||||
// useId's output contains characters that are not legal inside url(#...) — React 19 wraps
|
||||
// ids in guillemets, React 18 in colons — and an unresolvable fill paints nothing at all.
|
||||
// Keep the per-instance uniqueness, drop everything a fragment reference can't carry.
|
||||
const gradientId = `sidebar-scrim-${useId().replace(/[^a-zA-Z0-9_-]/g, "")}`;
|
||||
return (
|
||||
<View
|
||||
style={sidebarWorkspaceRowStyles.trailingActionScrim}
|
||||
pointerEvents="none"
|
||||
testID="sidebar-workspace-trailing-scrim"
|
||||
>
|
||||
<ThemedTrailingActionScrimSvg
|
||||
gradientId={gradientId}
|
||||
uniProps={scrimColorMappings[backdrop]}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
workspaceRowContent: {
|
||||
position: "relative",
|
||||
|
||||
@@ -28,6 +28,11 @@ import {
|
||||
} from "@dnd-kit/core";
|
||||
import { arrayMove, sortableKeyboardCoordinates } from "@dnd-kit/sortable";
|
||||
import { View, Text } from "react-native";
|
||||
import Animated, {
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
type SharedValue,
|
||||
} from "react-native-reanimated";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { ResizeHandle } from "@/components/resize-handle";
|
||||
@@ -139,6 +144,9 @@ interface SplitPaneDropData {
|
||||
paneId: string;
|
||||
}
|
||||
|
||||
const EMPTY_SPLIT_NODES: SplitNode[] = [];
|
||||
const EMPTY_SPLIT_SIZES: number[] = [];
|
||||
|
||||
function isWorkspaceTabDragData(data: unknown): data is WorkspaceTabDragData {
|
||||
return typeof data === "object" && data !== null && Reflect.get(data, "kind") === "workspace-tab";
|
||||
}
|
||||
@@ -721,9 +729,68 @@ function DragOverlayTabChipInner({
|
||||
);
|
||||
}
|
||||
|
||||
function SplitGroupChild({ flex, children }: { flex: number; children: ReactNode }) {
|
||||
const childStyle = useMemo(() => [styles.groupChild, { flex }], [flex]);
|
||||
return <View style={childStyle}>{children}</View>;
|
||||
function SplitGroupChild({
|
||||
resizeFlex,
|
||||
index,
|
||||
hidden,
|
||||
children,
|
||||
}: {
|
||||
resizeFlex: SharedValue<number[]>;
|
||||
index: number;
|
||||
hidden: boolean;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const resizeStyle = useAnimatedStyle(() => ({
|
||||
flexGrow: resizeFlex.value[index] ?? 0,
|
||||
}));
|
||||
const childStyle = useMemo(
|
||||
() => [
|
||||
styles.groupChild,
|
||||
{
|
||||
flexShrink: hidden ? 0 : 1,
|
||||
flexBasis: 0,
|
||||
...(hidden ? { width: 0, height: 0 } : {}),
|
||||
},
|
||||
],
|
||||
[hidden],
|
||||
);
|
||||
return (
|
||||
<Animated.View
|
||||
style={[childStyle, resizeStyle]}
|
||||
testID={hidden ? "split-group-child-hidden" : "split-group-child"}
|
||||
>
|
||||
{children}
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Flex grow per child, renormalized so the visible ones always sum to 1.
|
||||
*
|
||||
* `sizes` are fractions, so a two-pane group is `[0.5, 0.5]`. Hiding one child drops the group's
|
||||
* total grow factor to 0.5, and CSS hands children that sum to less than 1 only that fraction of
|
||||
* the free space — the other half of the row is simply left empty. Renormalizing is what makes a
|
||||
* hidden pane give its space back instead of just going invisible. The stored `sizes` are never
|
||||
* touched, so unhiding restores the width the user dragged to.
|
||||
*/
|
||||
function resolveVisibleGroupFlex(children: SplitNode[], sizes: number[]): number[] {
|
||||
const visibleTotal = children.reduce(
|
||||
(total, child, index) => (isSplitNodeHidden(child) ? total : total + (sizes[index] ?? 1)),
|
||||
0,
|
||||
);
|
||||
if (visibleTotal <= 0) {
|
||||
return children.map(() => 0);
|
||||
}
|
||||
return children.map((child, index) =>
|
||||
isSplitNodeHidden(child) ? 0 : (sizes[index] ?? 1) / visibleTotal,
|
||||
);
|
||||
}
|
||||
|
||||
function isSplitNodeHidden(node: SplitNode): boolean {
|
||||
if (node.kind === "pane") {
|
||||
return node.pane.hidden === true;
|
||||
}
|
||||
return node.group.children.every(isSplitNodeHidden);
|
||||
}
|
||||
|
||||
function SplitNodeView({
|
||||
@@ -773,6 +840,23 @@ function SplitNodeView({
|
||||
const storedGroupSizes = useWorkspaceLayoutStore((state) =>
|
||||
groupId ? state.splitSizesByWorkspace[workspaceKey]?.[groupId] : undefined,
|
||||
);
|
||||
const groupChildren = node.kind === "group" ? node.group.children : EMPTY_SPLIT_NODES;
|
||||
const groupSizes =
|
||||
storedGroupSizes ?? (node.kind === "group" ? node.group.sizes : EMPTY_SPLIT_SIZES);
|
||||
const visibleFlex = useMemo(
|
||||
() => resolveVisibleGroupFlex(groupChildren, groupSizes),
|
||||
[groupChildren, groupSizes],
|
||||
);
|
||||
const resizeFlex = useSharedValue(visibleFlex);
|
||||
useEffect(() => {
|
||||
resizeFlex.value = visibleFlex;
|
||||
}, [resizeFlex, visibleFlex]);
|
||||
const previewResizeSplit = useCallback(
|
||||
(_groupId: string, sizes: number[]) => {
|
||||
resizeFlex.value = resolveVisibleGroupFlex(groupChildren, sizes);
|
||||
},
|
||||
[groupChildren, resizeFlex],
|
||||
);
|
||||
|
||||
const groupStyle = useMemo(
|
||||
() => [
|
||||
@@ -784,56 +868,56 @@ function SplitNodeView({
|
||||
|
||||
if (node.kind === "pane") {
|
||||
return (
|
||||
<WindowChromeRegion corners={windowChromeCorners}>
|
||||
<SplitPaneView
|
||||
pane={node.pane}
|
||||
uiTabs={uiTabs}
|
||||
isFocused={node.pane.id === focusedPaneId}
|
||||
normalizedServerId={normalizedServerId}
|
||||
normalizedWorkspaceId={normalizedWorkspaceId}
|
||||
isWorkspaceFocused={isWorkspaceFocused}
|
||||
hoveredCloseTabKey={hoveredCloseTabKey}
|
||||
setHoveredCloseTabKey={setHoveredCloseTabKey}
|
||||
closingTabIds={closingTabIds}
|
||||
onNavigateTab={onNavigateTab}
|
||||
onCloseTab={onCloseTab}
|
||||
onCopyResumeCommand={onCopyResumeCommand}
|
||||
onCopyAgentId={onCopyAgentId}
|
||||
onCopyTerminalId={onCopyTerminalId}
|
||||
onCopyFilePath={onCopyFilePath}
|
||||
onReloadAgent={onReloadAgent}
|
||||
onRenameTab={onRenameTab}
|
||||
onCloseTabsToLeft={onCloseTabsToLeft}
|
||||
onCloseTabsToRight={onCloseTabsToRight}
|
||||
onCloseOtherTabs={onCloseOtherTabs}
|
||||
onCreateDraftTab={onCreateDraftTab}
|
||||
onCreateTerminalTab={onCreateTerminalTab}
|
||||
onCreateBrowserTab={onCreateBrowserTab}
|
||||
showCreateBrowserTab={showCreateBrowserTab}
|
||||
buildPaneContentModel={buildPaneContentModel}
|
||||
onFocusPane={onFocusPane}
|
||||
onSplitPane={onSplitPane}
|
||||
onSplitPaneEmpty={onSplitPaneEmpty}
|
||||
onReorderTabsInPane={onReorderTabsInPane}
|
||||
renderPaneEmptyState={renderPaneEmptyState}
|
||||
activeDragTabId={activeDragTabId}
|
||||
showDropZones={showDropZones}
|
||||
dropPreview={dropPreview}
|
||||
tabDropPreview={tabDropPreview}
|
||||
focusModeEnabled={focusModeEnabled}
|
||||
onExitFocusMode={onExitFocusMode}
|
||||
/>
|
||||
</WindowChromeRegion>
|
||||
<RetainedPanel active={node.pane.hidden !== true}>
|
||||
<WindowChromeRegion corners={windowChromeCorners}>
|
||||
<SplitPaneView
|
||||
pane={node.pane}
|
||||
uiTabs={uiTabs}
|
||||
isFocused={node.pane.id === focusedPaneId}
|
||||
normalizedServerId={normalizedServerId}
|
||||
normalizedWorkspaceId={normalizedWorkspaceId}
|
||||
isWorkspaceFocused={isWorkspaceFocused}
|
||||
hoveredCloseTabKey={hoveredCloseTabKey}
|
||||
setHoveredCloseTabKey={setHoveredCloseTabKey}
|
||||
closingTabIds={closingTabIds}
|
||||
onNavigateTab={onNavigateTab}
|
||||
onCloseTab={onCloseTab}
|
||||
onCopyResumeCommand={onCopyResumeCommand}
|
||||
onCopyAgentId={onCopyAgentId}
|
||||
onCopyTerminalId={onCopyTerminalId}
|
||||
onCopyFilePath={onCopyFilePath}
|
||||
onReloadAgent={onReloadAgent}
|
||||
onRenameTab={onRenameTab}
|
||||
onCloseTabsToLeft={onCloseTabsToLeft}
|
||||
onCloseTabsToRight={onCloseTabsToRight}
|
||||
onCloseOtherTabs={onCloseOtherTabs}
|
||||
onCreateDraftTab={onCreateDraftTab}
|
||||
onCreateTerminalTab={onCreateTerminalTab}
|
||||
onCreateBrowserTab={onCreateBrowserTab}
|
||||
showCreateBrowserTab={showCreateBrowserTab}
|
||||
buildPaneContentModel={buildPaneContentModel}
|
||||
onFocusPane={onFocusPane}
|
||||
onSplitPane={onSplitPane}
|
||||
onSplitPaneEmpty={onSplitPaneEmpty}
|
||||
onReorderTabsInPane={onReorderTabsInPane}
|
||||
renderPaneEmptyState={renderPaneEmptyState}
|
||||
activeDragTabId={activeDragTabId}
|
||||
showDropZones={showDropZones}
|
||||
dropPreview={dropPreview}
|
||||
tabDropPreview={tabDropPreview}
|
||||
focusModeEnabled={focusModeEnabled}
|
||||
onExitFocusMode={onExitFocusMode}
|
||||
/>
|
||||
</WindowChromeRegion>
|
||||
</RetainedPanel>
|
||||
);
|
||||
}
|
||||
|
||||
const groupSizes = storedGroupSizes ?? node.group.sizes;
|
||||
|
||||
return (
|
||||
<View style={groupStyle}>
|
||||
{node.group.children.map((child, index) => (
|
||||
<Fragment key={getNodeKey(child)}>
|
||||
<SplitGroupChild flex={groupSizes[index] ?? 1}>
|
||||
<SplitGroupChild resizeFlex={resizeFlex} index={index} hidden={isSplitNodeHidden(child)}>
|
||||
<SplitNodeView
|
||||
node={child}
|
||||
workspaceKey={workspaceKey}
|
||||
@@ -876,12 +960,16 @@ function SplitNodeView({
|
||||
onExitFocusMode={onExitFocusMode}
|
||||
/>
|
||||
</SplitGroupChild>
|
||||
{index < node.group.children.length - 1 ? (
|
||||
{index < node.group.children.length - 1 &&
|
||||
!isSplitNodeHidden(child) &&
|
||||
!isSplitNodeHidden(node.group.children[index + 1]) ? (
|
||||
<ResizeHandle
|
||||
testID="workspace-split-resize-handle"
|
||||
direction={node.group.direction}
|
||||
groupId={node.group.id}
|
||||
index={index}
|
||||
sizes={groupSizes}
|
||||
onPreviewResizeSplit={previewResizeSplit}
|
||||
onResizeSplit={onResizeSplit}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
acceptTreeRailContainerMeasurement,
|
||||
resolveVisibleTreeRailWidth,
|
||||
} from "@/components/tree-rail-layout";
|
||||
|
||||
describe("tree rail layout", () => {
|
||||
it("keeps the last usable geometry while a retained panel is hidden", () => {
|
||||
const visibleContainerWidth = 900;
|
||||
const hiddenContainerWidth = acceptTreeRailContainerMeasurement(visibleContainerWidth, 0);
|
||||
|
||||
expect(hiddenContainerWidth).toBe(visibleContainerWidth);
|
||||
expect(resolveVisibleTreeRailWidth(420, hiddenContainerWidth)).toBe(420);
|
||||
});
|
||||
|
||||
it("clamps the rail after a usable narrow-container measurement", () => {
|
||||
const containerWidth = acceptTreeRailContainerMeasurement(900, 500);
|
||||
|
||||
expect(resolveVisibleTreeRailWidth(420, containerWidth)).toBe(260);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
import { MIN_TREE_RAIL_WIDTH, clampTreeRailWidth } from "@/stores/panel-store";
|
||||
|
||||
const MIN_CONTENT_WIDTH = 240;
|
||||
|
||||
export function resolveVisibleTreeRailWidth(
|
||||
requestedWidth: number,
|
||||
containerWidth: number,
|
||||
): number {
|
||||
const maximumVisibleWidth = Math.max(MIN_TREE_RAIL_WIDTH, containerWidth - MIN_CONTENT_WIDTH);
|
||||
return Math.min(clampTreeRailWidth(requestedWidth), maximumVisibleWidth);
|
||||
}
|
||||
|
||||
export function acceptTreeRailContainerMeasurement(
|
||||
currentWidth: number,
|
||||
measuredWidth: number,
|
||||
): number {
|
||||
return measuredWidth > 0 ? measuredWidth : currentWidth;
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Children, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { View, useWindowDimensions } from "react-native";
|
||||
import { Gesture } from "react-native-gesture-handler";
|
||||
import Animated, { runOnJS, useAnimatedStyle, useSharedValue } from "react-native-reanimated";
|
||||
import { scheduleOnRN } from "react-native-worklets";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import {
|
||||
SIDEBAR_RESIZE_ACTIVATION_OFFSET,
|
||||
SIDEBAR_RESIZE_FAIL_OFFSET,
|
||||
} from "@/components/sidebar-resize-handle-layout";
|
||||
import { SidebarResizeHandle } from "@/components/sidebar-resize-handle";
|
||||
import {
|
||||
acceptTreeRailContainerMeasurement,
|
||||
resolveVisibleTreeRailWidth,
|
||||
} from "@/components/tree-rail-layout";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
|
||||
export interface TreeRailProps {
|
||||
/**
|
||||
* Exactly two children, in order: content, then tree.
|
||||
*
|
||||
* Slots are children rather than `content` / `tree` props because `react-perf/jsx-no-jsx-as-prop`
|
||||
* rejects JSX in a prop, including via a local variable. Pass both unconditionally — a `{cond &&
|
||||
* …}` slot collapses out of the array and silently promotes the tree into the content position.
|
||||
* Branch around the whole rail instead, the way `diff-panel.tsx` does.
|
||||
*/
|
||||
children: ReactNode;
|
||||
testID?: string;
|
||||
}
|
||||
|
||||
/** Content-left/tree-right master-detail geometry with one app-wide tree width. */
|
||||
export function TreeRail({ children, testID }: TreeRailProps) {
|
||||
const [content, tree] = Children.toArray(children);
|
||||
const requestedWidth = usePanelStore((state) => state.treeRailWidth);
|
||||
const setTreeRailWidth = usePanelStore((state) => state.setTreeRailWidth);
|
||||
const { width: viewportWidth } = useWindowDimensions();
|
||||
const [containerWidth, setContainerWidth] = useState(viewportWidth);
|
||||
const visibleWidth = resolveVisibleTreeRailWidth(requestedWidth, containerWidth);
|
||||
const startWidthRef = useRef(visibleWidth);
|
||||
const resizeWidth = useSharedValue(visibleWidth);
|
||||
const [resizePressed, setResizePressed] = useState(false);
|
||||
const showResizeGrip = useCallback(() => setResizePressed(true), []);
|
||||
const hideResizeGrip = useCallback(() => setResizePressed(false), []);
|
||||
|
||||
useEffect(() => {
|
||||
resizeWidth.value = visibleWidth;
|
||||
}, [resizeWidth, visibleWidth]);
|
||||
|
||||
const resizeGesture = useMemo(
|
||||
() =>
|
||||
Gesture.Pan()
|
||||
.hitSlop({ left: 8, right: 8, top: 0, bottom: 0 })
|
||||
.onBegin(() => scheduleOnRN(showResizeGrip))
|
||||
.activeOffsetX([-SIDEBAR_RESIZE_ACTIVATION_OFFSET, SIDEBAR_RESIZE_ACTIVATION_OFFSET])
|
||||
.failOffsetY([-SIDEBAR_RESIZE_FAIL_OFFSET, SIDEBAR_RESIZE_FAIL_OFFSET])
|
||||
.onStart((event) => {
|
||||
startWidthRef.current = visibleWidth + event.translationX;
|
||||
resizeWidth.value = visibleWidth;
|
||||
})
|
||||
.onUpdate((event) => {
|
||||
resizeWidth.value = resolveVisibleTreeRailWidth(
|
||||
startWidthRef.current - event.translationX,
|
||||
containerWidth,
|
||||
);
|
||||
})
|
||||
.onEnd(() => runOnJS(setTreeRailWidth)(resizeWidth.value))
|
||||
.onFinalize(() => scheduleOnRN(hideResizeGrip)),
|
||||
[containerWidth, hideResizeGrip, resizeWidth, setTreeRailWidth, showResizeGrip, visibleWidth],
|
||||
);
|
||||
const railWidthStyle = useAnimatedStyle(() => ({ width: resizeWidth.value }));
|
||||
const handleLayout = useCallback(
|
||||
(event: { nativeEvent: { layout: { width: number } } }) =>
|
||||
setContainerWidth((currentWidth) =>
|
||||
acceptTreeRailContainerMeasurement(currentWidth, event.nativeEvent.layout.width),
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={styles.container} testID={testID} onLayout={handleLayout}>
|
||||
<View style={styles.content} testID={testID ? `${testID}-content` : undefined}>
|
||||
{content}
|
||||
</View>
|
||||
<Animated.View
|
||||
style={[styles.rail, railWidthStyle]}
|
||||
testID={testID ? `${testID}-tree` : undefined}
|
||||
>
|
||||
<SidebarResizeHandle
|
||||
edge="left"
|
||||
gesture={resizeGesture}
|
||||
pressed={resizePressed}
|
||||
testID={testID ? `${testID}-resize-handle` : "tree-rail-resize-handle"}
|
||||
/>
|
||||
{tree}
|
||||
</Animated.View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
flexDirection: "row",
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
rail: {
|
||||
position: "relative",
|
||||
flexShrink: 0,
|
||||
borderLeftWidth: 1,
|
||||
borderLeftColor: theme.colors.border,
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,67 @@
|
||||
import { useId } from "react";
|
||||
import { View } from "react-native";
|
||||
import Svg, { Defs, LinearGradient as SvgLinearGradient, Rect, Stop } from "react-native-svg";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import type { SurfaceBackdrop } from "@/styles/surface-backdrop";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
|
||||
export const SCRIM_WIDTH = 48;
|
||||
const SCRIM_SOLID_OFFSET = "55%";
|
||||
|
||||
function TrailingActionScrimSvg({ gradientId, color }: { gradientId: string; color: string }) {
|
||||
return (
|
||||
<Svg width="100%" height="100%" preserveAspectRatio="none">
|
||||
<Defs>
|
||||
<SvgLinearGradient id={gradientId} x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
{/* Vary opacity rather than interpolating toward `transparent`, which crosses black in
|
||||
some engines and leaves a grey fringe. */}
|
||||
<Stop offset="0%" stopColor={color} stopOpacity={0} />
|
||||
<Stop offset={SCRIM_SOLID_OFFSET} stopColor={color} stopOpacity={1} />
|
||||
<Stop offset="100%" stopColor={color} stopOpacity={1} />
|
||||
</SvgLinearGradient>
|
||||
</Defs>
|
||||
<Rect x="0" y="0" width="100%" height="100%" fill={`url(#${gradientId})`} />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
const ThemedTrailingActionScrimSvg = withUnistyles(TrailingActionScrimSvg);
|
||||
|
||||
const backdropColorMappings: Record<SurfaceBackdrop, (theme: Theme) => { color: string }> = {
|
||||
surface0: (theme) => ({ color: theme.colors.surface0 }),
|
||||
surface1: (theme) => ({ color: theme.colors.surface1 }),
|
||||
surface2: (theme) => ({ color: theme.colors.surface2 }),
|
||||
surfaceSidebar: (theme) => ({ color: theme.colors.surfaceSidebar }),
|
||||
surfaceSidebarHover: (theme) => ({ color: theme.colors.surfaceSidebarHover }),
|
||||
surfaceSidebarSelected: (theme) => ({ color: theme.colors.surfaceSidebarSelected }),
|
||||
};
|
||||
|
||||
/** Fades trailing content into the surface beneath an absolutely overlaid action. */
|
||||
export function TrailingActionScrim({
|
||||
backdrop,
|
||||
testID,
|
||||
}: {
|
||||
backdrop: SurfaceBackdrop;
|
||||
testID?: string;
|
||||
}) {
|
||||
// React-generated ids contain characters that are invalid inside SVG fragment references.
|
||||
const gradientId = `trailing-action-scrim-${useId().replace(/[^a-zA-Z0-9_-]/g, "")}`;
|
||||
return (
|
||||
<View style={styles.scrim} pointerEvents="none" testID={testID}>
|
||||
<ThemedTrailingActionScrimSvg
|
||||
gradientId={gradientId}
|
||||
uniProps={backdropColorMappings[backdrop]}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
scrim: {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
width: SCRIM_WIDTH,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user