feat(app): make pinned workspaces sortable (#3341)

Reuse the existing workspace drag interaction for pinned rows and persist their local order across grouping modes. Keep pressed and dragged row scrims aligned with the active backdrop, and preserve memoized row rendering on web.
This commit is contained in:
Mohamed Boudra
2026-08-13 23:48:01 +02:00
committed by GitHub
parent 373a98c64d
commit b03dc804eb
16 changed files with 602 additions and 95 deletions
@@ -1,7 +1,23 @@
import { memo, useCallback, useMemo, useState, type ReactNode } from "react";
import {
memo,
useCallback,
useMemo,
useState,
type MutableRefObject,
type ReactNode,
type Ref,
} from "react";
import { useTranslation } from "react-i18next";
import { View, Text, Pressable, ScrollView, type PressableStateCallbackType } from "react-native";
import {
View,
Text,
Pressable,
ScrollView,
type GestureResponderEvent,
type PressableStateCallbackType,
} from "react-native";
import { NestableScrollContainer } from "react-native-draggable-flatlist";
import type { GestureType } from "react-native-gesture-handler";
import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store";
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
import { type SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
@@ -10,6 +26,7 @@ import type { HostBadgeModel } from "@/hosts/appearance";
import { isWeb as platformIsWeb, isNative as platformIsNative } from "@/constants/platform";
import { StyleSheet } from "react-native-unistyles";
import type { Theme } from "@/styles/theme";
import type { SidebarSurfaceBackdrop } from "@/styles/surface-backdrop";
import { withUnistyles } from "react-native-unistyles";
import {
ChevronDown,
@@ -59,6 +76,9 @@ 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";
import { DraggableList, type DraggableRenderItemInfo } from "@/components/draggable-list";
import type { DraggableListDragHandleProps } from "@/components/draggable-list.types";
import { useLongPressDragInteraction } from "@/components/sidebar/use-long-press-drag-interaction";
// Themed icon wrappers
const foregroundMutedColorMapping = (theme: Theme) => ({
@@ -85,6 +105,12 @@ const ThemedCircleAlert = withUnistyles(CircleAlert);
const ThemedCircleCheck = withUnistyles(CircleCheck);
const ThemedCircleDot = withUnistyles(CircleDot);
const ThemedCircleX = withUnistyles(CircleX);
const EMPTY_SHORTCUT_INDEX = new Map<string, number>();
function statusWorkspaceKeyExtractor(workspace: SidebarWorkspaceEntry): string {
return workspace.workspaceKey;
}
interface StatusWorkspaceListProps {
groups: StatusGroup[];
pinnedWorkspaces: SidebarWorkspaceEntry[];
@@ -95,7 +121,10 @@ interface StatusWorkspaceListProps {
hostBadgeByServerId: ReadonlyMap<string, HostBadgeModel>;
supportsPinningByServerId: ReadonlyMap<string, boolean>;
onToggleWorkspacePin: ToggleSidebarWorkspacePin;
onPinnedWorkspaceReorder: (workspaces: SidebarWorkspaceEntry[]) => void;
listHeaderComponent?: ReactNode;
parentGestureRef?: MutableRefObject<GestureType | undefined>;
dragGestureHostPresented?: boolean;
}
export function SidebarStatusWorkspaceList({
@@ -108,7 +137,10 @@ export function SidebarStatusWorkspaceList({
hostBadgeByServerId,
supportsPinningByServerId,
onToggleWorkspacePin,
onPinnedWorkspaceReorder,
listHeaderComponent,
parentGestureRef,
dragGestureHostPresented,
}: StatusWorkspaceListProps) {
const collapsedStatusGroupKeys = useSidebarCollapsedSectionsStore(
(state) => state.collapsedStatusGroupKeys,
@@ -124,7 +156,44 @@ export function SidebarStatusWorkspaceList({
toggleExpanded: togglePinnedWorkspacesExpanded,
} = useLimitedSidebarGroup(pinnedWorkspaces);
const statusShortcutIndex = showShortcutBadges ? shortcutIndexByWorkspaceKey : new Map();
const statusShortcutIndex = showShortcutBadges
? shortcutIndexByWorkspaceKey
: EMPTY_SHORTCUT_INDEX;
const renderPinnedWorkspace = useCallback(
({
item: workspace,
drag,
isActive,
dragHandleProps,
}: DraggableRenderItemInfo<SidebarWorkspaceEntry>) => (
<StatusWorkspaceRow
workspace={workspace}
{...buildStatusRowProjectPresentation({
workspace,
projectIconByProjectViewKey,
hostBadgeByServerId,
})}
inStatusGroup={false}
shortcutNumber={statusShortcutIndex.get(workspace.workspaceKey) ?? null}
showShortcutBadge={showShortcutBadges}
canPin={supportsPinningByServerId.get(workspace.serverId) === true}
onToggleWorkspacePin={onToggleWorkspacePin}
onWorkspacePress={onWorkspacePress}
drag={drag}
isDragging={isActive}
dragHandleProps={dragHandleProps}
/>
),
[
hostBadgeByServerId,
onToggleWorkspacePin,
onWorkspacePress,
projectIconByProjectViewKey,
showShortcutBadges,
statusShortcutIndex,
supportsPinningByServerId,
],
);
const content = (
<>
{pinnedWorkspaces.length > 0 ? (
@@ -132,23 +201,18 @@ export function SidebarStatusWorkspaceList({
<PinnedSectionHeader collapsed={pinnedCollapsed} onToggle={togglePinnedCollapsed} />
{pinnedCollapsed ? null : (
<>
{visiblePinnedWorkspaces.map((workspace) => (
<StatusWorkspaceRow
key={workspace.workspaceKey}
workspace={workspace}
{...buildStatusRowProjectPresentation({
workspace,
projectIconByProjectViewKey,
hostBadgeByServerId,
})}
inStatusGroup={false}
shortcutNumber={statusShortcutIndex.get(workspace.workspaceKey) ?? null}
showShortcutBadge={showShortcutBadges}
canPin={supportsPinningByServerId.get(workspace.serverId) === true}
onToggleWorkspacePin={onToggleWorkspacePin}
onWorkspacePress={onWorkspacePress}
/>
))}
<DraggableList
testID="sidebar-pinned-list"
data={visiblePinnedWorkspaces}
keyExtractor={statusWorkspaceKeyExtractor}
renderItem={renderPinnedWorkspace}
onDragEnd={onPinnedWorkspaceReorder}
scrollEnabled={false}
useDragHandle
nestable={platformIsNative}
simultaneousGestureRef={parentGestureRef}
gestureHostPresented={dragGestureHostPresented}
/>
{canTogglePinnedWorkspaces ? (
<SidebarGroupToggleRow
expanded={pinnedWorkspacesExpanded}
@@ -423,6 +487,9 @@ const StatusWorkspaceRow = memo(function StatusWorkspaceRow({
reserveIdleStatusIndicatorSpace = true,
inStatusGroup = true,
onWorkspacePress,
drag,
isDragging = false,
dragHandleProps,
}: {
workspace: SidebarWorkspaceEntry;
hostBadge: HostBadgeModel | null;
@@ -439,6 +506,9 @@ const StatusWorkspaceRow = memo(function StatusWorkspaceRow({
*/
inStatusGroup?: boolean;
onWorkspacePress?: () => void;
drag?: () => void;
isDragging?: boolean;
dragHandleProps?: DraggableListDragHandleProps;
}) {
const activeWorkspaceSelection = useActiveWorkspaceSelection();
const selected =
@@ -465,6 +535,9 @@ const StatusWorkspaceRow = memo(function StatusWorkspaceRow({
reserveIdleStatusIndicatorSpace={reserveIdleStatusIndicatorSpace}
inStatusGroup={inStatusGroup}
onPress={handlePress}
drag={drag}
isDragging={isDragging}
dragHandleProps={dragHandleProps}
/>
);
});
@@ -482,6 +555,9 @@ function StatusWorkspaceRowWithMenu({
reserveIdleStatusIndicatorSpace = true,
inStatusGroup = true,
onPress,
drag,
isDragging = false,
dragHandleProps,
}: {
workspace: SidebarWorkspaceEntry;
hostBadge: HostBadgeModel | null;
@@ -499,6 +575,9 @@ function StatusWorkspaceRowWithMenu({
*/
inStatusGroup?: boolean;
onPress: () => void;
drag?: () => void;
isDragging?: boolean;
dragHandleProps?: DraggableListDragHandleProps;
}) {
const { t } = useTranslation();
const toast = useToast();
@@ -620,6 +699,9 @@ function StatusWorkspaceRowWithMenu({
onTogglePin={onTogglePin}
reserveIdleStatusIndicatorSpace={reserveIdleStatusIndicatorSpace}
inStatusGroup={inStatusGroup}
drag={drag}
isDragging={isDragging}
dragHandleProps={dragHandleProps}
/>
<AdaptiveRenameModal
visible={isRenameOpen}
@@ -635,7 +717,53 @@ function StatusWorkspaceRowWithMenu({
);
}
function StatusWorkspaceRowInner({
interface StatusWorkspaceRowInnerProps {
workspace: SidebarWorkspaceEntry;
hostBadge: HostBadgeModel | null;
projectName: string;
projectIconDataUri: string | null;
selected: boolean;
shortcutNumber: number | null;
showShortcutBadge: boolean;
onPress: () => void;
isArchiving: boolean;
archiveLabel?: string;
archiveStatus?: "idle" | "pending" | "success";
archivePendingLabel?: string;
onArchive?: () => void;
onCopyBranchName?: () => void;
onCopyPath?: () => void;
onRename?: () => void;
onMarkAsRead?: () => void;
archiveShortcutKeys?: ShortcutKey[][] | null;
isPinned?: boolean;
onTogglePin?: () => void;
reserveIdleStatusIndicatorSpace?: boolean;
/** Pinned rows are flat under their own header; status-group rows indent from theirs. */
inStatusGroup?: boolean;
drag?: () => void;
isDragging?: boolean;
dragHandleProps?: DraggableListDragHandleProps;
}
function StatusWorkspaceRowInner(props: StatusWorkspaceRowInnerProps) {
if (props.drag) {
return <DraggableStatusWorkspaceRowInner {...props} drag={props.drag} />;
}
return <StatusWorkspaceRowInnerContent {...props} />;
}
function DraggableStatusWorkspaceRowInner(
props: StatusWorkspaceRowInnerProps & { drag: () => void },
) {
const dragInteraction = useLongPressDragInteraction({
drag: props.drag,
menuController: null,
});
return <StatusWorkspaceRowInnerContent {...props} dragInteraction={dragInteraction} />;
}
function StatusWorkspaceRowInnerContent({
workspace,
hostBadge,
projectName,
@@ -658,47 +786,51 @@ function StatusWorkspaceRowInner({
onTogglePin,
reserveIdleStatusIndicatorSpace = true,
inStatusGroup = true,
}: {
workspace: SidebarWorkspaceEntry;
hostBadge: HostBadgeModel | null;
projectName: string;
projectIconDataUri: string | null;
selected: boolean;
shortcutNumber: number | null;
showShortcutBadge: boolean;
onPress: () => void;
isArchiving: boolean;
archiveLabel?: string;
archiveStatus?: "idle" | "pending" | "success";
archivePendingLabel?: string;
onArchive?: () => void;
onCopyBranchName?: () => void;
onCopyPath?: () => void;
onRename?: () => void;
onMarkAsRead?: () => void;
archiveShortcutKeys?: ShortcutKey[][] | null;
isPinned?: boolean;
onTogglePin?: () => void;
reserveIdleStatusIndicatorSpace?: boolean;
/**
* Whether the row sits under a status header, which is what it indents from. Pinned rows
* are a flat list under their own header and sit flush.
*/
inStatusGroup?: boolean;
isDragging = false,
dragHandleProps,
dragInteraction,
}: StatusWorkspaceRowInnerProps & {
dragInteraction?: ReturnType<typeof useLongPressDragInteraction>;
}) {
const isTouchPlatform = platformIsNative;
const [isPressed, setIsPressed] = useState(false);
const trailing = useSidebarWorkspaceTrailing();
const {
role: _dragRole,
tabIndex: _dragTabIndex,
"aria-roledescription": _dragRoleDescription,
...dragAttributes
} = dragHandleProps?.attributes ?? {};
const isDesktop = !isTouchPlatform;
const serviceSummary = isDesktop ? selectWorkspaceServiceSummary(workspace.scripts) : null;
const accessibilityState = useMemo(() => ({ selected }), [selected]);
const handlePressIn = useCallback(() => setIsPressed(true), []);
const handlePressOut = useCallback(() => setIsPressed(false), []);
const didLongPressRef = dragInteraction?.didLongPressRef;
const startDragPress = dragInteraction?.handlePressIn;
const moveDragPress = dragInteraction?.handleTouchMove;
const endDragPress = dragInteraction?.handlePressOut;
const handlePress = useCallback(() => {
if (didLongPressRef?.current) {
didLongPressRef.current = false;
return;
}
onPress();
}, [didLongPressRef, onPress]);
const handlePressIn = useCallback(
(event: GestureResponderEvent) => {
setIsPressed(true);
startDragPress?.(event);
},
[startDragPress],
);
const handlePressOut = useCallback(() => {
setIsPressed(false);
endDragPress?.();
}, [endDragPress]);
return (
<SidebarWorkspaceRowFrame workspace={workspace}>
<SidebarWorkspaceRowFrame workspace={workspace} isDragging={isDragging}>
{({ isHovered, contextMenuOpen, onContextMenuOpenChange, hoverHandlers }) => {
const showShortcut = showShortcutBadge && shortcutNumber !== null;
const {
@@ -720,9 +852,17 @@ function StatusWorkspaceRowInner({
selected,
isHovered,
inStatusGroup,
isDragging,
});
const backdrop = getSidebarRowBackdrop({ isDragging, isPressed, selected, isHovered });
return (
<View style={styles.workspaceRowContainer} {...hoverHandlers}>
<View
{...dragAttributes}
{...dragHandleProps?.listeners}
ref={dragHandleProps?.setActivatorNodeRef as unknown as Ref<View>}
style={styles.workspaceRowContainer}
{...hoverHandlers}
>
<SidebarWorkspaceContextMenu
contextMenuOpen={contextMenuOpen}
onContextMenuOpenChange={onContextMenuOpenChange}
@@ -749,8 +889,9 @@ function StatusWorkspaceRowInner({
style={workspaceRowStyle}
highlightStyle={styles.workspaceRowPressed}
onPressIn={handlePressIn}
onTouchMove={moveDragPress}
onPressOut={handlePressOut}
onPress={onPress}
onPress={handlePress}
testID={`sidebar-workspace-row-${workspace.workspaceKey}`}
>
<SidebarWorkspaceRowContent
@@ -759,7 +900,7 @@ function StatusWorkspaceRowInner({
leadingProjectName={projectName}
leadingProjectIconDataUri={projectIconDataUri}
serviceSummary={serviceSummary}
backdrop={getSidebarRowBackdrop({ isPressed, selected, isHovered })}
backdrop={backdrop}
isHovered={isHovered}
isLoading={isArchiving}
shortcutNumber={shortcutNumber}
@@ -769,6 +910,7 @@ function StatusWorkspaceRowInner({
{renderSlot ? (
<StatusWorkspaceActionSlot
workspace={workspace}
backdrop={backdrop}
trailing={trailing}
showBase={showTrailing}
showKebab={showKebabInSlot}
@@ -798,6 +940,7 @@ function StatusWorkspaceRowInner({
function StatusWorkspaceActionSlot({
workspace,
backdrop,
trailing,
showBase,
showKebab,
@@ -816,6 +959,7 @@ function StatusWorkspaceActionSlot({
archiveShortcutKeys,
}: {
workspace: SidebarWorkspaceEntry;
backdrop: SidebarSurfaceBackdrop;
trailing: SidebarWorkspaceTrailing;
showBase: boolean;
showKebab: boolean;
@@ -839,7 +983,10 @@ function StatusWorkspaceActionSlot({
<SidebarWorkspaceTrailingActionBase visible={showBase}>
<SidebarWorkspaceTrailingContent workspace={workspace} trailing={trailing} />
</SidebarWorkspaceTrailingActionBase>
<SidebarWorkspaceTrailingActionOverlay visible={kebab.showKebab} scrim={showScrim}>
<SidebarWorkspaceTrailingActionOverlay
visible={kebab.showKebab}
scrimBackdrop={showScrim ? backdrop : undefined}
>
{kebab.showKebab && onArchive ? (
<SidebarWorkspaceMenu
{...kebab.menuProps}
@@ -867,17 +1014,20 @@ function getStatusWorkspaceRowStyle({
selected,
isHovered,
inStatusGroup,
isDragging,
}: {
isPressed: boolean;
selected: boolean;
isHovered: boolean;
inStatusGroup: boolean;
isDragging: boolean;
}) {
return [
styles.workspaceRow,
inStatusGroup && sidebarWorkspaceRowStyles.rowIndented,
selected && styles.sidebarRowSelected,
isHovered && styles.workspaceRowHovered,
isDragging && styles.workspaceRowDragging,
isPressed && styles.workspaceRowPressed,
];
}
@@ -972,6 +1122,14 @@ const styles = StyleSheet.create((theme) => ({
workspaceRowPressed: {
backgroundColor: theme.colors.surface2,
},
workspaceRowDragging: {
backgroundColor: theme.colors.surface2,
borderWidth: 1,
borderColor: theme.colors.border,
transform: [{ scale: 1.02 }],
zIndex: 3,
...theme.shadow.md,
},
sidebarRowSelected: {
backgroundColor: theme.colors.surfaceSidebarHover,
},