mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-09 18:04:55 +00:00
* fix(app): keep the compact sidebar close button beside the first nav row The close button was absolutely positioned over the nav group, so it covered the first row's label and hover fill. It now shares a flex row with the first item, which shrinks to make room, and uses the compact header's icon button so it gets the same size and round press highlight. * test(app): cover the compact sidebar close button beside the first nav row
959 lines
29 KiB
TypeScript
959 lines
29 KiB
TypeScript
import { router } from "expo-router";
|
|
import {
|
|
CircleGauge,
|
|
FolderPlus,
|
|
GitBranch,
|
|
Server,
|
|
Settings,
|
|
X,
|
|
type LucideIcon,
|
|
} from "lucide-react-native";
|
|
import { useTranslation } from "react-i18next";
|
|
import { memo, useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react";
|
|
import {
|
|
Pressable,
|
|
StyleSheet as RNStyleSheet,
|
|
Text,
|
|
useWindowDimensions,
|
|
View,
|
|
} 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 { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles";
|
|
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
|
|
import { HeaderToggleButton } from "@/components/headers/header-toggle-button";
|
|
import {
|
|
IconButtonChromeProvider,
|
|
iconButtonChromeGlyphSize,
|
|
mutedIconColorMapping,
|
|
} from "@/components/ui/icon-button-chrome";
|
|
import { resolveDesktopSidebarWidth } from "@/components/desktop-sidebar-layout";
|
|
import {
|
|
SIDEBAR_RESIZE_ACTIVATION_OFFSET,
|
|
SIDEBAR_RESIZE_FAIL_OFFSET,
|
|
} from "@/components/sidebar-resize-handle-layout";
|
|
import { HostPicker } from "@/components/hosts/host-picker";
|
|
import { SidebarDisplayPreferencesMenu } from "@/components/sidebar/display-preferences/menu";
|
|
import { SidebarSeparator } from "@/components/sidebar/sidebar-separator";
|
|
import { SidebarNavRows } from "@/components/sidebar/sidebar-nav-rows";
|
|
import { SidebarFooterIconHighlight } from "@/components/sidebar/sidebar-footer-icon-highlight";
|
|
import { SidebarHelpMenu } from "@/components/sidebar/sidebar-help-menu";
|
|
import { SidebarResizeHandle } from "@/components/sidebar-resize-handle";
|
|
import { DensityIcon } from "@/components/ui/density-icon";
|
|
import { SidebarSectionHeader } from "@/components/sidebar/sidebar-section-header";
|
|
import { Shortcut } from "@/components/ui/shortcut";
|
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
|
import { HEADER_INNER_HEIGHT, useIsCompactFormFactor } from "@/constants/layout";
|
|
import { useOpenAddProject } from "@/hooks/use-open-add-project";
|
|
import { useImportSession } from "@/hooks/use-import-session";
|
|
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
|
import {
|
|
type SidebarProjectEntry,
|
|
type SidebarWorkspaceEntry,
|
|
} from "@/hooks/use-sidebar-workspaces-list";
|
|
import { density } from "@/styles/density";
|
|
import { useSidebarModel } from "@/components/sidebar/sidebar-model";
|
|
import type { PinnedSidebarGroups } from "@/hooks/use-sidebar-pins";
|
|
import { RetainedPanelActivity } from "@/components/retained-panel";
|
|
import type { SidebarWorkspaceGroup } from "@/components/sidebar/sidebar-labels";
|
|
import type { SidebarProjectIconTarget } from "@/utils/sidebar-project-row-model";
|
|
import { type SidebarGroupMode, useSidebarViewStore } from "@/stores/sidebar-view-store";
|
|
import { useHosts } from "@/runtime/host-runtime";
|
|
import { PluginSidebarItem } from "@/plugins/sidebar-items";
|
|
import { builtinSidebarNavLabelKey } from "@/sidebar-nav/model";
|
|
import { useSidebarNavItems } from "@/sidebar-nav/use-sidebar-nav-items";
|
|
import { usePanelStore } from "@/stores/panel-store";
|
|
import { useOwnsWindowChromeCorner, WindowChromeSafeArea } from "@/utils/desktop-window";
|
|
import { useCloseAgentListGesture } from "@/mobile-panels/gestures";
|
|
import { MobilePanelOverlay } from "@/mobile-panels/presentation";
|
|
import type { ShortcutKey } from "@/utils/format-shortcut";
|
|
import { buildSettingsAddHostRoute, buildSettingsRoute } from "@/utils/host-routes";
|
|
import { openHostOverview } from "@/navigation/settings-navigation";
|
|
import {
|
|
UsageSidebarItem,
|
|
UsageSidebarRoot,
|
|
useHasUsageSummary,
|
|
useOpenSidebarUsage,
|
|
} from "@/usage";
|
|
import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton";
|
|
import { SidebarCalloutSlot } from "./sidebar-callout-slot";
|
|
import { SidebarWorkspaceList } from "./sidebar-workspace-list";
|
|
|
|
const ThemedX = withUnistyles(X);
|
|
|
|
type SidebarTheme = ReturnType<typeof useUnistyles>["theme"];
|
|
|
|
const DEV_BUILD_LABEL = process.env.EXPO_PUBLIC_PASEO_DEV_BUILD_LABEL?.trim() || null;
|
|
|
|
interface SidebarSharedProps {
|
|
theme: SidebarTheme;
|
|
workspaceGroups: SidebarWorkspaceGroup[];
|
|
projectIconTargets: SidebarProjectIconTarget[];
|
|
pinnedGroups: PinnedSidebarGroups;
|
|
projects: SidebarProjectEntry[];
|
|
hasProjectsBeforeFilter: boolean;
|
|
hasActiveProjectFilter: boolean;
|
|
workspaceEntriesByKey: ReadonlyMap<string, SidebarWorkspaceEntry>;
|
|
isInitialLoad: boolean;
|
|
isRevalidating: boolean;
|
|
isManualRefresh: boolean;
|
|
groupMode: SidebarGroupMode;
|
|
collapsedProjectKeys: ReadonlySet<string>;
|
|
shortcutIndexByWorkspaceKey: Map<string, number>;
|
|
toggleProjectCollapsed: (projectViewKey: string) => void;
|
|
handleRefresh: () => void;
|
|
handleOpenProject: () => void;
|
|
handleImportSession: () => void;
|
|
handleSettings: () => void;
|
|
labels: SidebarLabels;
|
|
handleAddHost: () => void;
|
|
handleOpenHostSettings: (serverId: string) => void;
|
|
}
|
|
|
|
interface SidebarLabels {
|
|
addProject: string;
|
|
hosts: string;
|
|
settings: string;
|
|
searchHosts: string;
|
|
usage: string;
|
|
closeSidebar: string;
|
|
}
|
|
|
|
interface MobileSidebarProps extends SidebarSharedProps {
|
|
active: boolean;
|
|
insetsTop: number;
|
|
insetsBottom: number;
|
|
closeSidebar: () => void;
|
|
}
|
|
|
|
interface DesktopSidebarProps extends SidebarSharedProps {
|
|
insetsTop: number;
|
|
active: boolean;
|
|
}
|
|
|
|
export const LeftSidebar = memo(function LeftSidebar({ active }: { active: boolean }) {
|
|
const { theme } = useUnistyles();
|
|
const { t } = useTranslation();
|
|
const insets = useSafeAreaInsets();
|
|
const isCompactLayout = useIsCompactFormFactor();
|
|
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
|
|
|
|
const {
|
|
projects,
|
|
hasProjectsBeforeFilter,
|
|
resolvedProjectFilters,
|
|
workspaceEntriesByKey,
|
|
isInitialLoad,
|
|
isRevalidating,
|
|
refreshAll,
|
|
workspaceGroups,
|
|
projectIconTargets,
|
|
pinnedGroups,
|
|
collapsedProjectKeys,
|
|
toggleProjectCollapsed,
|
|
groupMode,
|
|
shortcutModel,
|
|
} = useSidebarModel();
|
|
const { shortcutIndexByWorkspaceKey } = shortcutModel;
|
|
|
|
const [isManualRefresh, setIsManualRefresh] = useState(false);
|
|
|
|
const handleRefresh = useCallback(() => {
|
|
setIsManualRefresh(true);
|
|
refreshAll();
|
|
}, [refreshAll]);
|
|
|
|
useEffect(() => {
|
|
if (!isRevalidating && isManualRefresh) {
|
|
setIsManualRefresh(false);
|
|
}
|
|
}, [isRevalidating, isManualRefresh]);
|
|
|
|
const openProjectPicker = useOpenAddProject();
|
|
const { open: openImportSession, sheet: importSessionSheet } = useImportSession();
|
|
|
|
const handleOpenProjectMobile = useCallback(() => {
|
|
showMobileAgent();
|
|
void openProjectPicker();
|
|
}, [showMobileAgent, openProjectPicker]);
|
|
|
|
const handleOpenProjectDesktop = useCallback(() => {
|
|
void openProjectPicker();
|
|
}, [openProjectPicker]);
|
|
|
|
const handleSettingsMobile = useCallback(() => {
|
|
showMobileAgent();
|
|
router.push(buildSettingsRoute());
|
|
}, [showMobileAgent]);
|
|
|
|
const handleSettingsDesktop = useCallback(() => {
|
|
router.push(buildSettingsRoute());
|
|
}, []);
|
|
|
|
const handleAddHostMobile = useCallback(() => {
|
|
showMobileAgent();
|
|
router.push(buildSettingsAddHostRoute(Date.now()));
|
|
}, [showMobileAgent]);
|
|
|
|
const handleAddHostDesktop = useCallback(() => {
|
|
router.push(buildSettingsAddHostRoute(Date.now()));
|
|
}, []);
|
|
|
|
const handleOpenHostSettingsMobile = useCallback(
|
|
(serverId: string) => {
|
|
showMobileAgent();
|
|
openHostOverview(serverId);
|
|
},
|
|
[showMobileAgent],
|
|
);
|
|
|
|
const handleOpenHostSettingsDesktop = useCallback((serverId: string) => {
|
|
openHostOverview(serverId);
|
|
}, []);
|
|
|
|
const handleImportSessionMobile = useCallback(() => {
|
|
showMobileAgent();
|
|
openImportSession();
|
|
}, [openImportSession, showMobileAgent]);
|
|
|
|
const labels = useMemo(
|
|
(): SidebarLabels => ({
|
|
addProject: t("sidebar.actions.addProject"),
|
|
hosts: t("sidebar.actions.hosts"),
|
|
settings: t("sidebar.actions.settings"),
|
|
searchHosts: t("sidebar.host.searchPlaceholder"),
|
|
usage: t(builtinSidebarNavLabelKey("usage")),
|
|
closeSidebar: t("sidebar.actions.closeSidebar"),
|
|
}),
|
|
[t],
|
|
);
|
|
|
|
const sharedProps = {
|
|
theme,
|
|
workspaceGroups,
|
|
projectIconTargets,
|
|
pinnedGroups,
|
|
projects,
|
|
hasProjectsBeforeFilter,
|
|
hasActiveProjectFilter: resolvedProjectFilters.length > 0,
|
|
workspaceEntriesByKey,
|
|
isInitialLoad,
|
|
isRevalidating,
|
|
isManualRefresh,
|
|
groupMode,
|
|
collapsedProjectKeys,
|
|
shortcutIndexByWorkspaceKey,
|
|
toggleProjectCollapsed,
|
|
handleRefresh,
|
|
labels,
|
|
};
|
|
|
|
if (isCompactLayout) {
|
|
return (
|
|
<>
|
|
<RetainedPanelActivity active={active}>
|
|
<MobileSidebar
|
|
{...sharedProps}
|
|
active={active}
|
|
insetsTop={insets.top}
|
|
insetsBottom={insets.bottom}
|
|
closeSidebar={showMobileAgent}
|
|
handleOpenProject={handleOpenProjectMobile}
|
|
handleImportSession={handleImportSessionMobile}
|
|
handleSettings={handleSettingsMobile}
|
|
handleAddHost={handleAddHostMobile}
|
|
handleOpenHostSettings={handleOpenHostSettingsMobile}
|
|
/>
|
|
</RetainedPanelActivity>
|
|
{importSessionSheet}
|
|
</>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<RetainedPanelActivity active={active}>
|
|
<DesktopSidebar
|
|
{...sharedProps}
|
|
insetsTop={insets.top}
|
|
active={active}
|
|
handleOpenProject={handleOpenProjectDesktop}
|
|
handleImportSession={openImportSession}
|
|
handleSettings={handleSettingsDesktop}
|
|
handleAddHost={handleAddHostDesktop}
|
|
handleOpenHostSettings={handleOpenHostSettingsDesktop}
|
|
/>
|
|
</RetainedPanelActivity>
|
|
{importSessionSheet}
|
|
</>
|
|
);
|
|
});
|
|
|
|
function sidebarHostOptionTestID(serverId: string): string {
|
|
return `sidebar-host-row-${serverId}`;
|
|
}
|
|
|
|
function FooterIconButton({
|
|
buttonRef,
|
|
onPress,
|
|
testID,
|
|
label,
|
|
icon,
|
|
iconSizeAdjustment = 0,
|
|
shortcutKeys,
|
|
}: {
|
|
onPress: () => void;
|
|
testID: string;
|
|
label: string;
|
|
icon: LucideIcon;
|
|
/** Only for a glyph that reads larger than the others at the same size. */
|
|
iconSizeAdjustment?: number;
|
|
shortcutKeys?: ReturnType<typeof useShortcutKeys>;
|
|
buttonRef?: RefObject<View | null>;
|
|
}) {
|
|
return (
|
|
<Tooltip delayDuration={300}>
|
|
<TooltipTrigger asChild>
|
|
<Pressable
|
|
ref={buttonRef}
|
|
style={styles.footerIconButton}
|
|
testID={testID}
|
|
nativeID={testID}
|
|
collapsable={false}
|
|
accessible
|
|
accessibilityLabel={label}
|
|
accessibilityRole="button"
|
|
onPress={onPress}
|
|
>
|
|
{({ hovered, pressed }) => (
|
|
<>
|
|
<SidebarFooterIconHighlight visible={pressed} />
|
|
<DensityIcon
|
|
icon={icon}
|
|
size="md"
|
|
sizeAdjustment={iconSizeAdjustment}
|
|
color={hovered ? "foreground" : "foregroundMuted"}
|
|
/>
|
|
</>
|
|
)}
|
|
</Pressable>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="top" align="center" offset={8} testID={`${testID}-tooltip`}>
|
|
<IconTooltipContent label={label} shortcutKeys={shortcutKeys} />
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
);
|
|
}
|
|
|
|
function SidebarHostPicker({
|
|
label,
|
|
onAddHost,
|
|
onOpenHostSettings,
|
|
}: {
|
|
label: string;
|
|
onAddHost: () => void;
|
|
onOpenHostSettings: (serverId: string) => void;
|
|
}) {
|
|
const hosts = useHosts();
|
|
const triggerRef = useRef<View | null>(null);
|
|
const [isOpen, setIsOpen] = useState(false);
|
|
|
|
const handleSelect = useCallback(
|
|
(id: string) => {
|
|
onOpenHostSettings(id);
|
|
},
|
|
[onOpenHostSettings],
|
|
);
|
|
|
|
const handleOpen = useCallback(() => setIsOpen(true), []);
|
|
|
|
return (
|
|
<HostPicker
|
|
hosts={hosts}
|
|
value=""
|
|
onSelect={handleSelect}
|
|
open={isOpen}
|
|
onOpenChange={setIsOpen}
|
|
anchorRef={triggerRef}
|
|
includeAddHost
|
|
onAddHost={onAddHost}
|
|
showActiveConnection
|
|
onOpenHostSettings={onOpenHostSettings}
|
|
searchable
|
|
desktopPlacement="top-start"
|
|
desktopMinWidth={240}
|
|
addHostTestID="sidebar-host-add"
|
|
hostOptionTestID={sidebarHostOptionTestID}
|
|
>
|
|
<FooterIconButton
|
|
buttonRef={triggerRef}
|
|
onPress={handleOpen}
|
|
testID="sidebar-hosts-trigger"
|
|
label={label}
|
|
icon={Server}
|
|
// Server's two boxes fill more of the square than the other glyphs.
|
|
iconSizeAdjustment={-1}
|
|
/>
|
|
</HostPicker>
|
|
);
|
|
}
|
|
|
|
function IconTooltipContent({
|
|
label,
|
|
shortcutKeys,
|
|
}: {
|
|
label: string;
|
|
shortcutKeys?: ReturnType<typeof useShortcutKeys>;
|
|
}) {
|
|
return (
|
|
<View style={styles.tooltipRow}>
|
|
<Text style={styles.tooltipText}>{label}</Text>
|
|
{shortcutKeys ? <Shortcut chord={shortcutKeys} /> : null}
|
|
</View>
|
|
);
|
|
}
|
|
|
|
function SidebarFooter({
|
|
handleOpenProject,
|
|
handleSettings,
|
|
labels,
|
|
handleAddHost,
|
|
handleOpenHostSettings,
|
|
onBeforeNavigate,
|
|
}: {
|
|
handleOpenProject: () => void;
|
|
handleSettings: () => void;
|
|
labels: {
|
|
addProject: string;
|
|
hosts: string;
|
|
settings: string;
|
|
searchHosts: string;
|
|
usage: string;
|
|
};
|
|
handleAddHost: () => void;
|
|
handleOpenHostSettings: (serverId: string) => void;
|
|
onBeforeNavigate?: () => void;
|
|
}) {
|
|
const newAgentKeys = useShortcutKeys("new-agent");
|
|
const settingsKeys = useShortcutKeys("toggle-settings");
|
|
|
|
// One line of icons: Add project, Usage, Hosts, then Help and Settings at the end.
|
|
return (
|
|
<UsageSidebarRoot>
|
|
<View style={styles.footerContainer} testID="sidebar-footer">
|
|
<SidebarFooterRows onBeforeNavigate={onBeforeNavigate} />
|
|
<View style={styles.sidebarFooter} testID="sidebar-footer-bottom-line">
|
|
<FooterIconButton
|
|
onPress={handleOpenProject}
|
|
testID="sidebar-add-project"
|
|
label={labels.addProject}
|
|
icon={FolderPlus}
|
|
shortcutKeys={newAgentKeys}
|
|
/>
|
|
<SidebarUsageIcon label={labels.usage} />
|
|
<SidebarHostPicker
|
|
label={labels.hosts}
|
|
onAddHost={handleAddHost}
|
|
onOpenHostSettings={handleOpenHostSettings}
|
|
/>
|
|
<View style={styles.footerSpacer} />
|
|
<SidebarHelpMenu />
|
|
<FooterIconButton
|
|
onPress={handleSettings}
|
|
testID="sidebar-settings"
|
|
label={labels.settings}
|
|
icon={Settings}
|
|
shortcutKeys={settingsKeys}
|
|
/>
|
|
</View>
|
|
</View>
|
|
</UsageSidebarRoot>
|
|
);
|
|
}
|
|
|
|
function SidebarUsageIcon({ label }: { label: string }) {
|
|
const openUsage = useOpenSidebarUsage();
|
|
return (
|
|
<FooterIconButton
|
|
onPress={openUsage}
|
|
testID="sidebar-usage-icon"
|
|
label={label}
|
|
icon={CircleGauge}
|
|
/>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* The footer rows in the user's `sidebarFooterItems` order: the Usage item and plugin rows. The
|
|
* Usage item is left out while it has no summary to show.
|
|
*/
|
|
function SidebarFooterRows({ onBeforeNavigate }: { onBeforeNavigate?: () => void }) {
|
|
const { items } = useSidebarNavItems("footer");
|
|
const hasUsageSummary = useHasUsageSummary();
|
|
const rowsRef = useRef<View | null>(null);
|
|
const visibleItems = items.filter(
|
|
(item) => item.visible && (item.kind === "plugin" || hasUsageSummary),
|
|
);
|
|
if (visibleItems.length === 0) return null;
|
|
return (
|
|
<>
|
|
<View ref={rowsRef} collapsable={false} style={styles.footerRows}>
|
|
{visibleItems.map((item) =>
|
|
item.kind === "plugin" ? (
|
|
<PluginSidebarItem
|
|
key={item.key}
|
|
group={item.group}
|
|
section="footer"
|
|
fallbackAnchorRef={rowsRef}
|
|
onBeforeNavigate={onBeforeNavigate}
|
|
/>
|
|
) : (
|
|
<UsageSidebarItem key={item.key} />
|
|
),
|
|
)}
|
|
</View>
|
|
<SidebarSeparator testID="sidebar-footer-separator" />
|
|
</>
|
|
);
|
|
}
|
|
|
|
function MobileSidebar({
|
|
active,
|
|
theme,
|
|
workspaceGroups,
|
|
projectIconTargets,
|
|
pinnedGroups,
|
|
projects,
|
|
hasProjectsBeforeFilter,
|
|
hasActiveProjectFilter,
|
|
workspaceEntriesByKey,
|
|
isInitialLoad,
|
|
isRevalidating,
|
|
isManualRefresh,
|
|
groupMode,
|
|
collapsedProjectKeys,
|
|
shortcutIndexByWorkspaceKey,
|
|
toggleProjectCollapsed,
|
|
handleRefresh,
|
|
handleOpenProject,
|
|
handleImportSession,
|
|
handleSettings,
|
|
labels,
|
|
handleAddHost,
|
|
handleOpenHostSettings,
|
|
insetsTop,
|
|
insetsBottom,
|
|
closeSidebar,
|
|
}: MobileSidebarProps) {
|
|
const hasActiveHostFilter = useSidebarViewStore((state) => state.hostFilters.length > 0);
|
|
const { gesture: closeGesture, gestureRef: closeGestureRef } = useCloseAgentListGesture();
|
|
|
|
const handleWorkspacePress = useCallback(() => {
|
|
closeSidebar();
|
|
}, [closeSidebar]);
|
|
|
|
const closeButton = useMemo(
|
|
() => <SidebarCloseButton label={labels.closeSidebar} onPress={closeSidebar} />,
|
|
[labels.closeSidebar, closeSidebar],
|
|
);
|
|
|
|
const mobileSidebarInsetStyle = useMemo(
|
|
() => ({
|
|
paddingTop: insetsTop,
|
|
paddingBottom: insetsBottom,
|
|
backgroundColor: theme.colors.surfaceSidebar,
|
|
}),
|
|
[insetsTop, insetsBottom, theme.colors.surfaceSidebar],
|
|
);
|
|
|
|
return (
|
|
<MobilePanelOverlay
|
|
panel="agent-list"
|
|
closeGesture={closeGesture}
|
|
panelStyle={mobileSidebarInsetStyle}
|
|
>
|
|
<View style={styles.sidebarContent} pointerEvents="auto">
|
|
<WindowChromeSafeArea placement="below" />
|
|
<SidebarNavRows
|
|
style={styles.sidebarHeaderGroup}
|
|
onBeforeNavigate={closeSidebar}
|
|
firstRowTrailing={closeButton}
|
|
/>
|
|
|
|
{isInitialLoad && !hasActiveHostFilter ? (
|
|
<SidebarAgentListSkeleton />
|
|
) : (
|
|
<SidebarWorkspaceList
|
|
collapsedProjectKeys={collapsedProjectKeys}
|
|
onToggleProjectCollapsed={toggleProjectCollapsed}
|
|
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
|
|
groupMode={groupMode}
|
|
workspaceGroups={workspaceGroups}
|
|
projectIconTargets={projectIconTargets}
|
|
pinnedGroups={pinnedGroups}
|
|
projects={projects}
|
|
hasProjectsBeforeFilter={hasProjectsBeforeFilter}
|
|
hasActiveProjectFilter={hasActiveProjectFilter}
|
|
workspaceEntriesByKey={workspaceEntriesByKey}
|
|
isRefreshing={isManualRefresh && isRevalidating}
|
|
onRefresh={handleRefresh}
|
|
onWorkspacePress={handleWorkspacePress}
|
|
onAddProject={handleOpenProject}
|
|
onImportSession={handleImportSession}
|
|
parentGestureRef={closeGestureRef}
|
|
dragGestureHostActive={active}
|
|
listHeaderComponent={workspacesSectionHeaderElement}
|
|
/>
|
|
)}
|
|
|
|
<SidebarFooter
|
|
handleOpenProject={handleOpenProject}
|
|
handleSettings={handleSettings}
|
|
labels={labels}
|
|
handleAddHost={handleAddHost}
|
|
handleOpenHostSettings={handleOpenHostSettings}
|
|
onBeforeNavigate={closeSidebar}
|
|
/>
|
|
</View>
|
|
</MobilePanelOverlay>
|
|
);
|
|
}
|
|
|
|
const NO_SHORTCUT: ShortcutKey[] = [];
|
|
|
|
/** The compact header's icon button, so the sidebar's close reads like the header it covers. */
|
|
function SidebarCloseButton({ label, onPress }: { label: string; onPress: () => void }) {
|
|
return (
|
|
<IconButtonChromeProvider value="header-island">
|
|
<HeaderToggleButton
|
|
onPress={onPress}
|
|
tooltipLabel={label}
|
|
tooltipKeys={NO_SHORTCUT}
|
|
tooltipSide="bottom"
|
|
style={styles.mobileCloseButton}
|
|
testID="sidebar-close"
|
|
nativeID="sidebar-close"
|
|
accessible
|
|
accessibilityRole="button"
|
|
accessibilityLabel={label}
|
|
>
|
|
<ThemedX
|
|
size={iconButtonChromeGlyphSize("large")}
|
|
strokeWidth={1.5}
|
|
uniProps={mutedIconColorMapping}
|
|
/>
|
|
</HeaderToggleButton>
|
|
</IconButtonChromeProvider>
|
|
);
|
|
}
|
|
|
|
function DesktopSidebar({
|
|
theme,
|
|
workspaceGroups,
|
|
projectIconTargets,
|
|
pinnedGroups,
|
|
projects,
|
|
hasProjectsBeforeFilter,
|
|
hasActiveProjectFilter,
|
|
workspaceEntriesByKey,
|
|
isInitialLoad,
|
|
isRevalidating,
|
|
isManualRefresh,
|
|
groupMode,
|
|
collapsedProjectKeys,
|
|
shortcutIndexByWorkspaceKey,
|
|
toggleProjectCollapsed,
|
|
handleRefresh,
|
|
handleOpenProject,
|
|
handleImportSession,
|
|
handleSettings,
|
|
labels,
|
|
handleAddHost,
|
|
handleOpenHostSettings,
|
|
insetsTop,
|
|
active,
|
|
}: DesktopSidebarProps) {
|
|
const ownsTopLeft = useOwnsWindowChromeCorner("top-left");
|
|
const hasActiveHostFilter = useSidebarViewStore((state) => state.hostFilters.length > 0);
|
|
const sidebarWidth = usePanelStore((state) => state.sidebarWidth);
|
|
const setSidebarWidth = usePanelStore((state) => state.setSidebarWidth);
|
|
const { width: viewportWidth } = useWindowDimensions();
|
|
const visibleSidebarWidth = resolveDesktopSidebarWidth({
|
|
requestedWidth: sidebarWidth,
|
|
viewportWidth,
|
|
});
|
|
|
|
const startWidthRef = useRef(visibleSidebarWidth);
|
|
const resizeWidth = useSharedValue(visibleSidebarWidth);
|
|
const [resizePressed, setResizePressed] = useState(false);
|
|
const showResizeGrip = useCallback(() => setResizePressed(true), []);
|
|
const hideResizeGrip = useCallback(() => setResizePressed(false), []);
|
|
|
|
useEffect(() => {
|
|
resizeWidth.value = visibleSidebarWidth;
|
|
}, [resizeWidth, visibleSidebarWidth]);
|
|
|
|
const resizeGesture = useMemo(
|
|
() =>
|
|
Gesture.Pan()
|
|
.hitSlop({ left: 8, right: 8, top: 0, bottom: 0 })
|
|
.onBegin(() => {
|
|
scheduleOnRN(showResizeGrip);
|
|
})
|
|
// Horizontal intent only, so a finger dragging down the touch grip scrolls
|
|
// the workspace list instead of resizing. Anchoring the start width to the
|
|
// activation translation keeps the extra threshold from jumping the edge.
|
|
.activeOffsetX([-SIDEBAR_RESIZE_ACTIVATION_OFFSET, SIDEBAR_RESIZE_ACTIVATION_OFFSET])
|
|
.failOffsetY([-SIDEBAR_RESIZE_FAIL_OFFSET, SIDEBAR_RESIZE_FAIL_OFFSET])
|
|
.onStart((event) => {
|
|
startWidthRef.current = visibleSidebarWidth - event.translationX;
|
|
resizeWidth.value = visibleSidebarWidth;
|
|
})
|
|
.onUpdate((event) => {
|
|
// Dragging right (positive translationX) increases width
|
|
const newWidth = startWidthRef.current + event.translationX;
|
|
resizeWidth.value = resolveDesktopSidebarWidth({
|
|
requestedWidth: newWidth,
|
|
viewportWidth,
|
|
});
|
|
})
|
|
.onEnd(() => {
|
|
runOnJS(setSidebarWidth)(resizeWidth.value);
|
|
})
|
|
.onFinalize(() => {
|
|
scheduleOnRN(hideResizeGrip);
|
|
}),
|
|
[
|
|
hideResizeGrip,
|
|
resizeWidth,
|
|
setSidebarWidth,
|
|
showResizeGrip,
|
|
viewportWidth,
|
|
visibleSidebarWidth,
|
|
],
|
|
);
|
|
|
|
const resizeAnimatedStyle = useAnimatedStyle(() => ({
|
|
width: resizeWidth.value,
|
|
}));
|
|
|
|
const desktopSidebarStyle = useMemo(
|
|
() => [
|
|
staticStyles.desktopSidebar,
|
|
!active && staticStyles.desktopSidebarHidden,
|
|
resizeAnimatedStyle,
|
|
],
|
|
[active, resizeAnimatedStyle],
|
|
);
|
|
const desktopSidebarBorderStyle = useMemo(
|
|
() => [styles.desktopSidebarBorder, { flex: 1, paddingTop: insetsTop }],
|
|
[insetsTop],
|
|
);
|
|
const sidebarHeaderGroupStyle = useMemo(
|
|
() => [styles.sidebarHeaderGroup, ownsTopLeft && styles.sidebarHeaderGroupBelowChrome],
|
|
[ownsTopLeft],
|
|
);
|
|
return (
|
|
<Animated.View
|
|
accessibilityElementsHidden={!active}
|
|
importantForAccessibility={active ? "auto" : "no-hide-descendants"}
|
|
pointerEvents={active ? "auto" : "none"}
|
|
style={desktopSidebarStyle}
|
|
>
|
|
<View style={desktopSidebarBorderStyle}>
|
|
<View style={styles.sidebarDragArea}>
|
|
{ownsTopLeft || DEV_BUILD_LABEL ? (
|
|
<View style={styles.desktopChromeRow}>
|
|
<TitlebarDragRegion />
|
|
{DEV_BUILD_LABEL ? (
|
|
<View
|
|
pointerEvents="none"
|
|
style={styles.devBuildBadge}
|
|
testID="dev-build-label"
|
|
accessibilityLabel={`Development build: ${DEV_BUILD_LABEL}`}
|
|
>
|
|
<GitBranch size={12} color={theme.colors.accentForeground} />
|
|
<Text numberOfLines={1} ellipsizeMode="tail" style={styles.devBuildBadgeText}>
|
|
{DEV_BUILD_LABEL}
|
|
</Text>
|
|
</View>
|
|
) : null}
|
|
</View>
|
|
) : (
|
|
<TitlebarDragRegion />
|
|
)}
|
|
<SidebarNavRows style={sidebarHeaderGroupStyle} />
|
|
</View>
|
|
|
|
{isInitialLoad && !hasActiveHostFilter ? (
|
|
<SidebarAgentListSkeleton />
|
|
) : (
|
|
<SidebarWorkspaceList
|
|
collapsedProjectKeys={collapsedProjectKeys}
|
|
onToggleProjectCollapsed={toggleProjectCollapsed}
|
|
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
|
|
groupMode={groupMode}
|
|
workspaceGroups={workspaceGroups}
|
|
projectIconTargets={projectIconTargets}
|
|
pinnedGroups={pinnedGroups}
|
|
projects={projects}
|
|
hasProjectsBeforeFilter={hasProjectsBeforeFilter}
|
|
hasActiveProjectFilter={hasActiveProjectFilter}
|
|
workspaceEntriesByKey={workspaceEntriesByKey}
|
|
isRefreshing={isManualRefresh && isRevalidating}
|
|
onRefresh={handleRefresh}
|
|
onAddProject={handleOpenProject}
|
|
onImportSession={handleImportSession}
|
|
listHeaderComponent={workspacesSectionHeaderElement}
|
|
/>
|
|
)}
|
|
|
|
<SidebarCalloutSlot />
|
|
|
|
<SidebarFooter
|
|
handleOpenProject={handleOpenProject}
|
|
handleSettings={handleSettings}
|
|
labels={labels}
|
|
handleAddHost={handleAddHost}
|
|
handleOpenHostSettings={handleOpenHostSettings}
|
|
/>
|
|
|
|
<SidebarResizeHandle
|
|
edge="right"
|
|
gesture={resizeGesture}
|
|
pressed={resizePressed}
|
|
testID="left-sidebar-resize-handle"
|
|
/>
|
|
</View>
|
|
</Animated.View>
|
|
);
|
|
}
|
|
|
|
function WorkspacesSectionHeader() {
|
|
return (
|
|
<SidebarSectionHeader title="Workspaces">
|
|
<Tooltip delayDuration={300}>
|
|
<TooltipTrigger asChild>
|
|
<View>
|
|
<SidebarDisplayPreferencesMenu />
|
|
</View>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="bottom" align="center" offset={8}>
|
|
<IconTooltipContent label="Display preferences" />
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</SidebarSectionHeader>
|
|
);
|
|
}
|
|
|
|
// Stable element so the sidebar list's listHeaderComponent prop keeps identity across
|
|
// renders (WorkspacesSectionHeader takes no props).
|
|
const workspacesSectionHeaderElement = <WorkspacesSectionHeader />;
|
|
|
|
// 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
|
|
// tries to patch the native node that Reanimated also manages.
|
|
const staticStyles = RNStyleSheet.create({
|
|
desktopSidebar: {
|
|
position: "relative" as const,
|
|
},
|
|
desktopSidebarHidden: {
|
|
display: "none",
|
|
},
|
|
});
|
|
|
|
const styles = StyleSheet.create((theme) => ({
|
|
sidebarHeaderGroup: {
|
|
paddingTop: theme.spacing[2],
|
|
gap: 2,
|
|
paddingBottom: theme.spacing[1.5],
|
|
borderBottomWidth: 1,
|
|
borderBottomColor: theme.colors.border,
|
|
},
|
|
sidebarHeaderGroupBelowChrome: {
|
|
paddingTop: 0,
|
|
},
|
|
sidebarContent: {
|
|
flex: 1,
|
|
minHeight: 0,
|
|
},
|
|
mobileCloseButton: {
|
|
marginRight: theme.spacing[2],
|
|
},
|
|
desktopSidebarBorder: {
|
|
borderRightWidth: 1,
|
|
borderRightColor: theme.colors.border,
|
|
backgroundColor: theme.colors.surfaceSidebar,
|
|
},
|
|
sidebarDragArea: {
|
|
position: "relative",
|
|
},
|
|
desktopChromeRow: {
|
|
position: "relative",
|
|
height: HEADER_INNER_HEIGHT,
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
justifyContent: "flex-end",
|
|
paddingHorizontal: theme.spacing[3],
|
|
borderBottomWidth: theme.borderWidth[1],
|
|
borderBottomColor: "transparent",
|
|
},
|
|
devBuildBadge: {
|
|
maxWidth: "60%",
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: theme.spacing[1],
|
|
paddingHorizontal: theme.spacing[2],
|
|
paddingVertical: 2,
|
|
borderRadius: theme.borderRadius.full,
|
|
backgroundColor: theme.colors.accent,
|
|
},
|
|
devBuildBadgeText: {
|
|
minWidth: 0,
|
|
flexShrink: 1,
|
|
color: theme.colors.accentForeground,
|
|
fontSize: theme.fontSize.sm,
|
|
fontWeight: theme.fontWeight.medium,
|
|
},
|
|
footerContainer: {
|
|
borderTopWidth: 1,
|
|
borderTopColor: theme.colors.border,
|
|
},
|
|
// Buttons sit edge to edge; their own inset spaces the glyphs.
|
|
sidebarFooter: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
paddingHorizontal: theme.spacing[2],
|
|
paddingVertical: theme.spacing[1.5],
|
|
},
|
|
// Pushes Help and Settings to the end of the icon line.
|
|
footerSpacer: {
|
|
flex: 1,
|
|
},
|
|
// Usage and plugin rows sit above the footer's icon line, spaced like the header nav rows.
|
|
footerRows: {
|
|
paddingHorizontal: theme.spacing[2],
|
|
paddingVertical: theme.spacing[1.5],
|
|
gap: 2,
|
|
},
|
|
footerIconButton: {
|
|
width: density.iconButton,
|
|
height: density.iconButton,
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
paddingVertical: theme.spacing[1],
|
|
paddingHorizontal: theme.spacing[1],
|
|
},
|
|
tooltipRow: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: theme.spacing[2],
|
|
},
|
|
tooltipText: {
|
|
fontSize: theme.fontSize.base,
|
|
color: theme.colors.popoverForeground,
|
|
},
|
|
}));
|