diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx index 47b120d85..9acba693e 100644 --- a/packages/app/src/components/sidebar-workspace-list.tsx +++ b/packages/app/src/components/sidebar-workspace-list.tsx @@ -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; 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 = ( - + <> + + {canToggleWorkspaces ? ( + + ) : 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 ? ( - {pinnedCollapsed ? null : pinnedChats.map(renderPinnedChat)} + {pinnedCollapsed ? null : ( + <> + {visiblePinnedChats.map(renderPinnedChat)} + {canTogglePinnedChats ? ( + + ) : null} + + )} ) : null} {unpinnedProjects.length > 0 || hasActiveHostFilter ? listHeaderComponent : null} diff --git a/packages/app/src/components/sidebar/sidebar-group-toggle-row.tsx b/packages/app/src/components/sidebar/sidebar-group-toggle-row.tsx new file mode 100644 index 000000000..d6b0c6e04 --- /dev/null +++ b/packages/app/src/components/sidebar/sidebar-group-toggle-row.tsx @@ -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 ( + + {({ hovered, pressed }) => ( + <> + + {expanded ? ( + + ) : ( + + )} + + + {label} + + + )} + + ); +} + +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, + }, +})); diff --git a/packages/app/src/components/sidebar/sidebar-status-list.tsx b/packages/app/src/components/sidebar/sidebar-status-list.tsx index c3e6b1259..a7f0d7216 100644 --- a/packages/app/src/components/sidebar/sidebar-status-list.tsx +++ b/packages/app/src/components/sidebar/sidebar-status-list.tsx @@ -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 ? ( - {pinnedCollapsed - ? null - : pinnedWorkspaces.map((workspace) => ( + {pinnedCollapsed ? null : ( + <> + {visiblePinnedWorkspaces.map((workspace) => ( ))} + {canTogglePinnedWorkspaces ? ( + + ) : null} + + )} ) : null} {listHeaderComponent} @@ -188,38 +205,92 @@ function StatusGroupList({ return ( <> {groups.map((group) => ( - - - {!collapsedStatusGroupKeys.has(group.bucket) ? ( - - {group.rows.map((workspace) => ( - - ))} - - ) : null} - + ))} ); } +function StatusGroupRows({ + group, + collapsed, + projectNamesByKey, + shortcutIndex, + showShortcutBadges, + onWorkspacePress, + hostLabelByServerId, + showHostLabels, + supportsPinningByServerId, + onToggleWorkspacePin, +}: { + group: StatusGroup; + collapsed: boolean; + projectNamesByKey: Map; + shortcutIndex: Map; + showShortcutBadges: boolean; + onWorkspacePress?: () => void; + hostLabelByServerId: ReadonlyMap; + showHostLabels: boolean; + supportsPinningByServerId: ReadonlyMap; + onToggleWorkspacePin: ToggleSidebarWorkspacePin; +}) { + const { + visibleItems: visibleWorkspaces, + expanded: workspacesExpanded, + canToggle: canToggleWorkspaces, + toggleExpanded: toggleWorkspacesExpanded, + } = useLimitedSidebarGroup(group.rows); + + return ( + + + {!collapsed ? ( + + {visibleWorkspaces.map((workspace) => ( + + ))} + {canToggleWorkspaces ? ( + + ) : null} + + ) : null} + + ); +} + // 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({ diff --git a/packages/app/src/components/sidebar/use-limited-sidebar-group.ts b/packages/app/src/components/sidebar/use-limited-sidebar-group.ts new file mode 100644 index 000000000..5bf10ecec --- /dev/null +++ b/packages/app/src/components/sidebar/use-limited-sidebar-group.ts @@ -0,0 +1,15 @@ +import { useCallback, useMemo, useState } from "react"; + +const INITIAL_VISIBLE_ITEMS = 20; + +export function useLimitedSidebarGroup(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 }; +} diff --git a/packages/app/src/hooks/sidebar-workspaces-view-model.test.ts b/packages/app/src/hooks/sidebar-workspaces-view-model.test.ts index 78755173b..f827c0620 100644 --- a/packages/app/src/hooks/sidebar-workspaces-view-model.test.ts +++ b/packages/app/src/hooks/sidebar-workspaces-view-model.test.ts @@ -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"] }), diff --git a/packages/app/src/hooks/sidebar-workspaces-view-model.ts b/packages/app/src/hooks/sidebar-workspaces-view-model.ts index bc89a15a1..0d279f13e 100644 --- a/packages/app/src/hooks/sidebar-workspaces-view-model.ts +++ b/packages/app/src/hooks/sidebar-workspaces-view-model.ts @@ -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), }); diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index 76ab14396..b82eef681 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -926,6 +926,8 @@ export const ar: TranslationResources = { actions: { menu: "إجراءات Workspace", newWorkspace: "مساحة عمل جديدة", + showMore: "عرض المزيد", + showLess: "عرض أقل", createWorkspaceFor: "قم بإنشاء مساحة عمل جديدة لـ{{projectName}}", copyPath: "نسخ المسار", copyBranchName: "انسخ اسم الفرع", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index eed9a9eeb..cb3e413d7 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -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", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 08ad8d064..2a2187ed9 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -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", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index cbad99696..81d372679 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -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", diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index 44135239b..6be03847c 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -937,6 +937,8 @@ export const ja: TranslationResources = { actions: { menu: "ワークスペースアクション", newWorkspace: "新しいワークスペース", + showMore: "さらに表示", + showLess: "表示を減らす", createWorkspaceFor: "{{projectName}}の新しいワークスペースを作成", copyPath: "パスをコピー", copyBranchName: "ブランチ名をコピー", diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index 8189823f6..d83390d32 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -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", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 1bf835988..07453f949 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -948,6 +948,8 @@ export const ru: TranslationResources = { actions: { menu: "Действия Workspace", newWorkspace: "Новое рабочее пространство", + showMore: "Показать ещё", + showLess: "Показать меньше", createWorkspaceFor: "Создайте новое рабочее пространство для{{projectName}}.", copyPath: "Копировать путь", copyBranchName: "Скопировать название ветки", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index 3d19b56c6..e845d819f 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -916,6 +916,8 @@ export const zhCN: TranslationResources = { actions: { menu: "Workspace 操作", newWorkspace: "新建 workspace", + showMore: "显示更多", + showLess: "收起", createWorkspaceFor: "为 {{projectName}} 新建 workspace", copyPath: "复制路径", copyBranchName: "复制分支名称",