perf(app): limit initial sidebar workspace rendering
Large project, status, and pinned groups now render 20 rows until expanded. Newly discovered workspaces are prepended without disturbing the saved order.
This commit is contained in:
@@ -94,6 +94,8 @@ import type { StatusGroup } from "@/hooks/sidebar-status-view-model";
|
||||
import { SidebarWorkspaceMenu } from "@/components/sidebar/sidebar-workspace-menu";
|
||||
import { useLongPressDragInteraction } from "@/components/sidebar/use-long-press-drag-interaction";
|
||||
import { PinnedSectionHeader } from "@/components/sidebar/pinned-section-header";
|
||||
import { SidebarGroupToggleRow } from "@/components/sidebar/sidebar-group-toggle-row";
|
||||
import { useLimitedSidebarGroup } from "@/components/sidebar/use-limited-sidebar-group";
|
||||
import {
|
||||
SidebarWorkspaceRowFrame,
|
||||
SidebarWorkspaceRowContent,
|
||||
@@ -1632,6 +1634,12 @@ function ProjectBlock({
|
||||
supportsPinningByServerId: ReadonlyMap<string, boolean>;
|
||||
onToggleWorkspacePin: ToggleSidebarWorkspacePin;
|
||||
}) {
|
||||
const {
|
||||
visibleItems: visibleWorkspaces,
|
||||
expanded: workspacesExpanded,
|
||||
canToggle: canToggleWorkspaces,
|
||||
toggleExpanded: toggleWorkspacesExpanded,
|
||||
} = useLimitedSidebarGroup(project.workspaces);
|
||||
const rowModel = useMemo(
|
||||
() =>
|
||||
buildSidebarProjectRowModel({
|
||||
@@ -1784,19 +1792,28 @@ function ProjectBlock({
|
||||
if (!collapsed) {
|
||||
if (project.workspaces.length > 0) {
|
||||
projectChildren = (
|
||||
<DraggableList
|
||||
testID={`sidebar-workspace-list-${project.projectKey}`}
|
||||
data={project.workspaces}
|
||||
keyExtractor={workspaceKeyExtractor}
|
||||
renderItem={renderWorkspace}
|
||||
onDragEnd={handleWorkspaceDragEnd}
|
||||
extraData={activeWorkspaceSelectionKey(activeWorkspaceSelection)}
|
||||
scrollEnabled={false}
|
||||
useDragHandle
|
||||
nestable={useNestable}
|
||||
simultaneousGestureRef={parentGestureRef}
|
||||
containerStyle={styles.workspaceListContainer}
|
||||
/>
|
||||
<>
|
||||
<DraggableList
|
||||
testID={`sidebar-workspace-list-${project.projectKey}`}
|
||||
data={visibleWorkspaces}
|
||||
keyExtractor={workspaceKeyExtractor}
|
||||
renderItem={renderWorkspace}
|
||||
onDragEnd={handleWorkspaceDragEnd}
|
||||
extraData={activeWorkspaceSelectionKey(activeWorkspaceSelection)}
|
||||
scrollEnabled={false}
|
||||
useDragHandle
|
||||
nestable={useNestable}
|
||||
simultaneousGestureRef={parentGestureRef}
|
||||
containerStyle={styles.workspaceListContainer}
|
||||
/>
|
||||
{canToggleWorkspaces ? (
|
||||
<SidebarGroupToggleRow
|
||||
expanded={workspacesExpanded}
|
||||
onPress={toggleWorkspacesExpanded}
|
||||
testID={`sidebar-project-show-more-${project.projectKey}`}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
} else if (rowModel.trailingAction.kind === "new_workspace") {
|
||||
projectChildren = (
|
||||
@@ -2073,6 +2090,12 @@ function ProjectModeList({
|
||||
const selectionEnabled = isWorkspaceRoute;
|
||||
const activeWorkspaceSelection = useActiveWorkspaceSelection();
|
||||
const { pinnedChats, unpinnedProjects } = pinnedGroups;
|
||||
const {
|
||||
visibleItems: visiblePinnedChats,
|
||||
expanded: pinnedChatsExpanded,
|
||||
canToggle: canTogglePinnedChats,
|
||||
toggleExpanded: togglePinnedChatsExpanded,
|
||||
} = useLimitedSidebarGroup(pinnedChats);
|
||||
const projectIconTargets = useMemo(
|
||||
() =>
|
||||
projects.flatMap((project) => {
|
||||
@@ -2330,7 +2353,18 @@ function ProjectModeList({
|
||||
{pinnedChats.length > 0 ? (
|
||||
<View style={styles.pinnedSection} testID="sidebar-pinned-section">
|
||||
<PinnedSectionHeader collapsed={pinnedCollapsed} onToggle={togglePinnedCollapsed} />
|
||||
{pinnedCollapsed ? null : pinnedChats.map(renderPinnedChat)}
|
||||
{pinnedCollapsed ? null : (
|
||||
<>
|
||||
{visiblePinnedChats.map(renderPinnedChat)}
|
||||
{canTogglePinnedChats ? (
|
||||
<SidebarGroupToggleRow
|
||||
expanded={pinnedChatsExpanded}
|
||||
onPress={togglePinnedChatsExpanded}
|
||||
testID="sidebar-pinned-show-more"
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
) : null}
|
||||
{unpinnedProjects.length > 0 || hasActiveHostFilter ? listHeaderComponent : null}
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import { useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Pressable, Text, View, type PressableStateCallbackType } from "react-native";
|
||||
import { ChevronDown, ChevronUp } from "lucide-react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
|
||||
const foregroundMutedColorMapping = (theme: Theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
});
|
||||
const foregroundColorMapping = (theme: Theme) => ({
|
||||
color: theme.colors.foreground,
|
||||
});
|
||||
const ThemedChevronDown = withUnistyles(ChevronDown);
|
||||
const ThemedChevronUp = withUnistyles(ChevronUp);
|
||||
|
||||
export function SidebarGroupToggleRow({
|
||||
expanded,
|
||||
onPress,
|
||||
testID,
|
||||
}: {
|
||||
expanded: boolean;
|
||||
onPress: () => void;
|
||||
testID: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const label = t(
|
||||
expanded ? "sidebar.workspace.actions.showLess" : "sidebar.workspace.actions.showMore",
|
||||
);
|
||||
const rowStyle = useCallback(
|
||||
({ hovered = false, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||
styles.row,
|
||||
(Boolean(hovered) || pressed) && styles.rowHovered,
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
accessibilityRole={isWeb ? undefined : "button"}
|
||||
accessibilityLabel={label}
|
||||
onPress={onPress}
|
||||
style={rowStyle}
|
||||
testID={testID}
|
||||
>
|
||||
{({ hovered, pressed }) => (
|
||||
<>
|
||||
<View style={styles.iconSlot}>
|
||||
{expanded ? (
|
||||
<ThemedChevronUp
|
||||
size={14}
|
||||
uniProps={hovered || pressed ? foregroundColorMapping : foregroundMutedColorMapping}
|
||||
/>
|
||||
) : (
|
||||
<ThemedChevronDown
|
||||
size={14}
|
||||
uniProps={hovered || pressed ? foregroundColorMapping : foregroundMutedColorMapping}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
<Text style={hovered || pressed ? styles.textHovered : styles.text} numberOfLines={1}>
|
||||
{label}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
row: {
|
||||
minHeight: 32,
|
||||
marginLeft: theme.spacing[6],
|
||||
marginRight: theme.spacing[1],
|
||||
paddingVertical: theme.spacing[1],
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
userSelect: "none",
|
||||
},
|
||||
rowHovered: {
|
||||
backgroundColor: theme.colors.surfaceSidebarHover,
|
||||
},
|
||||
iconSlot: {
|
||||
width: theme.iconSize.sm,
|
||||
height: theme.iconSize.sm,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flexShrink: 0,
|
||||
},
|
||||
text: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
minWidth: 0,
|
||||
flexShrink: 1,
|
||||
},
|
||||
textHovered: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
minWidth: 0,
|
||||
flexShrink: 1,
|
||||
},
|
||||
}));
|
||||
@@ -42,6 +42,8 @@ import {
|
||||
import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store";
|
||||
import { SidebarWorkspaceMenu } from "@/components/sidebar/sidebar-workspace-menu";
|
||||
import { PinnedSectionHeader } from "@/components/sidebar/pinned-section-header";
|
||||
import { SidebarGroupToggleRow } from "@/components/sidebar/sidebar-group-toggle-row";
|
||||
import { useLimitedSidebarGroup } from "@/components/sidebar/use-limited-sidebar-group";
|
||||
import type { ToggleSidebarWorkspacePin } from "@/hooks/use-sidebar-workspace-pin";
|
||||
|
||||
// Themed icon wrappers
|
||||
@@ -93,6 +95,12 @@ export function SidebarStatusWorkspaceList({
|
||||
const togglePinnedCollapsed = useSidebarCollapsedSectionsStore(
|
||||
(state) => state.togglePinnedCollapsed,
|
||||
);
|
||||
const {
|
||||
visibleItems: visiblePinnedWorkspaces,
|
||||
expanded: pinnedWorkspacesExpanded,
|
||||
canToggle: canTogglePinnedWorkspaces,
|
||||
toggleExpanded: togglePinnedWorkspacesExpanded,
|
||||
} = useLimitedSidebarGroup(pinnedWorkspaces);
|
||||
|
||||
const statusShortcutIndex = showShortcutBadges ? shortcutIndexByWorkspaceKey : new Map();
|
||||
const content = (
|
||||
@@ -100,9 +108,9 @@ export function SidebarStatusWorkspaceList({
|
||||
{pinnedWorkspaces.length > 0 ? (
|
||||
<View style={styles.pinnedSection} testID="sidebar-pinned-section">
|
||||
<PinnedSectionHeader collapsed={pinnedCollapsed} onToggle={togglePinnedCollapsed} />
|
||||
{pinnedCollapsed
|
||||
? null
|
||||
: pinnedWorkspaces.map((workspace) => (
|
||||
{pinnedCollapsed ? null : (
|
||||
<>
|
||||
{visiblePinnedWorkspaces.map((workspace) => (
|
||||
<StatusWorkspaceRow
|
||||
key={workspace.workspaceKey}
|
||||
workspace={workspace}
|
||||
@@ -119,6 +127,15 @@ export function SidebarStatusWorkspaceList({
|
||||
onWorkspacePress={onWorkspacePress}
|
||||
/>
|
||||
))}
|
||||
{canTogglePinnedWorkspaces ? (
|
||||
<SidebarGroupToggleRow
|
||||
expanded={pinnedWorkspacesExpanded}
|
||||
onPress={togglePinnedWorkspacesExpanded}
|
||||
testID="sidebar-pinned-show-more"
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
) : null}
|
||||
{listHeaderComponent}
|
||||
@@ -188,38 +205,92 @@ function StatusGroupList({
|
||||
return (
|
||||
<>
|
||||
{groups.map((group) => (
|
||||
<View key={group.bucket} style={styles.statusGroupBlock}>
|
||||
<StatusGroupHeader group={group} collapsed={collapsedStatusGroupKeys.has(group.bucket)} />
|
||||
{!collapsedStatusGroupKeys.has(group.bucket) ? (
|
||||
<View
|
||||
style={styles.statusWorkspaceListContainer}
|
||||
testID={`sidebar-status-group-rows-${group.bucket}`}
|
||||
>
|
||||
{group.rows.map((workspace) => (
|
||||
<StatusWorkspaceRow
|
||||
key={workspace.workspaceKey}
|
||||
workspace={workspace}
|
||||
subtitle={buildStatusRowSubtitle({
|
||||
projectName: projectNamesByKey.get(workspace.projectKey) ?? "",
|
||||
hostLabel: showHostLabels
|
||||
? (hostLabelByServerId.get(workspace.serverId) ?? workspace.serverId)
|
||||
: null,
|
||||
})}
|
||||
shortcutNumber={shortcutIndex.get(workspace.workspaceKey) ?? null}
|
||||
showShortcutBadge={showShortcutBadges}
|
||||
canPin={supportsPinningByServerId.get(workspace.serverId) === true}
|
||||
onToggleWorkspacePin={onToggleWorkspacePin}
|
||||
onWorkspacePress={onWorkspacePress}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
<StatusGroupRows
|
||||
key={group.bucket}
|
||||
group={group}
|
||||
collapsed={collapsedStatusGroupKeys.has(group.bucket)}
|
||||
projectNamesByKey={projectNamesByKey}
|
||||
shortcutIndex={shortcutIndex}
|
||||
showShortcutBadges={showShortcutBadges}
|
||||
onWorkspacePress={onWorkspacePress}
|
||||
hostLabelByServerId={hostLabelByServerId}
|
||||
showHostLabels={showHostLabels}
|
||||
supportsPinningByServerId={supportsPinningByServerId}
|
||||
onToggleWorkspacePin={onToggleWorkspacePin}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusGroupRows({
|
||||
group,
|
||||
collapsed,
|
||||
projectNamesByKey,
|
||||
shortcutIndex,
|
||||
showShortcutBadges,
|
||||
onWorkspacePress,
|
||||
hostLabelByServerId,
|
||||
showHostLabels,
|
||||
supportsPinningByServerId,
|
||||
onToggleWorkspacePin,
|
||||
}: {
|
||||
group: StatusGroup;
|
||||
collapsed: boolean;
|
||||
projectNamesByKey: Map<string, string>;
|
||||
shortcutIndex: Map<string, number>;
|
||||
showShortcutBadges: boolean;
|
||||
onWorkspacePress?: () => void;
|
||||
hostLabelByServerId: ReadonlyMap<string, string>;
|
||||
showHostLabels: boolean;
|
||||
supportsPinningByServerId: ReadonlyMap<string, boolean>;
|
||||
onToggleWorkspacePin: ToggleSidebarWorkspacePin;
|
||||
}) {
|
||||
const {
|
||||
visibleItems: visibleWorkspaces,
|
||||
expanded: workspacesExpanded,
|
||||
canToggle: canToggleWorkspaces,
|
||||
toggleExpanded: toggleWorkspacesExpanded,
|
||||
} = useLimitedSidebarGroup(group.rows);
|
||||
|
||||
return (
|
||||
<View style={styles.statusGroupBlock}>
|
||||
<StatusGroupHeader group={group} collapsed={collapsed} />
|
||||
{!collapsed ? (
|
||||
<View
|
||||
style={styles.statusWorkspaceListContainer}
|
||||
testID={`sidebar-status-group-rows-${group.bucket}`}
|
||||
>
|
||||
{visibleWorkspaces.map((workspace) => (
|
||||
<StatusWorkspaceRow
|
||||
key={workspace.workspaceKey}
|
||||
workspace={workspace}
|
||||
subtitle={buildStatusRowSubtitle({
|
||||
projectName: projectNamesByKey.get(workspace.projectKey) ?? "",
|
||||
hostLabel: showHostLabels
|
||||
? (hostLabelByServerId.get(workspace.serverId) ?? workspace.serverId)
|
||||
: null,
|
||||
})}
|
||||
shortcutNumber={shortcutIndex.get(workspace.workspaceKey) ?? null}
|
||||
showShortcutBadge={showShortcutBadges}
|
||||
canPin={supportsPinningByServerId.get(workspace.serverId) === true}
|
||||
onToggleWorkspacePin={onToggleWorkspacePin}
|
||||
onWorkspacePress={onWorkspacePress}
|
||||
/>
|
||||
))}
|
||||
{canToggleWorkspaces ? (
|
||||
<SidebarGroupToggleRow
|
||||
expanded={workspacesExpanded}
|
||||
onPress={toggleWorkspacesExpanded}
|
||||
testID={`sidebar-status-show-more-${group.bucket}`}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// Status mode breaks the project grouping, so the row needs the project name to stay
|
||||
// legible; the host is appended after a middle dot once labels are active.
|
||||
function buildStatusRowSubtitle({
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
|
||||
const INITIAL_VISIBLE_ITEMS = 20;
|
||||
|
||||
export function useLimitedSidebarGroup<T>(items: readonly T[]) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const visibleItems = useMemo(
|
||||
() => (expanded ? items.slice() : items.slice(0, INITIAL_VISIBLE_ITEMS)),
|
||||
[expanded, items],
|
||||
);
|
||||
const canToggle = items.length > INITIAL_VISIBLE_ITEMS;
|
||||
const toggleExpanded = useCallback(() => setExpanded((current) => !current), []);
|
||||
|
||||
return { visibleItems, expanded, canToggle, toggleExpanded };
|
||||
}
|
||||
Reference in New Issue
Block a user