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:
Mohamed Boudra
2026-07-21 23:56:22 +02:00
parent 309672c8e5
commit 9603397aed
14 changed files with 328 additions and 47 deletions
@@ -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 };
}
@@ -471,7 +471,7 @@ describe("computeSidebarOrderUpdates", () => {
expect(updates).toEqual({ projectOrder: null, workspaceOrders: [] });
});
it("appends unseen projects and workspaces to the persisted orders", () => {
it("appends unseen projects while putting unseen workspaces before the saved order", () => {
const projects = [
sidebarProject({ projectKey: "project-a", workspaceKeys: ["ws-1", "ws-2"] }),
sidebarProject({ projectKey: "project-b", workspaceKeys: ["ws-3"] }),
@@ -485,11 +485,33 @@ describe("computeSidebarOrderUpdates", () => {
expect(updates.projectOrder).toEqual(["project-a", "project-b"]);
expect(updates.workspaceOrders).toEqual([
{ projectKey: "project-a", order: ["srv:ws-1", "srv:ws-2"] },
{ projectKey: "project-a", order: ["srv:ws-2", "srv:ws-1"] },
{ projectKey: "project-b", order: ["srv:ws-3"] },
]);
});
it("preserves the saved workspace order behind multiple newly discovered workspaces", () => {
const projects = [
sidebarProject({
projectKey: "project-a",
workspaceKeys: ["newest", "newer", "old-a", "old-b"],
}),
];
const updates = computeSidebarOrderUpdates({
projects,
persistedProjectOrder: ["project-a"],
getWorkspaceOrder: () => ["srv:old-b", "srv:old-a"],
});
expect(updates.workspaceOrders).toEqual([
{
projectKey: "project-a",
order: ["srv:newest", "srv:newer", "srv:old-b", "srv:old-a"],
},
]);
});
it("returns no project-order update when persisted order already covers visible keys", () => {
const projects = [
sidebarProject({ projectKey: "project-a", workspaceKeys: ["ws-1"] }),
@@ -477,6 +477,23 @@ export function appendMissingOrderKeys(input: {
return [...input.currentOrder, ...missingKeys];
}
export function prependMissingOrderKeys(input: {
currentOrder: string[];
visibleKeys: string[];
}): string[] {
if (input.visibleKeys.length === 0) {
return input.currentOrder;
}
const existingKeys = new Set(input.currentOrder);
const missingKeys = input.visibleKeys.filter((key) => !existingKeys.has(key));
if (missingKeys.length === 0) {
return input.currentOrder;
}
return [...missingKeys, ...input.currentOrder];
}
export interface SidebarOrderUpdates {
projectOrder: string[] | null;
workspaceOrders: Array<{ projectKey: string; order: string[] }>;
@@ -500,7 +517,7 @@ export function computeSidebarOrderUpdates(input: {
const workspaceOrders: Array<{ projectKey: string; order: string[] }> = [];
for (const project of input.projects) {
const persistedWorkspaceOrder = input.getWorkspaceOrder(project.projectKey);
const nextWorkspaceOrder = appendMissingOrderKeys({
const nextWorkspaceOrder = prependMissingOrderKeys({
currentOrder: persistedWorkspaceOrder,
visibleKeys: project.workspaces.map((workspace) => workspace.workspaceKey),
});
+2
View File
@@ -926,6 +926,8 @@ export const ar: TranslationResources = {
actions: {
menu: "إجراءات Workspace",
newWorkspace: "مساحة عمل جديدة",
showMore: "عرض المزيد",
showLess: "عرض أقل",
createWorkspaceFor: "قم بإنشاء مساحة عمل جديدة لـ{{projectName}}",
copyPath: "نسخ المسار",
copyBranchName: "انسخ اسم الفرع",
+2
View File
@@ -936,6 +936,8 @@ export const en = {
actions: {
menu: "Workspace actions",
newWorkspace: "New workspace",
showMore: "Show more",
showLess: "Show less",
createWorkspaceFor: "Create a new workspace for {{projectName}}",
copyPath: "Copy path",
copyBranchName: "Copy branch name",
+2
View File
@@ -957,6 +957,8 @@ export const es: TranslationResources = {
actions: {
menu: "AccionesWorkspace",
newWorkspace: "Nuevo espacio de trabajo",
showMore: "Mostrar más",
showLess: "Mostrar menos",
createWorkspaceFor: "Crea un nuevo espacio de trabajo para{{projectName}}",
copyPath: "Copiar ruta",
copyBranchName: "Copiar nombre de sucursal",
+2
View File
@@ -956,6 +956,8 @@ export const fr: TranslationResources = {
actions: {
menu: "ActionsWorkspace",
newWorkspace: "Nouvel espace de travail",
showMore: "Afficher plus",
showLess: "Afficher moins",
createWorkspaceFor: "Créer un nouvel espace de travail pour{{projectName}}",
copyPath: "Copier le chemin",
copyBranchName: "Copier le nom de la branche",
+2
View File
@@ -937,6 +937,8 @@ export const ja: TranslationResources = {
actions: {
menu: "ワークスペースアクション",
newWorkspace: "新しいワークスペース",
showMore: "さらに表示",
showLess: "表示を減らす",
createWorkspaceFor: "{{projectName}}の新しいワークスペースを作成",
copyPath: "パスをコピー",
copyBranchName: "ブランチ名をコピー",
+2
View File
@@ -948,6 +948,8 @@ export const ptBR: TranslationResources = {
actions: {
menu: "Ações do workspace",
newWorkspace: "Novo workspace",
showMore: "Mostrar mais",
showLess: "Mostrar menos",
createWorkspaceFor: "Criar um novo workspace para {{projectName}}",
copyPath: "Copiar caminho",
copyBranchName: "Copiar nome da branch",
+2
View File
@@ -948,6 +948,8 @@ export const ru: TranslationResources = {
actions: {
menu: "Действия Workspace",
newWorkspace: "Новое рабочее пространство",
showMore: "Показать ещё",
showLess: "Показать меньше",
createWorkspaceFor: "Создайте новое рабочее пространство для{{projectName}}.",
copyPath: "Копировать путь",
copyBranchName: "Скопировать название ветки",
+2
View File
@@ -916,6 +916,8 @@ export const zhCN: TranslationResources = {
actions: {
menu: "Workspace 操作",
newWorkspace: "新建 workspace",
showMore: "显示更多",
showLess: "收起",
createWorkspaceFor: "为 {{projectName}} 新建 workspace",
copyPath: "复制路径",
copyBranchName: "复制分支名称",