feat(app): make the side panel user-directed (#3605)

* feat(app): let the file panel close its tree rail

The file panel's tree rail was permanent on desktop — the changes panel
had a folder-tree toggle in its toolbar, the file toolbar had nothing, so
the rail was there whether you wanted it or not.

The changes panel's private DesktopTreeToggle becomes a shared
TreeRailToggle so both toolbars render the same affordance; that also
drops the treeToggleStyle prop threaded through ChangesToolbarProps,
which recomputed exactly what the component already built internally.
The i18n keys move out of workspace.git.diff into workspace.tree, since a
shared component reaching into the git namespace would rot.

The two rails keep independent flags — the file rail in panel-store, the
changes rail in useChangesPreferences — so closing one leaves the other
alone. FileTreeToggle wires itself to the store rather than taking a
callback: the bar renders several components below FilePanel, where the
rail actually lives.

Both rails also get narrower (320 -> 260 default, 200 -> 180 minimum).
The persist version bump resets treeRailWidth on purpose: nearly every
existing install carries the old 320 default, so without the reset the
narrower rail would only ever reach new users. Anyone who dragged their
rail to a deliberate custom width loses it once.

Note that MIN_CONTENT_WIDTH still clamps the rail to
`containerWidth - 240`, so in a narrow pane the new default never
applies — that floor, not the default, is what sets the width there.

* feat(app): make the side panel an empty workspace surface

The fixed, hideable side panel keeps supporting context beside an agent without seeding content. Pane-local choices stay local, while implicit supporting opens follow a persisted routing preference.

* test(app): exercise side panel policy through its capability boundary

The browser rail journey already covers the real tree-toggle behavior. Side-panel policy tests now pass their platform capability through the public boundary instead of replacing a production module.

* test(app): expect an empty side panel after upgrade

* test(app): expect an empty first split pane

* test(app): account for the empty side panel in split retention

* feat(app): make workspace panel tabs independent

Give explicit New actions fresh tab instances, keep panel state opaque to tab management, preserve File navigation context, and stabilize uncached File transitions.

* fix(app): preserve file conflicts during preview refresh

Keep the current preview mounted for same-target reads and conversions so the editor retains its conflict observation state. Clear retained previews only when the file target changes.
This commit is contained in:
Mohamed Boudra
2026-08-20 21:49:23 +02:00
committed by GitHub
parent 2a0a0ff656
commit 19a7d26349
107 changed files with 5386 additions and 2178 deletions
@@ -8,6 +8,7 @@ import { useOpenFileExplorerGesture } from "@/mobile-panels/gestures";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { selectIsCompactFileExplorerOpen, usePanelStore } from "@/stores/panel-store";
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
import { useWorkspaceCheckoutStatus } from "@/screens/workspace/use-workspace-checkout-status";
import { openWorkspaceFileFromExplorer } from "@/screens/workspace/workspace-file-open-command";
import { isWeb } from "@/constants/platform";
@@ -99,8 +100,11 @@ export function CompactExplorerSidebarHost({ children, enabled }: CompactExplore
const model = useActiveCompactExplorerSidebarModel(enabled);
const openCompactFileExplorer = usePanelStore((state) => state.openCompactFileExplorer);
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
const openWorkspaceTabInFocusedPane = useWorkspaceLayoutStore(
(state) => state.openTabInFocusedPane,
const openTab = useWorkspaceLayoutStore((state) => state.openTab);
const openWorkspaceTabInFocusedPane = useCallback(
(workspaceKey: string, target: WorkspaceTabTarget) =>
openTab({ workspaceKey, target, intent: "reveal" }),
[openTab],
);
const focusWorkspaceTab = useWorkspaceLayoutStore((state) => state.focusTab);
@@ -1,4 +1,4 @@
import { useCallback, useMemo } from "react";
import { useCallback, useMemo, useState } from "react";
import { View, Text, Pressable } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
@@ -18,6 +18,7 @@ import {
HEADER_TOP_PADDING_MOBILE,
} from "@/constants/layout";
import { ChangesSurface } from "@/git/diff-pane";
import { changesStateSchema, defaultChangesState, type ChangesState } from "@/panels/changes/state";
import { FileExplorerPane } from "./file-explorer-pane";
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
import { shouldUseCompactExplorerKeyboardPadding } from "@/hooks/keyboard-shift-policy";
@@ -229,7 +230,7 @@ function ExplorerSidebarContent({
<ExplorerTabButton
tab="changes"
active={resolvedTab === "changes"}
label={t("workspace.tabs.explorer.changes")}
label={t("workspace.tabs.sidePanel.changes")}
onTabPress={onTabPress}
testID="explorer-tab-changes"
/>
@@ -237,7 +238,7 @@ function ExplorerSidebarContent({
<ExplorerTabButton
tab="files"
active={resolvedTab === "files"}
label={t("workspace.tabs.explorer.files")}
label={t("workspace.tabs.sidePanel.files")}
onTabPress={onTabPress}
testID="explorer-tab-files"
/>
@@ -267,7 +268,7 @@ function ExplorerSidebarContent({
nativeID="explorer-close"
accessible
accessibilityRole="button"
accessibilityLabel={t("workspace.tabs.explorer.close")}
accessibilityLabel={t("workspace.tabs.sidePanel.close")}
hitSlop={8}
>
{({ hovered, pressed }) => (
@@ -329,6 +330,9 @@ function ChangedFilesPane({
"serverId" | "workspaceId" | "workspaceRoot" | "isOpen" | "onOpenFile"
>) {
const { addFile, canAddToChat } = useAddFileToChat({ serverId, workspaceId });
const [changesState, setChangesState] = useState<ChangesState>(() =>
changesStateSchema.parse(defaultChangesState),
);
return (
<ChangesSurface
host="explorer"
@@ -338,6 +342,8 @@ function ChangedFilesPane({
enabled={isOpen}
onOpenFile={onOpenFile}
onAddToChat={canAddToChat ? addFile : undefined}
state={changesState}
onStateChange={setChangesState}
/>
);
}
@@ -24,7 +24,7 @@ import {
} from "react-native";
import { EditingTextInput as TextInput } from "@/components/ui/text-input";
import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor, WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
import { useIsCompactFormFactor } from "@/constants/layout";
import { isWeb } from "@/constants/platform";
import * as Clipboard from "expo-clipboard";
import { ChevronDown, Eye, EyeOff, FilePlus, FolderPlus, RotateCw } from "lucide-react-native";
@@ -38,6 +38,11 @@ import {
WORKSPACE_TREE_LOADING_ICON_SIZE,
} from "@/components/tree-primitives";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import {
PaneContentToolbar,
paneContentToolbarIconSize,
paneContentToolbarIconButtonStyle,
} from "@/components/ui/pane-content-toolbar";
import {
useOverlayFlatListScrollbar,
type OverlayFlatListScrollbar,
@@ -136,8 +141,11 @@ function sortTriggerStyle({
return [styles.sortTrigger, (Boolean(hovered) || pressed) && styles.sortTriggerHovered];
}
function iconButtonStyle({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) {
return [styles.iconButton, (Boolean(hovered) || pressed) && styles.iconButtonHovered];
function iconButtonStyle(
isCompact: boolean,
{ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean },
) {
return paneContentToolbarIconButtonStyle({ hovered, pressed }, false, isCompact);
}
type ExplorerListRow =
@@ -1041,6 +1049,11 @@ export function FileExplorerPane({
});
}, [requestDirectoryListing]);
const toolbarIconButtonStyle = useCallback(
(state: PressableStateCallbackType) => iconButtonStyle(isCompact, state),
[isCompact],
);
if (!hasWorkspaceScope) {
return (
<View style={styles.centerState}>
@@ -1058,6 +1071,7 @@ export function FileExplorerPane({
>
<FileExplorerPaneContent
error={error}
isCompact={isCompact}
showInitialLoading={showInitialLoading}
showBackFromError={showBackFromError}
listRows={listRows}
@@ -1073,7 +1087,7 @@ export function FileExplorerPane({
handleBackFromError={handleBackFromError}
handleRetry={handleRetry}
sortTriggerStyle={sortTriggerStyle}
iconButtonStyle={iconButtonStyle}
iconButtonStyle={toolbarIconButtonStyle}
/>
</View>
);
@@ -1132,6 +1146,7 @@ function RootCreationContextTarget({
interface FileExplorerPaneContentProps {
error: string | null;
isCompact: boolean;
showInitialLoading: boolean;
showBackFromError: boolean;
listRows: ExplorerListRow[];
@@ -1155,6 +1170,7 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) {
const { t } = useTranslation();
const {
error,
isCompact,
showInitialLoading,
showBackFromError,
listRows,
@@ -1229,7 +1245,7 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) {
return (
<View style={[styles.treePane, styles.treePaneFill]}>
<View style={styles.paneHeader} testID="files-pane-header">
<PaneContentToolbar style={styles.paneHeader} testID="files-pane-header">
<Pressable
onPress={handleSortCycle}
style={sortTriggerStyleProp}
@@ -1251,7 +1267,10 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) {
accessibilityLabel={t("workspace.fileActions.newFile")}
testID="files-new-file"
>
<FilePlus size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
<FilePlus
size={paneContentToolbarIconSize(isCompact)}
color={theme.colors.foregroundMuted}
/>
</Pressable>
<Pressable
onPress={handleNewFolderAtRoot}
@@ -1261,7 +1280,10 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) {
accessibilityLabel={t("workspace.fileActions.newFolder")}
testID="files-new-folder"
>
<FolderPlus size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
<FolderPlus
size={paneContentToolbarIconSize(isCompact)}
color={theme.colors.foregroundMuted}
/>
</Pressable>
</>
) : null}
@@ -1275,9 +1297,15 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) {
testID="files-hidden-toggle"
>
{showHiddenFiles ? (
<Eye size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
<Eye
size={paneContentToolbarIconSize(isCompact)}
color={theme.colors.foregroundMuted}
/>
) : (
<EyeOff size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
<EyeOff
size={paneContentToolbarIconSize(isCompact)}
color={theme.colors.foregroundMuted}
/>
)}
</Pressable>
<Pressable
@@ -1295,14 +1323,20 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) {
>
<View style={styles.refreshIcon}>
{isRefreshFetching ? (
<LoadingSpinner size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
<LoadingSpinner
size={paneContentToolbarIconSize(isCompact)}
color={theme.colors.foregroundMuted}
/>
) : (
<RotateCw size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
<RotateCw
size={paneContentToolbarIconSize(isCompact)}
color={theme.colors.foregroundMuted}
/>
)}
</View>
</Pressable>
</View>
</View>
</PaneContentToolbar>
<ContextMenu>
<RootCreationContextTarget enabled={Boolean(onNewEntryAtRoot)}>
{listRows.length === 0 ? (
@@ -1662,13 +1696,10 @@ const styles = StyleSheet.create((theme) => ({
minWidth: 0,
},
paneHeader: {
height: WORKSPACE_SECONDARY_HEADER_HEIGHT,
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
paddingRight: theme.spacing[3],
borderBottomWidth: 1,
borderBottomColor: theme.colors.border,
},
sortTrigger: {
flexDirection: "row",
@@ -1806,16 +1837,6 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.normal,
},
iconButton: {
width: 22,
height: 22,
borderRadius: theme.borderRadius.md,
alignItems: "center",
justifyContent: "center",
},
iconButtonHovered: {
backgroundColor: theme.colors.surface2,
},
iconButtonActive: {
backgroundColor: theme.colors.surface2,
},
+47 -14
View File
@@ -129,9 +129,14 @@ interface SplitContainerProps {
position: "left" | "right" | "top" | "bottom";
}) => void;
onMoveTabToPane: (tabId: string, toPaneId: string) => void;
onClosePane: (paneId: string) => void;
onResizeSplit: (groupId: string, sizes: number[]) => void;
onReorderTabsInPane: (paneId: string, tabIds: string[]) => void;
renderPaneEmptyState?: () => ReactNode;
renderPaneEmptyState?: (input: {
paneId: string;
paneIsolated: boolean;
onClosePane: () => void;
}) => ReactNode;
focusModeEnabled?: boolean;
onExitFocusMode: () => void;
}
@@ -174,9 +179,10 @@ interface SplitNodeViewProps extends Omit<SplitContainerProps, "layout" | "onMov
node: SplitNode;
uiTabs: WorkspaceTab[];
focusedPaneId: string | null;
explorerPaneId: string | null;
sidePanelPaneId: string | null;
maximizedPaneId: string | null;
onTogglePaneMaximized: (paneId: string) => void;
paneIsolated: boolean;
activeDragTabId: string | null;
showDropZones: boolean;
dropPreview: SplitDropZoneHover | null;
@@ -263,6 +269,7 @@ function useStableTabDescriptorMap(tabDescriptors: WorkspaceTabDescriptor[]) {
cachedDescriptor &&
cachedDescriptor.key === tabDescriptor.key &&
cachedDescriptor.kind === tabDescriptor.kind &&
cachedDescriptor.state === tabDescriptor.state &&
workspaceTabTargetsEqual(cachedDescriptor.target, tabDescriptor.target)
) {
next.set(tabDescriptor.tabId, cachedDescriptor);
@@ -404,6 +411,7 @@ export function SplitContainer({
onSplitPane,
onSplitPaneEmpty,
onMoveTabToPane,
onClosePane,
onResizeSplit,
onReorderTabsInPane,
renderPaneEmptyState = () => null,
@@ -421,8 +429,8 @@ export function SplitContainer({
} | null>(null);
const maximizedPaneId =
maximizedPane?.workspaceKey === workspaceKey ? maximizedPane.paneId : null;
const explorerPaneId = useWorkspaceLayoutStore(
(state) => state.explorerPaneIdByWorkspace[workspaceKey] ?? null,
const sidePanelPaneId = useWorkspaceLayoutStore(
(state) => state.sidePanelPaneIdByWorkspace[workspaceKey] ?? null,
);
useEffect(() => {
@@ -451,7 +459,7 @@ export function SplitContainer({
if (action.id !== "workspace.explorer.maximize.toggle") {
return false;
}
const explorerPane = findPaneById(layout.root, explorerPaneId);
const explorerPane = findPaneById(layout.root, sidePanelPaneId);
if (focusModeEnabled || !explorerPane || explorerPane.hidden === true) {
return true;
}
@@ -459,7 +467,7 @@ export function SplitContainer({
handleTogglePaneMaximized(explorerPane.id);
return true;
},
[explorerPaneId, focusModeEnabled, handleTogglePaneMaximized, layout.root, onFocusPane],
[sidePanelPaneId, focusModeEnabled, handleTogglePaneMaximized, layout.root, onFocusPane],
);
useKeyboardActionHandler({
@@ -494,6 +502,11 @@ export function SplitContainer({
[focusModeEnabled, layout.focusedPaneId, layout.root, maximizedPaneId],
);
const renderRoot = useMemo(() => wrapRootPaneForStableMount(splitRoot.root), [splitRoot.root]);
// Isolating a pane (focus mode, maximize) hides its siblings without removing
// them, so the one pane on screen is not really the last one and closing it is not
// what anyone means. That is all this container knows; whether the layout itself
// allows the dismissal is `canDismissPaneInLayout`, which the caller reads.
const paneIsolated = focusModeEnabled || Boolean(maximizedPaneId);
const handleDragStart = useCallback((event: DragStartEvent) => {
const data = asWorkspaceTabDragData(event.active.data.current);
@@ -654,7 +667,7 @@ export function SplitContainer({
workspaceKey={workspaceKey}
uiTabs={uiTabs}
focusedPaneId={layout.focusedPaneId}
explorerPaneId={explorerPaneId}
sidePanelPaneId={sidePanelPaneId}
maximizedPaneId={maximizedPaneId}
onTogglePaneMaximized={handleTogglePaneMaximized}
normalizedServerId={normalizedServerId}
@@ -682,6 +695,8 @@ export function SplitContainer({
onFocusPane={onFocusPane}
onSplitPane={onSplitPane}
onSplitPaneEmpty={onSplitPaneEmpty}
paneIsolated={paneIsolated}
onClosePane={onClosePane}
onResizeSplit={onResizeSplit}
onReorderTabsInPane={onReorderTabsInPane}
renderPaneEmptyState={renderPaneEmptyState}
@@ -863,7 +878,7 @@ function SplitNodeView({
workspaceKey,
uiTabs,
focusedPaneId,
explorerPaneId,
sidePanelPaneId,
maximizedPaneId,
onTogglePaneMaximized,
normalizedServerId,
@@ -891,6 +906,8 @@ function SplitNodeView({
onFocusPane,
onSplitPane,
onSplitPaneEmpty,
paneIsolated,
onClosePane,
onResizeSplit,
onReorderTabsInPane,
renderPaneEmptyState,
@@ -942,7 +959,7 @@ function SplitNodeView({
pane={node.pane}
uiTabs={uiTabs}
isFocused={node.pane.id === focusedPaneId}
explorerPaneId={explorerPaneId}
sidePanelPaneId={sidePanelPaneId}
maximizedPaneId={maximizedPaneId}
onTogglePaneMaximized={onTogglePaneMaximized}
normalizedServerId={normalizedServerId}
@@ -970,6 +987,8 @@ function SplitNodeView({
onFocusPane={onFocusPane}
onSplitPane={onSplitPane}
onSplitPaneEmpty={onSplitPaneEmpty}
paneIsolated={paneIsolated}
onClosePane={onClosePane}
onReorderTabsInPane={onReorderTabsInPane}
renderPaneEmptyState={renderPaneEmptyState}
activeDragTabId={activeDragTabId}
@@ -994,7 +1013,7 @@ function SplitNodeView({
workspaceKey={workspaceKey}
uiTabs={uiTabs}
focusedPaneId={focusedPaneId}
explorerPaneId={explorerPaneId}
sidePanelPaneId={sidePanelPaneId}
maximizedPaneId={maximizedPaneId}
onTogglePaneMaximized={onTogglePaneMaximized}
normalizedServerId={normalizedServerId}
@@ -1022,6 +1041,8 @@ function SplitNodeView({
onFocusPane={onFocusPane}
onSplitPane={onSplitPane}
onSplitPaneEmpty={onSplitPaneEmpty}
paneIsolated={paneIsolated}
onClosePane={onClosePane}
onResizeSplit={onResizeSplit}
onReorderTabsInPane={onReorderTabsInPane}
renderPaneEmptyState={renderPaneEmptyState}
@@ -1082,13 +1103,15 @@ function SplitPaneView({
onFocusPane,
onSplitPane: _onSplitPane,
onSplitPaneEmpty: _onSplitPaneEmpty,
paneIsolated,
onClosePane,
onReorderTabsInPane,
renderPaneEmptyState,
activeDragTabId,
showDropZones,
dropPreview,
tabDropPreview,
explorerPaneId,
sidePanelPaneId,
maximizedPaneId,
onTogglePaneMaximized,
focusModeEnabled,
@@ -1195,9 +1218,15 @@ function SplitPaneView({
() => onTogglePaneMaximized(paneId),
[onTogglePaneMaximized, paneId],
);
const handleClosePane = useCallback(() => onClosePane(paneId), [onClosePane, paneId]);
return (
<RenderProfile id={`SplitPaneView:${pane.id}`}>
<View ref={paneRef} collapsable={false} style={styles.pane}>
<View
ref={paneRef}
collapsable={false}
style={styles.pane}
testID={pane.id === sidePanelPaneId ? "workspace-side-panel" : `workspace-pane-${pane.id}`}
>
<WindowChromeSafeArea placement="inline" style={styles.paneTabs}>
<TitlebarDragRegion />
<WorkspaceDesktopTabsRow
@@ -1228,7 +1257,7 @@ function SplitPaneView({
tabDropPreviewIndex={
tabDropPreview?.paneId === pane.id ? tabDropPreview.indicatorIndex : null
}
showPaneMaximizeAction={pane.id === explorerPaneId && !focusModeEnabled}
showPaneMaximizeAction={pane.id === sidePanelPaneId && !focusModeEnabled}
paneMaximized={pane.id === maximizedPaneId}
onTogglePaneMaximized={handleToggleMaximized}
focusModeEnabled={Boolean(focusModeEnabled)}
@@ -1257,7 +1286,11 @@ function SplitPaneView({
/>
);
})
: (renderPaneEmptyState?.() ?? null)}
: (renderPaneEmptyState?.({
paneId,
paneIsolated,
onClosePane: handleClosePane,
}) ?? null)}
<SplitDropZone paneId={pane.id} active={showDropZones} preview={dropPreview} />
</View>
</View>
@@ -1,13 +1,14 @@
import { MIN_TREE_RAIL_WIDTH, clampTreeRailWidth } from "@/stores/panel-store";
import { MIN_TREE_RAIL_WIDTH } from "@/stores/panel-store";
const MIN_CONTENT_WIDTH = 240;
export function resolveVisibleTreeRailWidth(
requestedWidth: number,
containerWidth: number,
minimumWidth = MIN_TREE_RAIL_WIDTH,
): number {
const maximumVisibleWidth = Math.max(MIN_TREE_RAIL_WIDTH, containerWidth - MIN_CONTENT_WIDTH);
return Math.min(clampTreeRailWidth(requestedWidth), maximumVisibleWidth);
const maximumVisibleWidth = Math.max(minimumWidth, containerWidth - MIN_CONTENT_WIDTH);
return Math.min(Math.max(minimumWidth, Math.min(600, requestedWidth)), maximumVisibleWidth);
}
export function acceptTreeRailContainerMeasurement(
@@ -0,0 +1,65 @@
import { useMemo } from "react";
import { Pressable, Text } from "react-native";
import { useTranslation } from "react-i18next";
import { FolderTree } from "lucide-react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import {
paneContentToolbarIconSize,
paneContentToolbarIconButtonStyle,
} from "@/components/ui/pane-content-toolbar";
import type { Theme } from "@/styles/theme";
const ThemedFolderTree = withUnistyles(FolderTree);
const iconColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
/**
* Opens and closes a `TreeRail`. Every panel that owns a tree rail uses this one
* button so the affordance reads the same wherever the rail appears; only the
* flag it drives is per-panel.
*/
export function TreeRailToggle({
visible,
testID,
onToggle,
}: {
visible: boolean;
testID: string;
onToggle: () => void;
}) {
const { t } = useTranslation();
const label = t(visible ? "workspace.tree.hideFolderTree" : "workspace.tree.showFolderTree");
const buttonStyle = useMemo(
() => (state: { hovered?: boolean; pressed: boolean }) =>
paneContentToolbarIconButtonStyle(state, visible),
[visible],
);
const accessibilityState = useMemo(() => ({ selected: visible }), [visible]);
return (
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<Pressable
accessibilityRole="button"
accessibilityLabel={label}
accessibilityState={accessibilityState}
aria-selected={visible}
testID={testID}
style={buttonStyle}
onPress={onToggle}
>
<ThemedFolderTree size={paneContentToolbarIconSize(false)} uniProps={iconColorMapping} />
</Pressable>
</TooltipTrigger>
<TooltipContent side="bottom">
<Text style={styles.tooltipText}>{label}</Text>
</TooltipContent>
</Tooltip>
);
}
const styles = StyleSheet.create((theme) => ({
tooltipText: {
fontSize: theme.fontSize.sm,
color: theme.colors.foreground,
},
}));
+20 -6
View File
@@ -27,16 +27,20 @@ export interface TreeRailProps {
*/
children: ReactNode;
testID?: string;
width?: number;
onWidthChange?: (width: number) => void;
minimumWidth?: number;
}
/** Content-left/tree-right master-detail geometry with one app-wide tree width. */
export function TreeRail({ children, testID }: TreeRailProps) {
export function TreeRail({ children, testID, width, onWidthChange, minimumWidth }: TreeRailProps) {
const [content, tree] = Children.toArray(children);
const requestedWidth = usePanelStore((state) => state.treeRailWidth);
const setTreeRailWidth = usePanelStore((state) => state.setTreeRailWidth);
const sharedWidth = usePanelStore((state) => state.treeRailWidth);
const setSharedWidth = usePanelStore((state) => state.setTreeRailWidth);
const requestedWidth = width ?? sharedWidth;
const { width: viewportWidth } = useWindowDimensions();
const [containerWidth, setContainerWidth] = useState(viewportWidth);
const visibleWidth = resolveVisibleTreeRailWidth(requestedWidth, containerWidth);
const visibleWidth = resolveVisibleTreeRailWidth(requestedWidth, containerWidth, minimumWidth);
const startWidthRef = useRef(visibleWidth);
const resizeWidth = useSharedValue(visibleWidth);
const [resizePressed, setResizePressed] = useState(false);
@@ -62,11 +66,21 @@ export function TreeRail({ children, testID }: TreeRailProps) {
resizeWidth.value = resolveVisibleTreeRailWidth(
startWidthRef.current - event.translationX,
containerWidth,
minimumWidth,
);
})
.onEnd(() => runOnJS(setTreeRailWidth)(resizeWidth.value))
.onEnd(() => runOnJS(onWidthChange ?? setSharedWidth)(resizeWidth.value))
.onFinalize(() => scheduleOnRN(hideResizeGrip)),
[containerWidth, hideResizeGrip, resizeWidth, setTreeRailWidth, showResizeGrip, visibleWidth],
[
containerWidth,
hideResizeGrip,
onWidthChange,
minimumWidth,
resizeWidth,
setSharedWidth,
showResizeGrip,
visibleWidth,
],
);
const railWidthStyle = useAnimatedStyle(() => ({ width: resizeWidth.value }));
const handleLayout = useCallback(
@@ -0,0 +1,65 @@
import type { ReactNode } from "react";
import { View, type StyleProp, type ViewStyle } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
/** Shared chrome at the boundary between a workspace pane's tabs and content. */
export function PaneContentToolbar({
children,
style,
testID,
}: {
children: ReactNode;
style?: StyleProp<ViewStyle>;
testID?: string;
}) {
return (
<View style={[styles.toolbar, style]} testID={testID}>
{children}
</View>
);
}
export function paneContentToolbarIconSize(isCompact: boolean): number {
return isCompact ? 18 : 14;
}
export function paneContentToolbarIconButtonStyle(
{ hovered, pressed }: { hovered?: boolean; pressed: boolean },
selected = false,
isCompact = false,
) {
return [
isCompact ? styles.iconButtonCompact : styles.iconButton,
(selected || Boolean(hovered) || pressed) && styles.iconButtonSelected,
];
}
const styles = StyleSheet.create((theme) => ({
toolbar: {
height: WORKSPACE_SECONDARY_HEADER_HEIGHT,
backgroundColor: theme.colors.surface0,
borderBottomWidth: 1,
borderBottomColor: theme.colors.border,
flexShrink: 0,
},
iconButton: {
width: 24,
height: 24,
alignItems: "center",
justifyContent: "center",
borderRadius: theme.borderRadius.md,
flexShrink: 0,
},
iconButtonCompact: {
width: 32,
height: 32,
alignItems: "center",
justifyContent: "center",
borderRadius: theme.borderRadius.md,
flexShrink: 0,
},
iconButtonSelected: {
backgroundColor: theme.colors.surface2,
},
}));
+9 -1
View File
@@ -39,6 +39,7 @@ export function Shortcut({
if (displayChord.length === 1) {
return (
<View style={badgeStyle}>
<View style={styles.badgeBackground} />
<Text style={textCombinedStyle}>{formatShortcut(singleCombo, shortcutOs)}</Text>
</View>
);
@@ -49,6 +50,7 @@ export function Shortcut({
{displayChord.map(function (combo) {
return (
<View key={combo.join("+")} style={styles.badge}>
<View style={styles.badgeBackground} />
<Text style={textCombinedStyle}>{formatShortcut(combo, shortcutOs)}</Text>
</View>
);
@@ -59,12 +61,18 @@ export function Shortcut({
const styles = StyleSheet.create((theme) => ({
badge: {
position: "relative",
paddingHorizontal: theme.spacing[1],
paddingVertical: 2,
borderRadius: theme.borderRadius.md,
backgroundColor: theme.colors.surface2,
borderWidth: 0,
},
badgeBackground: {
...StyleSheet.absoluteFillObject,
borderRadius: theme.borderRadius.md,
backgroundColor: theme.colors.surface4,
opacity: theme.opacity[50],
},
sequence: {
flexDirection: "row",
alignItems: "center",