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:
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}));
|
||||
@@ -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,
|
||||
},
|
||||
}));
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user