feat(app): pin chats to the top of the sidebar (#1981)
* feat(app): pin projects to top of the sidebar Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(app): dedicated Pinned sidebar section with UX fixes Replaces the invisible float-in-place pinning with a Codex-style Pinned section at the top of the sidebar that hoists pinned chats and projects. - Pin-aware keyboard shortcut numbering (badges match visual order) - Pinned chats show their project name as subtitle (context after hoisting) - Pinned entries ordered most-recently-pinned first - Collapsible Pinned section (persisted) - Double-click guard on workspace pin toggle Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(app): move Pinned section above the Workspaces header Pinned now sits at the top of the scroll, above the "Workspaces" section header (which moves into the scroll below it), matching the market-standard sidebar layout. Chevron sits beside the "Pinned" label. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(app): reveal Pinned collapse chevron on hover only Extracts PinnedSectionHeader with its own hover state; the chevron hides until hover on web and stays visible on touch/compact. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(app): pin the active chat via keyboard shortcut and inline pin button Adds a ⌘⇧P / Ctrl+Shift+P shortcut that pins the selected chat, mirroring the worktree.archive action across actions/dispatcher/route-shortcut, gated by `selected && supportsWorkspacePinning`. Also surfaces an inline pin button on row hover (always-on for touch) alongside the kebab in the shared trailing overlay, swapping Pin/PinOff by pinned state. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(server): unify project and workspace pin handlers Collapses the two near-identical pin.set handlers into a shared handlePinSetRequest that owns control flow, error handling and the workspace-update fan-out; each caller only supplies its registry apply step and typed response payload. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(app): drop empty pinned project shells and empty Workspaces header Pinning every chat of a project hoisted them into Pinned but left an empty project header (plus its new-workspace ghost row) below, duplicating the project. Skip projects emptied by pinning while keeping genuinely empty projects. Also hide the "Workspaces" section header when no unpinned projects remain, so it no longer floats above nothing. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(protocol): correct workspacePinning COMPAT version to v0.1.105 Feature ships in 0.1.105 per the changelog, not the placeholder v0.1.103. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(app): hide redundant chat pin control inside pinned projects A pinned project already hoists all its chats to the top, so the per-chat pin was a no-op that just toggled a dead icon. Suppress the inline pin button, the kebab pin item, and the pin keyboard shortcut on chats whose project is pinned; they still work for chats in unpinned projects. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(app): hide project pin control on empty projects Project pin state is derived from workspace descriptors, so a project with no workspaces would set pinnedAt on the server but emit no descriptor update — the pin silently never surfaced and the user got no feedback. Offer the pin only once the project has a chat, matching how chat pins are suppressed inside already-pinned projects. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(app): drop collapsed Pinned rows from keyboard shortcut numbering When the Pinned section is collapsed its chats and projects are hidden, but the shortcut model kept counting them: visible unpinned rows showed off-by-N badge numbers and pressing a number key jumped to a hidden pinned chat. Exclude the pinned rows from numbering when Pinned is collapsed, matching what renders. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(app): hydrate pinned sidebar rows * fix(app): keep filtered workspace controls reachable * feat(app): scope sidebar pinning to chats, drop project pinning Per PR review: pinning whole projects overlapped with project reordering and mixed projects into the pinned list. Keep chat pinning only. - Remove the project.pin.set RPC, its server handler, the projectPinnedAt descriptor field, and the client setProjectPinned method. - Drop the project pin control, isProjectPinned reads, and the suppressPin plumbing (its only source — a pinned project — is gone). - Fold pin-aware shortcut ordering into SidebarModelProvider (the live shortcut owner) and delete the orphaned use-sidebar-shortcut-model hook, so pinned chats and their shortcut numbers share one projection. - Prune the now-unused project pin i18n keys across all locales. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(server): inline the pin handler into the workspace path With project pinning gone, handlePinSetRequest had a single caller. Fold it back into handleWorkspacePinSetRequest and drop the project/workspace generalization. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * revert: keep main's use-sidebar-shortcut-model untouched This hook (and its test) already live on main. The scope-down had deleted them, but that's out of scope for this PR — the pin-aware shortcut ordering lives in SidebarModelProvider now, so main's hook is left exactly as it was. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(protocol): align workspacePinning COMPAT version in websocket-server Match the v0.1.105 version already corrected in messages.ts. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(app): reshape pinned sidebar updates * chore(app): remove stale pin button styles * refactor(app): simplify pinned workspace rows --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
Mohamed Boudra
parent
8cd83b3a91
commit
a1e81685a1
@@ -41,9 +41,10 @@ import {
|
||||
type SidebarWorkspaceEntry,
|
||||
} from "@/hooks/use-sidebar-workspaces-list";
|
||||
import { useSidebarModel } from "@/components/sidebar/sidebar-model";
|
||||
import type { PinnedSidebarGroups } from "@/hooks/use-sidebar-pins";
|
||||
import { RetainedPanelActivity } from "@/components/retained-panel";
|
||||
import type { StatusGroup } from "@/hooks/sidebar-status-view-model";
|
||||
import { type SidebarGroupMode } from "@/stores/sidebar-view-store";
|
||||
import { type SidebarGroupMode, useSidebarViewStore } from "@/stores/sidebar-view-store";
|
||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||
import { useHosts } from "@/runtime/host-runtime";
|
||||
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
||||
@@ -78,6 +79,7 @@ type SidebarTheme = ReturnType<typeof useUnistyles>["theme"];
|
||||
interface SidebarSharedProps {
|
||||
theme: SidebarTheme;
|
||||
statusGroups: StatusGroup[];
|
||||
pinnedGroups: PinnedSidebarGroups;
|
||||
projects: SidebarProjectEntry[];
|
||||
workspaceEntriesByKey: ReadonlyMap<string, SidebarWorkspaceEntry>;
|
||||
projectNamesByKey: Map<string, string>;
|
||||
@@ -143,6 +145,7 @@ export const LeftSidebar = memo(function LeftSidebar() {
|
||||
isRevalidating,
|
||||
refreshAll,
|
||||
statusGroups,
|
||||
pinnedGroups,
|
||||
collapsedProjectKeys,
|
||||
toggleProjectCollapsed,
|
||||
groupMode,
|
||||
@@ -240,6 +243,7 @@ export const LeftSidebar = memo(function LeftSidebar() {
|
||||
const sharedProps = {
|
||||
theme,
|
||||
statusGroups,
|
||||
pinnedGroups,
|
||||
projects,
|
||||
workspaceEntriesByKey,
|
||||
projectNamesByKey,
|
||||
@@ -536,6 +540,7 @@ function SidebarFooter({
|
||||
function MobileSidebar({
|
||||
theme,
|
||||
statusGroups,
|
||||
pinnedGroups,
|
||||
projects,
|
||||
workspaceEntriesByKey,
|
||||
projectNamesByKey,
|
||||
@@ -561,6 +566,7 @@ function MobileSidebar({
|
||||
handleViewSchedulesNavigate,
|
||||
}: MobileSidebarProps) {
|
||||
const pathname = usePathname();
|
||||
const hasActiveHostFilter = useSidebarViewStore((state) => state.hostFilters.length > 0);
|
||||
const isSessionsActive = pathname.includes("/sessions");
|
||||
const isSchedulesActive = pathname.includes("/schedules");
|
||||
const { gesture: closeGesture, gestureRef: closeGestureRef } = useCloseAgentListGesture();
|
||||
@@ -620,7 +626,6 @@ function MobileSidebar({
|
||||
variant="compact"
|
||||
/>
|
||||
</View>
|
||||
<WorkspacesSectionHeader />
|
||||
<Pressable
|
||||
style={styles.mobileCloseButton}
|
||||
onPress={closeSidebar}
|
||||
@@ -639,7 +644,7 @@ function MobileSidebar({
|
||||
)}
|
||||
</Pressable>
|
||||
|
||||
{isInitialLoad ? (
|
||||
{isInitialLoad && !hasActiveHostFilter ? (
|
||||
<SidebarAgentListSkeleton />
|
||||
) : (
|
||||
<SidebarWorkspaceList
|
||||
@@ -648,6 +653,7 @@ function MobileSidebar({
|
||||
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
|
||||
groupMode={groupMode}
|
||||
statusGroups={statusGroups}
|
||||
pinnedGroups={pinnedGroups}
|
||||
projects={projects}
|
||||
workspaceEntriesByKey={workspaceEntriesByKey}
|
||||
projectNamesByKey={projectNamesByKey}
|
||||
@@ -656,6 +662,7 @@ function MobileSidebar({
|
||||
onWorkspacePress={handleWorkspacePress}
|
||||
onAddProject={handleOpenProject}
|
||||
parentGestureRef={closeGestureRef}
|
||||
listHeaderComponent={workspacesSectionHeaderElement}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -676,6 +683,7 @@ function MobileSidebar({
|
||||
function DesktopSidebar({
|
||||
theme,
|
||||
statusGroups,
|
||||
pinnedGroups,
|
||||
projects,
|
||||
workspaceEntriesByKey,
|
||||
projectNamesByKey,
|
||||
@@ -700,6 +708,7 @@ function DesktopSidebar({
|
||||
handleViewSchedules,
|
||||
}: DesktopSidebarProps) {
|
||||
const pathname = usePathname();
|
||||
const hasActiveHostFilter = useSidebarViewStore((state) => state.hostFilters.length > 0);
|
||||
const isSessionsActive = pathname.includes("/sessions");
|
||||
const isSchedulesActive = pathname.includes("/schedules");
|
||||
const padding = useWindowControlsPadding("sidebar");
|
||||
@@ -791,9 +800,8 @@ function DesktopSidebar({
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
<WorkspacesSectionHeader />
|
||||
|
||||
{isInitialLoad ? (
|
||||
{isInitialLoad && !hasActiveHostFilter ? (
|
||||
<SidebarAgentListSkeleton />
|
||||
) : (
|
||||
<SidebarWorkspaceList
|
||||
@@ -802,12 +810,14 @@ function DesktopSidebar({
|
||||
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
|
||||
groupMode={groupMode}
|
||||
statusGroups={statusGroups}
|
||||
pinnedGroups={pinnedGroups}
|
||||
projects={projects}
|
||||
workspaceEntriesByKey={workspaceEntriesByKey}
|
||||
projectNamesByKey={projectNamesByKey}
|
||||
isRefreshing={isManualRefresh && isRevalidating}
|
||||
onRefresh={handleRefresh}
|
||||
onAddProject={handleOpenProject}
|
||||
listHeaderComponent={workspacesSectionHeaderElement}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -887,6 +897,10 @@ function WorkspacesSectionHeader() {
|
||||
);
|
||||
}
|
||||
|
||||
// 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.
|
||||
@@ -912,15 +926,11 @@ const styles = StyleSheet.create((theme) => ({
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: theme.spacing[2],
|
||||
// Align the title with the compact rows' icons and the project icons below
|
||||
// (listContent + projectRow inner padding both spacing[2]).
|
||||
paddingLeft: theme.spacing[2] + theme.spacing[2],
|
||||
// Align the trailing action pill's right edge with the New workspace and
|
||||
// project row pills (both 8px from the sidebar edge).
|
||||
paddingRight: theme.spacing[2],
|
||||
// Less than sidebarHeaderGroup's paddingBottom: the 28px-tall action buttons
|
||||
// center the title and add their own offset above it, so equal padding reads
|
||||
// as a larger gap than History's. Trim paddingTop to balance it visually.
|
||||
// Rendered inside the scroll's listContent (paddingHorizontal spacing[2]), so the
|
||||
// title lands at spacing[2] left to align with project icons, and the trailing
|
||||
// pill sits flush with the list edge on the right.
|
||||
paddingLeft: theme.spacing[2],
|
||||
paddingRight: 0,
|
||||
paddingTop: theme.spacing[1],
|
||||
paddingBottom: theme.spacing[1],
|
||||
},
|
||||
|
||||
@@ -52,6 +52,12 @@ import { NestableScrollContainer } from "react-native-draggable-flatlist";
|
||||
import { DraggableList, type DraggableRenderItemInfo } from "./draggable-list";
|
||||
import type { DraggableListDragHandleProps } from "./draggable-list.types";
|
||||
import { getHostRuntimeStore, useHosts } from "@/runtime/host-runtime";
|
||||
import type { PinnedSidebarGroups } from "@/hooks/use-sidebar-pins";
|
||||
import {
|
||||
useSidebarWorkspacePinController,
|
||||
type ToggleSidebarWorkspacePin,
|
||||
} from "@/hooks/use-sidebar-workspace-pin";
|
||||
import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store";
|
||||
import { useHostFeatureMap } from "@/runtime/host-features";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { useProjectIconDataByProjectKey } from "@/projects/project-icons";
|
||||
@@ -67,6 +73,7 @@ import {
|
||||
type SidebarWorkspacePlacement,
|
||||
} from "@/hooks/use-sidebar-workspaces-list";
|
||||
import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
|
||||
import { useSidebarViewStore } from "@/stores/sidebar-view-store";
|
||||
import { useShowShortcutBadges } from "@/hooks/use-show-shortcut-badges";
|
||||
import { ContextMenuTrigger, useContextMenu } from "@/components/ui/context-menu";
|
||||
import {
|
||||
@@ -88,6 +95,7 @@ import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
|
||||
import { SidebarStatusWorkspaceList } from "@/components/sidebar/sidebar-status-list";
|
||||
import type { StatusGroup } from "@/hooks/sidebar-status-view-model";
|
||||
import { SidebarWorkspaceMenu } from "@/components/sidebar/sidebar-workspace-menu";
|
||||
import { PinnedSectionHeader } from "@/components/sidebar/pinned-section-header";
|
||||
import {
|
||||
SidebarWorkspaceRowFrame,
|
||||
SidebarWorkspaceRowContent,
|
||||
@@ -220,6 +228,7 @@ function selectionForSelectedWorkspace(
|
||||
|
||||
interface SidebarWorkspaceListProps {
|
||||
statusGroups: StatusGroup[];
|
||||
pinnedGroups: PinnedSidebarGroups;
|
||||
projects: SidebarProjectEntry[];
|
||||
workspaceEntriesByKey: ReadonlyMap<string, SidebarWorkspaceEntry>;
|
||||
projectNamesByKey: Map<string, string>;
|
||||
@@ -232,6 +241,9 @@ interface SidebarWorkspaceListProps {
|
||||
onWorkspacePress?: () => void;
|
||||
onAddProject?: () => void;
|
||||
listFooterComponent?: ReactElement | null;
|
||||
// Rendered inside the scroll area, below the Pinned section and above the workspace
|
||||
// list. Holds the "Workspaces" section header so pinned items sit above it.
|
||||
listHeaderComponent?: ReactElement | null;
|
||||
/** Gesture ref for coordinating with parent gestures (e.g., sidebar close) */
|
||||
parentGestureRef?: MutableRefObject<GestureType | undefined>;
|
||||
}
|
||||
@@ -281,6 +293,9 @@ interface WorkspaceRowInnerProps {
|
||||
onRename?: () => void;
|
||||
onMarkAsRead?: () => void;
|
||||
archiveShortcutKeys?: ShortcutKey[][] | null;
|
||||
isPinned?: boolean;
|
||||
onTogglePin?: () => void;
|
||||
reserveIdleStatusIndicatorSpace?: boolean;
|
||||
}
|
||||
|
||||
export function PrBadge({ hint }: { hint: PrHint }) {
|
||||
@@ -621,6 +636,8 @@ function WorkspaceRowRightGroup({
|
||||
onCopyBranchName,
|
||||
onCopyPath,
|
||||
onRename,
|
||||
isPinned,
|
||||
onTogglePin,
|
||||
}: {
|
||||
workspace: SidebarWorkspaceEntry;
|
||||
isHovered: boolean;
|
||||
@@ -637,6 +654,8 @@ function WorkspaceRowRightGroup({
|
||||
onCopyBranchName?: () => void;
|
||||
onCopyPath?: () => void;
|
||||
onRename?: () => void;
|
||||
isPinned?: boolean;
|
||||
onTogglePin?: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const showShortcut = showShortcutBadge && shortcutNumber !== null;
|
||||
@@ -674,6 +693,8 @@ function WorkspaceRowRightGroup({
|
||||
archiveStatus={archiveStatus}
|
||||
archivePendingLabel={archivePendingLabel}
|
||||
archiveShortcutKeys={archiveShortcutKeys}
|
||||
isPinned={isPinned}
|
||||
onTogglePin={onTogglePin}
|
||||
/>
|
||||
) : null}
|
||||
</SidebarWorkspaceTrailingActionOverlay>
|
||||
@@ -1318,6 +1339,9 @@ function WorkspaceRowInner({
|
||||
onCopyPath,
|
||||
onRename,
|
||||
archiveShortcutKeys,
|
||||
isPinned,
|
||||
onTogglePin,
|
||||
reserveIdleStatusIndicatorSpace = true,
|
||||
}: WorkspaceRowInnerProps) {
|
||||
const _isCompact = useIsCompactFormFactor();
|
||||
const isTouchPlatform = platformIsNative;
|
||||
@@ -1388,6 +1412,7 @@ function WorkspaceRowInner({
|
||||
isCreating={isCreating}
|
||||
shortcutNumber={shortcutNumber}
|
||||
showShortcutBadge={showShortcutBadge}
|
||||
reserveIdleStatusIndicatorSpace={reserveIdleStatusIndicatorSpace}
|
||||
>
|
||||
<WorkspaceRowRightGroup
|
||||
workspace={workspace}
|
||||
@@ -1404,6 +1429,8 @@ function WorkspaceRowInner({
|
||||
onCopyBranchName={onCopyBranchName}
|
||||
onCopyPath={onCopyPath}
|
||||
onRename={onRename}
|
||||
isPinned={isPinned}
|
||||
onTogglePin={onTogglePin}
|
||||
/>
|
||||
</SidebarWorkspaceRowContent>
|
||||
</Pressable>
|
||||
@@ -1425,6 +1452,9 @@ function WorkspaceRowWithMenu({
|
||||
isDragging,
|
||||
dragHandleProps,
|
||||
canCopyBranchName,
|
||||
canPin,
|
||||
onToggleWorkspacePin,
|
||||
reserveIdleStatusIndicatorSpace = true,
|
||||
isCreating = false,
|
||||
}: {
|
||||
workspace: SidebarWorkspaceEntry;
|
||||
@@ -1437,6 +1467,9 @@ function WorkspaceRowWithMenu({
|
||||
isDragging: boolean;
|
||||
dragHandleProps?: DraggableListDragHandleProps;
|
||||
canCopyBranchName: boolean;
|
||||
canPin: boolean;
|
||||
onToggleWorkspacePin: ToggleSidebarWorkspacePin;
|
||||
reserveIdleStatusIndicatorSpace?: boolean;
|
||||
isCreating?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
@@ -1521,6 +1554,12 @@ function WorkspaceRowWithMenu({
|
||||
[renameMutation],
|
||||
);
|
||||
|
||||
const isPinned = workspace.pinnedAt != null;
|
||||
const handleTogglePin = useCallback(() => {
|
||||
onToggleWorkspacePin(workspace);
|
||||
}, [onToggleWorkspacePin, workspace]);
|
||||
const onTogglePin = canPin ? handleTogglePin : undefined;
|
||||
|
||||
const archiveShortcutKeys = useShortcutKeys("archive-workspace");
|
||||
const { hasClearableAttention, clearAttention } = useClearWorkspaceAttention({
|
||||
serverId: workspace.serverId,
|
||||
@@ -1543,6 +1582,17 @@ function WorkspaceRowWithMenu({
|
||||
},
|
||||
});
|
||||
|
||||
useKeyboardActionHandler({
|
||||
handlerId: `workspace-pin-${workspace.workspaceKey}`,
|
||||
actions: ["workspace.pin"],
|
||||
enabled: selected && canPin,
|
||||
priority: 0,
|
||||
handle: () => {
|
||||
onTogglePin?.();
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<WorkspaceRowInner
|
||||
@@ -1567,6 +1617,9 @@ function WorkspaceRowWithMenu({
|
||||
onRename={handleOpenRename}
|
||||
onMarkAsRead={hasClearableAttention ? handleMarkAsRead : undefined}
|
||||
archiveShortcutKeys={selected ? archiveShortcutKeys : null}
|
||||
isPinned={isPinned}
|
||||
onTogglePin={onTogglePin}
|
||||
reserveIdleStatusIndicatorSpace={reserveIdleStatusIndicatorSpace}
|
||||
/>
|
||||
<AdaptiveRenameModal
|
||||
visible={isRenameOpen}
|
||||
@@ -1589,6 +1642,9 @@ interface WorkspaceRowItemProps {
|
||||
shortcutNumber: number | null;
|
||||
showShortcutBadge: boolean;
|
||||
canCopyBranchName: boolean;
|
||||
canPin: boolean;
|
||||
onToggleWorkspacePin: ToggleSidebarWorkspacePin;
|
||||
reserveIdleStatusIndicatorSpace?: boolean;
|
||||
isCreating?: boolean;
|
||||
selectionEnabled: boolean;
|
||||
activeWorkspaceSelection: ActiveWorkspaceSelection | null;
|
||||
@@ -1605,6 +1661,9 @@ function WorkspaceRowItem({
|
||||
shortcutNumber,
|
||||
showShortcutBadge,
|
||||
canCopyBranchName,
|
||||
canPin,
|
||||
onToggleWorkspacePin,
|
||||
reserveIdleStatusIndicatorSpace = true,
|
||||
isCreating = false,
|
||||
selectionEnabled,
|
||||
activeWorkspaceSelection,
|
||||
@@ -1628,6 +1687,9 @@ function WorkspaceRowItem({
|
||||
shortcutNumber={shortcutNumber}
|
||||
showShortcutBadge={showShortcutBadge}
|
||||
canCopyBranchName={canCopyBranchName}
|
||||
canPin={canPin}
|
||||
onToggleWorkspacePin={onToggleWorkspacePin}
|
||||
reserveIdleStatusIndicatorSpace={reserveIdleStatusIndicatorSpace}
|
||||
isCreating={isCreating}
|
||||
selected={isWorkspaceSelected({
|
||||
selection: activeWorkspaceSelection,
|
||||
@@ -1666,6 +1728,9 @@ function areWorkspaceRowItemPropsEqual(
|
||||
previous.shortcutNumber === next.shortcutNumber &&
|
||||
previous.showShortcutBadge === next.showShortcutBadge &&
|
||||
previous.canCopyBranchName === next.canCopyBranchName &&
|
||||
previous.canPin === next.canPin &&
|
||||
previous.onToggleWorkspacePin === next.onToggleWorkspacePin &&
|
||||
previous.reserveIdleStatusIndicatorSpace === next.reserveIdleStatusIndicatorSpace &&
|
||||
previous.isCreating === next.isCreating &&
|
||||
previous.onWorkspacePress === next.onWorkspacePress &&
|
||||
previous.drag === next.drag &&
|
||||
@@ -1687,6 +1752,9 @@ function WorkspaceRow({
|
||||
isDragging,
|
||||
dragHandleProps,
|
||||
canCopyBranchName,
|
||||
canPin,
|
||||
onToggleWorkspacePin,
|
||||
reserveIdleStatusIndicatorSpace = true,
|
||||
isCreating = false,
|
||||
selected,
|
||||
}: {
|
||||
@@ -1699,6 +1767,9 @@ function WorkspaceRow({
|
||||
isDragging: boolean;
|
||||
dragHandleProps?: DraggableListDragHandleProps;
|
||||
canCopyBranchName: boolean;
|
||||
canPin: boolean;
|
||||
onToggleWorkspacePin: ToggleSidebarWorkspacePin;
|
||||
reserveIdleStatusIndicatorSpace?: boolean;
|
||||
isCreating?: boolean;
|
||||
selected: boolean;
|
||||
}) {
|
||||
@@ -1718,6 +1789,9 @@ function WorkspaceRow({
|
||||
isDragging={isDragging}
|
||||
dragHandleProps={dragHandleProps}
|
||||
canCopyBranchName={canCopyBranchName}
|
||||
canPin={canPin}
|
||||
onToggleWorkspacePin={onToggleWorkspacePin}
|
||||
reserveIdleStatusIndicatorSpace={reserveIdleStatusIndicatorSpace}
|
||||
isCreating={isCreating}
|
||||
/>
|
||||
);
|
||||
@@ -1746,6 +1820,8 @@ function ProjectBlock({
|
||||
hostLabelByServerId,
|
||||
showHostLabels,
|
||||
supportsMultiplicityByServerId,
|
||||
supportsPinningByServerId,
|
||||
onToggleWorkspacePin,
|
||||
}: {
|
||||
project: SidebarProjectEntry;
|
||||
workspaceEntriesByKey: ReadonlyMap<string, SidebarWorkspaceEntry>;
|
||||
@@ -1769,6 +1845,8 @@ function ProjectBlock({
|
||||
hostLabelByServerId: ReadonlyMap<string, string>;
|
||||
showHostLabels: boolean;
|
||||
supportsMultiplicityByServerId: ReadonlyMap<string, boolean>;
|
||||
supportsPinningByServerId: ReadonlyMap<string, boolean>;
|
||||
onToggleWorkspacePin: ToggleSidebarWorkspacePin;
|
||||
}) {
|
||||
const rowModel = useMemo(
|
||||
() =>
|
||||
@@ -1805,6 +1883,8 @@ function ProjectBlock({
|
||||
shortcutNumber={shortcutIndexByWorkspaceKey.get(item.workspaceKey) ?? null}
|
||||
showShortcutBadge={showShortcutBadges}
|
||||
canCopyBranchName={project.projectKind === "git"}
|
||||
canPin={supportsPinningByServerId.get(item.serverId) === true}
|
||||
onToggleWorkspacePin={onToggleWorkspacePin}
|
||||
isCreating={creatingWorkspaceIds.has(item.workspaceId)}
|
||||
selectionEnabled={selectionEnabled}
|
||||
activeWorkspaceSelection={activeWorkspaceSelection}
|
||||
@@ -1817,6 +1897,8 @@ function ProjectBlock({
|
||||
},
|
||||
[
|
||||
project.projectKind,
|
||||
onToggleWorkspacePin,
|
||||
supportsPinningByServerId,
|
||||
showHostLabels,
|
||||
activeWorkspaceSelection,
|
||||
creatingWorkspaceIds,
|
||||
@@ -1990,6 +2072,8 @@ function areProjectBlockPropsEqual(previous: ProjectBlockProps, next: ProjectBlo
|
||||
previous.hostLabelByServerId === next.hostLabelByServerId &&
|
||||
previous.showHostLabels === next.showHostLabels &&
|
||||
previous.supportsMultiplicityByServerId === next.supportsMultiplicityByServerId &&
|
||||
previous.supportsPinningByServerId === next.supportsPinningByServerId &&
|
||||
previous.onToggleWorkspacePin === next.onToggleWorkspacePin &&
|
||||
previous.parentGestureRef === next.parentGestureRef &&
|
||||
previous.onToggleCollapsed === next.onToggleCollapsed &&
|
||||
previous.onWorkspacePress === next.onWorkspacePress &&
|
||||
@@ -2034,6 +2118,7 @@ const MemoProjectBlock = memo(ProjectBlock, areProjectBlockPropsEqual);
|
||||
|
||||
export function SidebarWorkspaceList({
|
||||
statusGroups,
|
||||
pinnedGroups,
|
||||
projects,
|
||||
workspaceEntriesByKey,
|
||||
projectNamesByKey,
|
||||
@@ -2046,6 +2131,7 @@ export function SidebarWorkspaceList({
|
||||
onWorkspacePress,
|
||||
onAddProject,
|
||||
listFooterComponent,
|
||||
listHeaderComponent,
|
||||
parentGestureRef,
|
||||
}: SidebarWorkspaceListProps) {
|
||||
const pathname = usePathname();
|
||||
@@ -2059,21 +2145,29 @@ export function SidebarWorkspaceList({
|
||||
}, [hosts]);
|
||||
const serverIds = useMemo(() => hosts.map((host) => host.serverId), [hosts]);
|
||||
const supportsMultiplicityByServerId = useHostFeatureMap(serverIds, "workspaceMultiplicity");
|
||||
const supportsPinningByServerId = useHostFeatureMap(serverIds, "workspacePinning");
|
||||
const onToggleWorkspacePin = useSidebarWorkspacePinController();
|
||||
const showHostLabels = useMemo(() => shouldShowSidebarHostLabels(projects), [projects]);
|
||||
|
||||
const content =
|
||||
groupMode === "status" ? (
|
||||
<SidebarStatusModeWrapper
|
||||
statusGroups={statusGroups}
|
||||
pinnedGroups={pinnedGroups}
|
||||
workspaceEntriesByKey={workspaceEntriesByKey}
|
||||
projectNamesByKey={projectNamesByKey}
|
||||
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
|
||||
onWorkspacePress={onWorkspacePress}
|
||||
hostLabelByServerId={hostLabelByServerId}
|
||||
showHostLabels={showHostLabels}
|
||||
supportsPinningByServerId={supportsPinningByServerId}
|
||||
onToggleWorkspacePin={onToggleWorkspacePin}
|
||||
listHeaderComponent={listHeaderComponent}
|
||||
/>
|
||||
) : (
|
||||
<ProjectModeList
|
||||
projects={projects}
|
||||
pinnedGroups={pinnedGroups}
|
||||
workspaceEntriesByKey={workspaceEntriesByKey}
|
||||
collapsedProjectKeys={collapsedProjectKeys}
|
||||
onToggleProjectCollapsed={onToggleProjectCollapsed}
|
||||
@@ -2081,11 +2175,14 @@ export function SidebarWorkspaceList({
|
||||
onWorkspacePress={onWorkspacePress}
|
||||
onAddProject={onAddProject}
|
||||
listFooterComponent={listFooterComponent}
|
||||
listHeaderComponent={listHeaderComponent}
|
||||
parentGestureRef={parentGestureRef}
|
||||
pathname={pathname}
|
||||
hostLabelByServerId={hostLabelByServerId}
|
||||
showHostLabels={showHostLabels}
|
||||
supportsMultiplicityByServerId={supportsMultiplicityByServerId}
|
||||
supportsPinningByServerId={supportsPinningByServerId}
|
||||
onToggleWorkspacePin={onToggleWorkspacePin}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -2094,36 +2191,54 @@ export function SidebarWorkspaceList({
|
||||
|
||||
function SidebarStatusModeWrapper({
|
||||
statusGroups,
|
||||
pinnedGroups,
|
||||
workspaceEntriesByKey,
|
||||
projectNamesByKey,
|
||||
shortcutIndexByWorkspaceKey: _projectShortcutIndex,
|
||||
onWorkspacePress,
|
||||
hostLabelByServerId,
|
||||
showHostLabels,
|
||||
supportsPinningByServerId,
|
||||
onToggleWorkspacePin,
|
||||
listHeaderComponent,
|
||||
}: {
|
||||
statusGroups: StatusGroup[];
|
||||
pinnedGroups: PinnedSidebarGroups;
|
||||
workspaceEntriesByKey: ReadonlyMap<string, SidebarWorkspaceEntry>;
|
||||
projectNamesByKey: Map<string, string>;
|
||||
shortcutIndexByWorkspaceKey: Map<string, number>;
|
||||
onWorkspacePress?: () => void;
|
||||
hostLabelByServerId: ReadonlyMap<string, string>;
|
||||
showHostLabels: boolean;
|
||||
supportsPinningByServerId: ReadonlyMap<string, boolean>;
|
||||
onToggleWorkspacePin: ToggleSidebarWorkspacePin;
|
||||
listHeaderComponent?: ReactElement | null;
|
||||
}) {
|
||||
const showShortcutBadges = useShowShortcutBadges();
|
||||
|
||||
return (
|
||||
<SidebarStatusWorkspaceList
|
||||
groups={statusGroups}
|
||||
pinnedWorkspaces={pinnedGroups.pinnedChats.flatMap((workspace) => {
|
||||
const entry = workspaceEntriesByKey.get(workspace.workspaceKey);
|
||||
return entry ? [entry] : [];
|
||||
})}
|
||||
projectNamesByKey={projectNamesByKey}
|
||||
shortcutIndexByWorkspaceKey={_projectShortcutIndex}
|
||||
showShortcutBadges={showShortcutBadges}
|
||||
onWorkspacePress={onWorkspacePress}
|
||||
hostLabelByServerId={hostLabelByServerId}
|
||||
showHostLabels={showHostLabels}
|
||||
supportsPinningByServerId={supportsPinningByServerId}
|
||||
onToggleWorkspacePin={onToggleWorkspacePin}
|
||||
listHeaderComponent={listHeaderComponent}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ProjectModeList({
|
||||
projects,
|
||||
pinnedGroups,
|
||||
workspaceEntriesByKey,
|
||||
collapsedProjectKeys,
|
||||
onToggleProjectCollapsed,
|
||||
@@ -2131,11 +2246,14 @@ function ProjectModeList({
|
||||
onWorkspacePress,
|
||||
onAddProject,
|
||||
listFooterComponent,
|
||||
listHeaderComponent,
|
||||
parentGestureRef,
|
||||
pathname,
|
||||
hostLabelByServerId,
|
||||
showHostLabels,
|
||||
supportsMultiplicityByServerId,
|
||||
supportsPinningByServerId,
|
||||
onToggleWorkspacePin,
|
||||
}: Omit<
|
||||
SidebarWorkspaceListProps,
|
||||
"statusGroups" | "projectNamesByKey" | "groupMode" | "isRefreshing" | "onRefresh"
|
||||
@@ -2144,13 +2262,20 @@ function ProjectModeList({
|
||||
hostLabelByServerId: ReadonlyMap<string, string>;
|
||||
showHostLabels: boolean;
|
||||
supportsMultiplicityByServerId: ReadonlyMap<string, boolean>;
|
||||
supportsPinningByServerId: ReadonlyMap<string, boolean>;
|
||||
onToggleWorkspacePin: ToggleSidebarWorkspacePin;
|
||||
}) {
|
||||
const hasActiveHostFilter = useSidebarViewStore((state) => state.hostFilters.length > 0);
|
||||
const { t } = useTranslation();
|
||||
const [creatingWorkspaceIds, setCreatingWorkspaceIds] = useState<Set<string>>(() => new Set());
|
||||
const creatingWorkspaceTimeoutsRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(
|
||||
new Map(),
|
||||
);
|
||||
const showShortcutBadges = useShowShortcutBadges();
|
||||
const pinnedCollapsed = useSidebarCollapsedSectionsStore((state) => state.collapsedPinned);
|
||||
const togglePinnedCollapsed = useSidebarCollapsedSectionsStore(
|
||||
(state) => state.togglePinnedCollapsed,
|
||||
);
|
||||
|
||||
const getProjectOrder = useSidebarOrderStore((state) => state.getProjectOrder);
|
||||
const setProjectOrder = useSidebarOrderStore((state) => state.setProjectOrder);
|
||||
@@ -2163,6 +2288,7 @@ function ProjectModeList({
|
||||
);
|
||||
const selectionEnabled = isWorkspaceRoute;
|
||||
const activeWorkspaceSelection = useActiveWorkspaceSelection();
|
||||
const { pinnedChats, unpinnedProjects } = pinnedGroups;
|
||||
const projectIconTargets = useMemo(
|
||||
() =>
|
||||
projects.flatMap((project) => {
|
||||
@@ -2308,10 +2434,18 @@ function ProjectModeList({
|
||||
);
|
||||
}, []);
|
||||
|
||||
const renderProject = useCallback(
|
||||
({ item, drag, isActive, dragHandleProps }: DraggableRenderItemInfo<SidebarProjectEntry>) => {
|
||||
const renderProjectBlock = useCallback(
|
||||
(
|
||||
item: SidebarProjectEntry,
|
||||
dragState: {
|
||||
drag: () => void;
|
||||
isDragging: boolean;
|
||||
dragHandleProps?: DraggableRenderItemInfo<SidebarProjectEntry>["dragHandleProps"];
|
||||
},
|
||||
) => {
|
||||
return (
|
||||
<MemoProjectBlock
|
||||
key={item.projectKey}
|
||||
project={item}
|
||||
workspaceEntriesByKey={workspaceEntriesByKey}
|
||||
collapsed={collapsedProjectKeys.has(item.projectKey)}
|
||||
@@ -2325,15 +2459,17 @@ function ProjectModeList({
|
||||
onWorkspacePress={onWorkspacePress}
|
||||
onWorkspaceReorder={handleWorkspaceReorder}
|
||||
onWorktreeCreated={handleWorktreeCreated}
|
||||
drag={drag}
|
||||
isDragging={isActive}
|
||||
dragHandleProps={dragHandleProps}
|
||||
drag={dragState.drag}
|
||||
isDragging={dragState.isDragging}
|
||||
dragHandleProps={dragState.dragHandleProps}
|
||||
useNestable={platformIsNative}
|
||||
creatingWorkspaceIds={creatingWorkspaceIds}
|
||||
activeWorkspaceSelection={activeWorkspaceSelection}
|
||||
hostLabelByServerId={hostLabelByServerId}
|
||||
showHostLabels={showHostLabels}
|
||||
supportsMultiplicityByServerId={supportsMultiplicityByServerId}
|
||||
supportsPinningByServerId={supportsPinningByServerId}
|
||||
onToggleWorkspacePin={onToggleWorkspacePin}
|
||||
/>
|
||||
);
|
||||
},
|
||||
@@ -2345,6 +2481,8 @@ function ProjectModeList({
|
||||
hostLabelByServerId,
|
||||
showHostLabels,
|
||||
supportsMultiplicityByServerId,
|
||||
supportsPinningByServerId,
|
||||
onToggleWorkspacePin,
|
||||
onWorkspacePress,
|
||||
onToggleProjectCollapsed,
|
||||
parentGestureRef,
|
||||
@@ -2357,8 +2495,62 @@ function ProjectModeList({
|
||||
],
|
||||
);
|
||||
|
||||
const renderProject = useCallback(
|
||||
({ item, drag, isActive, dragHandleProps }: DraggableRenderItemInfo<SidebarProjectEntry>) =>
|
||||
renderProjectBlock(item, { drag, isDragging: isActive, dragHandleProps }),
|
||||
[renderProjectBlock],
|
||||
);
|
||||
|
||||
const renderPinnedChat = useCallback(
|
||||
(workspace: SidebarWorkspacePlacement) => {
|
||||
// A hoisted chat loses its project context, so surface the project name (plus
|
||||
// host when the sidebar spans multiple hosts) as the subtitle.
|
||||
const hostLabel = showHostLabels
|
||||
? (hostLabelByServerId.get(workspace.serverId) ?? workspace.serverId)
|
||||
: null;
|
||||
return (
|
||||
<MemoWorkspaceRowItem
|
||||
key={workspace.workspaceKey}
|
||||
workspace={workspace}
|
||||
workspaceEntry={workspaceEntriesByKey.get(workspace.workspaceKey) ?? null}
|
||||
subtitle={hostLabel ? `${workspace.projectName} · ${hostLabel}` : workspace.projectName}
|
||||
shortcutNumber={shortcutIndexByWorkspaceKey.get(workspace.workspaceKey) ?? null}
|
||||
showShortcutBadge={showShortcutBadges}
|
||||
canCopyBranchName={workspace.projectKind === "git"}
|
||||
canPin={supportsPinningByServerId.get(workspace.serverId) === true}
|
||||
onToggleWorkspacePin={onToggleWorkspacePin}
|
||||
reserveIdleStatusIndicatorSpace={false}
|
||||
isCreating={creatingWorkspaceIds.has(workspace.workspaceId)}
|
||||
selectionEnabled={selectionEnabled}
|
||||
activeWorkspaceSelection={activeWorkspaceSelection}
|
||||
onWorkspacePress={onWorkspacePress}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[
|
||||
activeWorkspaceSelection,
|
||||
creatingWorkspaceIds,
|
||||
hostLabelByServerId,
|
||||
onWorkspacePress,
|
||||
selectionEnabled,
|
||||
shortcutIndexByWorkspaceKey,
|
||||
showHostLabels,
|
||||
showShortcutBadges,
|
||||
supportsPinningByServerId,
|
||||
onToggleWorkspacePin,
|
||||
workspaceEntriesByKey,
|
||||
],
|
||||
);
|
||||
|
||||
const content = (
|
||||
<>
|
||||
{pinnedChats.length > 0 ? (
|
||||
<View style={styles.pinnedSection} testID="sidebar-pinned-section">
|
||||
<PinnedSectionHeader collapsed={pinnedCollapsed} onToggle={togglePinnedCollapsed} />
|
||||
{pinnedCollapsed ? null : pinnedChats.map(renderPinnedChat)}
|
||||
</View>
|
||||
) : null}
|
||||
{unpinnedProjects.length > 0 || hasActiveHostFilter ? listHeaderComponent : null}
|
||||
{projects.length === 0 ? (
|
||||
<View style={styles.emptyContainer}>
|
||||
<Text style={styles.emptyTitle} testID="sidebar-project-empty-state">
|
||||
@@ -2372,7 +2564,7 @@ function ProjectModeList({
|
||||
) : (
|
||||
<DraggableList
|
||||
testID="sidebar-project-list"
|
||||
data={projects}
|
||||
data={unpinnedProjects}
|
||||
keyExtractor={projectKeyExtractor}
|
||||
renderItem={renderProject}
|
||||
onDragEnd={handleProjectDragEnd}
|
||||
@@ -2429,6 +2621,9 @@ const styles = StyleSheet.create((theme) => ({
|
||||
projectListContainer: {
|
||||
width: "100%",
|
||||
},
|
||||
pinnedSection: {
|
||||
marginBottom: theme.spacing[1],
|
||||
},
|
||||
projectBlock: {
|
||||
marginBottom: theme.spacing[1],
|
||||
},
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ChevronDown, ChevronRight } from "lucide-react-native";
|
||||
import { Pressable, Text } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { isNative } from "@/constants/platform";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
|
||||
const ThemedChevronDown = withUnistyles(ChevronDown);
|
||||
const ThemedChevronRight = withUnistyles(ChevronRight);
|
||||
const foregroundMutedColorMapping = (theme: Theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
});
|
||||
|
||||
export function PinnedSectionHeader({
|
||||
collapsed,
|
||||
onToggle,
|
||||
}: {
|
||||
collapsed: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const accessibilityState = useMemo(() => ({ expanded: !collapsed }), [collapsed]);
|
||||
const Chevron = collapsed ? ThemedChevronRight : ThemedChevronDown;
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityState={accessibilityState}
|
||||
onPress={onToggle}
|
||||
style={styles.header}
|
||||
testID="sidebar-pinned-section-header"
|
||||
>
|
||||
{({ hovered }) => (
|
||||
<>
|
||||
<Text style={styles.title}>{t("sidebar.pinned.title")}</Text>
|
||||
{hovered || isNative || isCompact ? (
|
||||
<Chevron size={12} uniProps={foregroundMutedColorMapping} />
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
header: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
alignSelf: "flex-start",
|
||||
gap: theme.spacing[1],
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
paddingVertical: theme.spacing[1],
|
||||
userSelect: "none",
|
||||
},
|
||||
title: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
},
|
||||
}));
|
||||
@@ -5,25 +5,25 @@ import {
|
||||
type SidebarWorkspacesListResult,
|
||||
} from "@/hooks/use-sidebar-workspaces-list";
|
||||
import { useSidebarWorkspaceEntries } from "@/hooks/use-sidebar-workspace-entries";
|
||||
import { buildStatusGroups, type StatusGroup } from "@/hooks/sidebar-status-view-model";
|
||||
import type { StatusGroup } from "@/hooks/sidebar-status-view-model";
|
||||
import { usePinnedSidebarKeys, type PinnedSidebarGroups } from "@/hooks/use-sidebar-pins";
|
||||
import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store";
|
||||
import { useSidebarViewStore, type SidebarGroupMode } from "@/stores/sidebar-view-store";
|
||||
import {
|
||||
buildSidebarShortcutModel,
|
||||
buildStatusSidebarShortcutModel,
|
||||
type SidebarShortcutModel,
|
||||
} from "@/utils/sidebar-shortcuts";
|
||||
import type { SidebarShortcutModel } from "@/utils/sidebar-shortcuts";
|
||||
import { buildSidebarProjection } from "./sidebar-projection";
|
||||
|
||||
interface SidebarModel extends SidebarWorkspacesListResult {
|
||||
workspaceEntriesByKey: ReadonlyMap<string, SidebarWorkspaceEntry>;
|
||||
groupMode: SidebarGroupMode;
|
||||
statusGroups: StatusGroup[];
|
||||
pinnedGroups: PinnedSidebarGroups;
|
||||
collapsedProjectKeys: ReadonlySet<string>;
|
||||
toggleProjectCollapsed: (projectKey: string) => void;
|
||||
shortcutModel: SidebarShortcutModel;
|
||||
}
|
||||
|
||||
const SidebarModelContext = createContext<SidebarModel | null>(null);
|
||||
const EMPTY_WORKSPACE_ENTRIES = new Map<string, SidebarWorkspaceEntry>();
|
||||
|
||||
export function SidebarModelProvider({
|
||||
active,
|
||||
@@ -40,6 +40,7 @@ export function SidebarModelProvider({
|
||||
const collapsedStatusGroupKeys = useSidebarCollapsedSectionsStore(
|
||||
(state) => state.collapsedStatusGroupKeys,
|
||||
);
|
||||
const pinnedCollapsed = useSidebarCollapsedSectionsStore((state) => state.collapsedPinned);
|
||||
const toggleProjectCollapsed = useSidebarCollapsedSectionsStore(
|
||||
(state) => state.toggleProjectCollapsed,
|
||||
);
|
||||
@@ -48,38 +49,49 @@ export function SidebarModelProvider({
|
||||
list.workspacePlacements,
|
||||
active !== false || isStatusMode,
|
||||
);
|
||||
const statusGroups = useMemo(
|
||||
const projectionWorkspaceEntriesByKey = isStatusMode
|
||||
? workspaceEntriesByKey
|
||||
: EMPTY_WORKSPACE_ENTRIES;
|
||||
const pinnedKeys = usePinnedSidebarKeys(list.projects);
|
||||
const projection = useMemo(
|
||||
() =>
|
||||
isStatusMode
|
||||
? buildStatusGroups(Array.from(workspaceEntriesByKey.values()), list.projectNamesByKey)
|
||||
: [],
|
||||
[isStatusMode, list.projectNamesByKey, workspaceEntriesByKey],
|
||||
);
|
||||
const shortcutModel = useMemo(() => {
|
||||
if (isStatusMode) {
|
||||
return buildStatusSidebarShortcutModel({
|
||||
groups: statusGroups,
|
||||
buildSidebarProjection({
|
||||
projects: list.projects,
|
||||
pinnedKeys,
|
||||
workspaceEntriesByKey: projectionWorkspaceEntriesByKey,
|
||||
projectNamesByKey: list.projectNamesByKey,
|
||||
groupMode,
|
||||
pinnedCollapsed,
|
||||
collapsedProjectKeys,
|
||||
collapsedStatusGroupKeys,
|
||||
});
|
||||
}
|
||||
return buildSidebarShortcutModel({ projects: list.projects, collapsedProjectKeys });
|
||||
}, [collapsedProjectKeys, collapsedStatusGroupKeys, isStatusMode, list.projects, statusGroups]);
|
||||
}),
|
||||
[
|
||||
collapsedProjectKeys,
|
||||
collapsedStatusGroupKeys,
|
||||
groupMode,
|
||||
list.projectNamesByKey,
|
||||
list.projects,
|
||||
pinnedCollapsed,
|
||||
pinnedKeys,
|
||||
projectionWorkspaceEntriesByKey,
|
||||
],
|
||||
);
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
...list,
|
||||
workspaceEntriesByKey,
|
||||
groupMode,
|
||||
statusGroups,
|
||||
statusGroups: projection.statusGroups,
|
||||
pinnedGroups: projection.pinnedGroups,
|
||||
collapsedProjectKeys,
|
||||
toggleProjectCollapsed,
|
||||
shortcutModel,
|
||||
shortcutModel: projection.shortcutModel,
|
||||
}),
|
||||
[
|
||||
collapsedProjectKeys,
|
||||
groupMode,
|
||||
list,
|
||||
shortcutModel,
|
||||
statusGroups,
|
||||
projection,
|
||||
toggleProjectCollapsed,
|
||||
workspaceEntriesByKey,
|
||||
],
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type {
|
||||
SidebarProjectEntry,
|
||||
SidebarWorkspaceEntry,
|
||||
SidebarWorkspacePlacement,
|
||||
} from "@/hooks/use-sidebar-workspaces-list";
|
||||
import { buildSidebarProjection } from "./sidebar-projection";
|
||||
|
||||
function makeWorkspace(id: string, statusBucket: SidebarWorkspaceEntry["statusBucket"] = "done") {
|
||||
const placement: SidebarWorkspacePlacement = {
|
||||
workspaceKey: `srv:${id}`,
|
||||
serverId: "srv",
|
||||
workspaceId: id,
|
||||
projectKey: "project",
|
||||
projectName: "Project",
|
||||
projectKind: "git",
|
||||
workspaceKind: "worktree",
|
||||
name: id,
|
||||
};
|
||||
const entry: SidebarWorkspaceEntry = {
|
||||
...placement,
|
||||
title: null,
|
||||
currentBranch: null,
|
||||
statusBucket,
|
||||
statusEnteredAt: null,
|
||||
archivingAt: null,
|
||||
diffStat: null,
|
||||
prHint: null,
|
||||
archiveHasUncommittedChanges: null,
|
||||
archiveUnpushedCommitCount: null,
|
||||
scripts: [],
|
||||
hasRunningScripts: false,
|
||||
};
|
||||
return { placement, entry };
|
||||
}
|
||||
|
||||
function makeProject(workspaces: SidebarWorkspacePlacement[]): SidebarProjectEntry {
|
||||
return {
|
||||
projectKey: "project",
|
||||
projectName: "Project",
|
||||
projectKind: "git",
|
||||
iconWorkingDir: "/repo",
|
||||
hosts: [
|
||||
{
|
||||
serverId: "srv",
|
||||
iconWorkingDir: "/repo",
|
||||
canCreateWorktree: true,
|
||||
},
|
||||
],
|
||||
workspaces,
|
||||
};
|
||||
}
|
||||
|
||||
function projectionInput(options?: {
|
||||
groupMode?: "project" | "status";
|
||||
pinnedCollapsed?: boolean;
|
||||
}) {
|
||||
const pinned = makeWorkspace("pinned", "running");
|
||||
const unpinned = makeWorkspace("unpinned", "needs_input");
|
||||
return {
|
||||
projects: [makeProject([pinned.placement, unpinned.placement])],
|
||||
pinnedKeys: {
|
||||
pinnedWorkspaceKeys: [pinned.placement.workspaceKey],
|
||||
pinnedAtByKey: { [pinned.placement.workspaceKey]: "2026-07-12T12:00:00.000Z" },
|
||||
},
|
||||
workspaceEntriesByKey: new Map([
|
||||
[pinned.entry.workspaceKey, pinned.entry],
|
||||
[unpinned.entry.workspaceKey, unpinned.entry],
|
||||
]),
|
||||
projectNamesByKey: new Map([["project", "Project"]]),
|
||||
groupMode: options?.groupMode ?? ("project" as const),
|
||||
pinnedCollapsed: options?.pinnedCollapsed ?? false,
|
||||
collapsedProjectKeys: new Set<string>(),
|
||||
collapsedStatusGroupKeys: new Set<string>(),
|
||||
};
|
||||
}
|
||||
|
||||
describe("buildSidebarProjection", () => {
|
||||
it("uses one pin-aware projection for project rows and shortcut order", () => {
|
||||
const projection = buildSidebarProjection(projectionInput());
|
||||
|
||||
expect(projection.pinnedGroups.pinnedChats.map((entry) => entry.workspaceId)).toEqual([
|
||||
"pinned",
|
||||
]);
|
||||
const remainingProject = projection.pinnedGroups.unpinnedProjects[0];
|
||||
expect(remainingProject?.workspaces.map((entry) => entry.workspaceId)).toEqual(["unpinned"]);
|
||||
expect(projection.shortcutModel.shortcutTargets).toEqual([
|
||||
{ serverId: "srv", workspaceId: "pinned" },
|
||||
{ serverId: "srv", workspaceId: "unpinned" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps pinned chats above status groups and removes them from those groups", () => {
|
||||
const projection = buildSidebarProjection(projectionInput({ groupMode: "status" }));
|
||||
|
||||
expect(projection.statusGroups.map((group) => group.bucket)).toEqual(["needs_input"]);
|
||||
expect(projection.statusGroups[0]?.rows.map((entry) => entry.workspaceId)).toEqual([
|
||||
"unpinned",
|
||||
]);
|
||||
expect(projection.shortcutModel.shortcutTargets).toEqual([
|
||||
{ serverId: "srv", workspaceId: "pinned" },
|
||||
{ serverId: "srv", workspaceId: "unpinned" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not number pinned chats while the pinned section is collapsed", () => {
|
||||
const projection = buildSidebarProjection(
|
||||
projectionInput({ groupMode: "status", pinnedCollapsed: true }),
|
||||
);
|
||||
|
||||
expect(projection.shortcutModel.shortcutTargets).toEqual([
|
||||
{ serverId: "srv", workspaceId: "unpinned" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import { buildStatusGroups, type StatusGroup } from "@/hooks/sidebar-status-view-model";
|
||||
import {
|
||||
splitPinnedSidebarGroups,
|
||||
type PinnedSidebarGroups,
|
||||
type PinnedSidebarKeys,
|
||||
} from "@/hooks/use-sidebar-pins";
|
||||
import type {
|
||||
SidebarProjectEntry,
|
||||
SidebarWorkspaceEntry,
|
||||
} from "@/hooks/use-sidebar-workspaces-list";
|
||||
import type { SidebarGroupMode } from "@/stores/sidebar-view-store";
|
||||
import {
|
||||
buildSidebarShortcutSections,
|
||||
type SidebarShortcutModel,
|
||||
type SidebarShortcutSection,
|
||||
} from "@/utils/sidebar-shortcuts";
|
||||
|
||||
export interface SidebarProjection {
|
||||
pinnedGroups: PinnedSidebarGroups;
|
||||
statusGroups: StatusGroup[];
|
||||
shortcutModel: SidebarShortcutModel;
|
||||
}
|
||||
|
||||
export function buildSidebarProjection(input: {
|
||||
projects: SidebarProjectEntry[];
|
||||
pinnedKeys: PinnedSidebarKeys;
|
||||
workspaceEntriesByKey: ReadonlyMap<string, SidebarWorkspaceEntry>;
|
||||
projectNamesByKey: Map<string, string>;
|
||||
groupMode: SidebarGroupMode;
|
||||
pinnedCollapsed: boolean;
|
||||
collapsedProjectKeys: ReadonlySet<string>;
|
||||
collapsedStatusGroupKeys: ReadonlySet<string>;
|
||||
}): SidebarProjection {
|
||||
const pinnedGroups = splitPinnedSidebarGroups({
|
||||
projects: input.projects,
|
||||
keys: input.pinnedKeys,
|
||||
});
|
||||
const pinnedWorkspaceKeys = new Set(input.pinnedKeys.pinnedWorkspaceKeys);
|
||||
const statusGroups =
|
||||
input.groupMode === "status"
|
||||
? buildStatusGroups(
|
||||
Array.from(input.workspaceEntriesByKey.values()).filter(
|
||||
(workspace) => !pinnedWorkspaceKeys.has(workspace.workspaceKey),
|
||||
),
|
||||
input.projectNamesByKey,
|
||||
)
|
||||
: [];
|
||||
|
||||
const sections: SidebarShortcutSection[] = [];
|
||||
if (!input.pinnedCollapsed) {
|
||||
sections.push({ workspaces: pinnedGroups.pinnedChats });
|
||||
}
|
||||
if (input.groupMode === "status") {
|
||||
sections.push(
|
||||
...statusGroups.map((group) => ({
|
||||
workspaces: group.rows,
|
||||
collapsed: input.collapsedStatusGroupKeys.has(group.bucket),
|
||||
})),
|
||||
);
|
||||
} else {
|
||||
sections.push(
|
||||
...pinnedGroups.unpinnedProjects.map((project) => ({
|
||||
workspaces: project.workspaces,
|
||||
collapsed: input.collapsedProjectKeys.has(project.projectKey),
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
pinnedGroups,
|
||||
statusGroups,
|
||||
shortcutModel: buildSidebarShortcutSections({ sections }),
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { memo, useCallback, useMemo, useState } from "react";
|
||||
import { memo, useCallback, useMemo, useState, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { View, Text, Pressable, ScrollView, type PressableStateCallbackType } from "react-native";
|
||||
import { NestableScrollContainer } from "react-native-draggable-flatlist";
|
||||
@@ -41,6 +41,8 @@ import {
|
||||
} from "@/components/sidebar/sidebar-workspace-row-content";
|
||||
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 type { ToggleSidebarWorkspacePin } from "@/hooks/use-sidebar-workspace-pin";
|
||||
|
||||
// Themed icon wrappers
|
||||
const foregroundMutedColorMapping = (theme: Theme) => ({
|
||||
@@ -59,28 +61,82 @@ const ThemedCircleDot = withUnistyles(CircleDot);
|
||||
const ThemedCircleX = withUnistyles(CircleX);
|
||||
interface StatusWorkspaceListProps {
|
||||
groups: StatusGroup[];
|
||||
pinnedWorkspaces: SidebarWorkspaceEntry[];
|
||||
projectNamesByKey: Map<string, string>;
|
||||
shortcutIndexByWorkspaceKey: Map<string, number>;
|
||||
showShortcutBadges: boolean;
|
||||
onWorkspacePress?: () => void;
|
||||
hostLabelByServerId: ReadonlyMap<string, string>;
|
||||
showHostLabels: boolean;
|
||||
supportsPinningByServerId: ReadonlyMap<string, boolean>;
|
||||
onToggleWorkspacePin: ToggleSidebarWorkspacePin;
|
||||
listHeaderComponent?: ReactNode;
|
||||
}
|
||||
|
||||
export function SidebarStatusWorkspaceList({
|
||||
groups,
|
||||
pinnedWorkspaces,
|
||||
projectNamesByKey,
|
||||
shortcutIndexByWorkspaceKey,
|
||||
showShortcutBadges,
|
||||
onWorkspacePress,
|
||||
hostLabelByServerId,
|
||||
showHostLabels,
|
||||
supportsPinningByServerId,
|
||||
onToggleWorkspacePin,
|
||||
listHeaderComponent,
|
||||
}: StatusWorkspaceListProps) {
|
||||
const collapsedStatusGroupKeys = useSidebarCollapsedSectionsStore(
|
||||
(state) => state.collapsedStatusGroupKeys,
|
||||
);
|
||||
const pinnedCollapsed = useSidebarCollapsedSectionsStore((state) => state.collapsedPinned);
|
||||
const togglePinnedCollapsed = useSidebarCollapsedSectionsStore(
|
||||
(state) => state.togglePinnedCollapsed,
|
||||
);
|
||||
|
||||
const statusShortcutIndex = showShortcutBadges ? shortcutIndexByWorkspaceKey : new Map();
|
||||
const content = (
|
||||
<>
|
||||
{pinnedWorkspaces.length > 0 ? (
|
||||
<View style={styles.pinnedSection} testID="sidebar-pinned-section">
|
||||
<PinnedSectionHeader collapsed={pinnedCollapsed} onToggle={togglePinnedCollapsed} />
|
||||
{pinnedCollapsed
|
||||
? null
|
||||
: pinnedWorkspaces.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={statusShortcutIndex.get(workspace.workspaceKey) ?? null}
|
||||
showShortcutBadge={showShortcutBadges}
|
||||
canPin={supportsPinningByServerId.get(workspace.serverId) === true}
|
||||
onToggleWorkspacePin={onToggleWorkspacePin}
|
||||
reserveIdleStatusIndicatorSpace={false}
|
||||
onWorkspacePress={onWorkspacePress}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
) : null}
|
||||
{listHeaderComponent}
|
||||
<StatusGroupList
|
||||
groups={groups}
|
||||
collapsedStatusGroupKeys={collapsedStatusGroupKeys}
|
||||
projectNamesByKey={projectNamesByKey}
|
||||
shortcutIndex={statusShortcutIndex}
|
||||
showShortcutBadges={showShortcutBadges}
|
||||
onWorkspacePress={onWorkspacePress}
|
||||
hostLabelByServerId={hostLabelByServerId}
|
||||
showHostLabels={showHostLabels}
|
||||
supportsPinningByServerId={supportsPinningByServerId}
|
||||
onToggleWorkspacePin={onToggleWorkspacePin}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
@@ -91,16 +147,7 @@ export function SidebarStatusWorkspaceList({
|
||||
showsVerticalScrollIndicator={false}
|
||||
testID="sidebar-status-list-scroll"
|
||||
>
|
||||
<StatusGroupList
|
||||
groups={groups}
|
||||
collapsedStatusGroupKeys={collapsedStatusGroupKeys}
|
||||
projectNamesByKey={projectNamesByKey}
|
||||
shortcutIndex={statusShortcutIndex}
|
||||
showShortcutBadges={showShortcutBadges}
|
||||
onWorkspacePress={onWorkspacePress}
|
||||
hostLabelByServerId={hostLabelByServerId}
|
||||
showHostLabels={showHostLabels}
|
||||
/>
|
||||
{content}
|
||||
</NestableScrollContainer>
|
||||
) : (
|
||||
<ScrollView
|
||||
@@ -109,16 +156,7 @@ export function SidebarStatusWorkspaceList({
|
||||
showsVerticalScrollIndicator={false}
|
||||
testID="sidebar-status-list-scroll"
|
||||
>
|
||||
<StatusGroupList
|
||||
groups={groups}
|
||||
collapsedStatusGroupKeys={collapsedStatusGroupKeys}
|
||||
projectNamesByKey={projectNamesByKey}
|
||||
shortcutIndex={statusShortcutIndex}
|
||||
showShortcutBadges={showShortcutBadges}
|
||||
onWorkspacePress={onWorkspacePress}
|
||||
hostLabelByServerId={hostLabelByServerId}
|
||||
showHostLabels={showHostLabels}
|
||||
/>
|
||||
{content}
|
||||
</ScrollView>
|
||||
)}
|
||||
</View>
|
||||
@@ -134,6 +172,8 @@ function StatusGroupList({
|
||||
onWorkspacePress,
|
||||
hostLabelByServerId,
|
||||
showHostLabels,
|
||||
supportsPinningByServerId,
|
||||
onToggleWorkspacePin,
|
||||
}: {
|
||||
groups: StatusGroup[];
|
||||
collapsedStatusGroupKeys: ReadonlySet<string>;
|
||||
@@ -143,6 +183,8 @@ function StatusGroupList({
|
||||
onWorkspacePress?: () => void;
|
||||
hostLabelByServerId: ReadonlyMap<string, string>;
|
||||
showHostLabels: boolean;
|
||||
supportsPinningByServerId: ReadonlyMap<string, boolean>;
|
||||
onToggleWorkspacePin: ToggleSidebarWorkspacePin;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
@@ -166,6 +208,8 @@ function StatusGroupList({
|
||||
})}
|
||||
shortcutNumber={shortcutIndex.get(workspace.workspaceKey) ?? null}
|
||||
showShortcutBadge={showShortcutBadges}
|
||||
canPin={supportsPinningByServerId.get(workspace.serverId) === true}
|
||||
onToggleWorkspacePin={onToggleWorkspacePin}
|
||||
onWorkspacePress={onWorkspacePress}
|
||||
/>
|
||||
))}
|
||||
@@ -279,12 +323,18 @@ const StatusWorkspaceRow = memo(function StatusWorkspaceRow({
|
||||
subtitle,
|
||||
shortcutNumber,
|
||||
showShortcutBadge,
|
||||
canPin,
|
||||
onToggleWorkspacePin,
|
||||
reserveIdleStatusIndicatorSpace = true,
|
||||
onWorkspacePress,
|
||||
}: {
|
||||
workspace: SidebarWorkspaceEntry;
|
||||
subtitle: string;
|
||||
shortcutNumber: number | null;
|
||||
showShortcutBadge: boolean;
|
||||
canPin: boolean;
|
||||
onToggleWorkspacePin: ToggleSidebarWorkspacePin;
|
||||
reserveIdleStatusIndicatorSpace?: boolean;
|
||||
onWorkspacePress?: () => void;
|
||||
}) {
|
||||
const activeWorkspaceSelection = useActiveWorkspaceSelection();
|
||||
@@ -305,6 +355,9 @@ const StatusWorkspaceRow = memo(function StatusWorkspaceRow({
|
||||
selected={selected}
|
||||
shortcutNumber={shortcutNumber}
|
||||
showShortcutBadge={showShortcutBadge}
|
||||
canPin={canPin}
|
||||
onToggleWorkspacePin={onToggleWorkspacePin}
|
||||
reserveIdleStatusIndicatorSpace={reserveIdleStatusIndicatorSpace}
|
||||
onPress={handlePress}
|
||||
/>
|
||||
);
|
||||
@@ -316,6 +369,9 @@ function StatusWorkspaceRowWithMenu({
|
||||
selected,
|
||||
shortcutNumber,
|
||||
showShortcutBadge,
|
||||
canPin,
|
||||
onToggleWorkspacePin,
|
||||
reserveIdleStatusIndicatorSpace = true,
|
||||
onPress,
|
||||
}: {
|
||||
workspace: SidebarWorkspaceEntry;
|
||||
@@ -323,6 +379,9 @@ function StatusWorkspaceRowWithMenu({
|
||||
selected: boolean;
|
||||
shortcutNumber: number | null;
|
||||
showShortcutBadge: boolean;
|
||||
canPin: boolean;
|
||||
onToggleWorkspacePin: ToggleSidebarWorkspacePin;
|
||||
reserveIdleStatusIndicatorSpace?: boolean;
|
||||
onPress: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
@@ -392,6 +451,11 @@ function StatusWorkspaceRowWithMenu({
|
||||
},
|
||||
[renameMutation],
|
||||
);
|
||||
const isPinned = workspace.pinnedAt != null;
|
||||
const handleTogglePin = useCallback(() => {
|
||||
onToggleWorkspacePin(workspace);
|
||||
}, [onToggleWorkspacePin, workspace]);
|
||||
const onTogglePin = canPin ? handleTogglePin : undefined;
|
||||
|
||||
const archiveShortcutKeys = useShortcutKeys("archive-workspace");
|
||||
const { hasClearableAttention, clearAttention } = useClearWorkspaceAttention({
|
||||
@@ -415,6 +479,17 @@ function StatusWorkspaceRowWithMenu({
|
||||
},
|
||||
});
|
||||
|
||||
useKeyboardActionHandler({
|
||||
handlerId: `workspace-pin-${workspace.workspaceKey}`,
|
||||
actions: ["workspace.pin"],
|
||||
enabled: selected && canPin,
|
||||
priority: 0,
|
||||
handle: () => {
|
||||
onTogglePin?.();
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<StatusWorkspaceRowInner
|
||||
@@ -434,6 +509,9 @@ function StatusWorkspaceRowWithMenu({
|
||||
onRename={handleOpenRename}
|
||||
onMarkAsRead={hasClearableAttention ? handleMarkAsRead : undefined}
|
||||
archiveShortcutKeys={selected ? archiveShortcutKeys : null}
|
||||
isPinned={isPinned}
|
||||
onTogglePin={onTogglePin}
|
||||
reserveIdleStatusIndicatorSpace={reserveIdleStatusIndicatorSpace}
|
||||
/>
|
||||
<AdaptiveRenameModal
|
||||
visible={isRenameOpen}
|
||||
@@ -466,6 +544,9 @@ function StatusWorkspaceRowInner({
|
||||
onRename,
|
||||
onMarkAsRead,
|
||||
archiveShortcutKeys,
|
||||
isPinned,
|
||||
onTogglePin,
|
||||
reserveIdleStatusIndicatorSpace = true,
|
||||
}: {
|
||||
workspace: SidebarWorkspaceEntry;
|
||||
subtitle: string;
|
||||
@@ -483,6 +564,9 @@ function StatusWorkspaceRowInner({
|
||||
onRename?: () => void;
|
||||
onMarkAsRead?: () => void;
|
||||
archiveShortcutKeys?: ShortcutKey[][] | null;
|
||||
isPinned?: boolean;
|
||||
onTogglePin?: () => void;
|
||||
reserveIdleStatusIndicatorSpace?: boolean;
|
||||
}) {
|
||||
const isTouchPlatform = platformIsNative;
|
||||
|
||||
@@ -524,12 +608,15 @@ function StatusWorkspaceRowInner({
|
||||
isLoading={isArchiving}
|
||||
shortcutNumber={shortcutNumber}
|
||||
showShortcutBadge={showShortcutBadge}
|
||||
reserveIdleStatusIndicatorSpace={reserveIdleStatusIndicatorSpace}
|
||||
>
|
||||
{shouldRenderActionSlot ? (
|
||||
<StatusWorkspaceActionSlot
|
||||
workspace={workspace}
|
||||
showBase={Boolean(workspace.diffStat && !showKebabInSlot && !showShortcut)}
|
||||
showOverlay={showKebabInSlot}
|
||||
showKebab={showKebabInSlot}
|
||||
isPinned={isPinned}
|
||||
onTogglePin={onTogglePin}
|
||||
onCopyPath={onCopyPath}
|
||||
onCopyBranchName={onCopyBranchName}
|
||||
onRename={onRename}
|
||||
@@ -553,7 +640,9 @@ function StatusWorkspaceRowInner({
|
||||
function StatusWorkspaceActionSlot({
|
||||
workspace,
|
||||
showBase,
|
||||
showOverlay,
|
||||
showKebab,
|
||||
isPinned,
|
||||
onTogglePin,
|
||||
onCopyPath,
|
||||
onCopyBranchName,
|
||||
onRename,
|
||||
@@ -566,7 +655,9 @@ function StatusWorkspaceActionSlot({
|
||||
}: {
|
||||
workspace: SidebarWorkspaceEntry;
|
||||
showBase: boolean;
|
||||
showOverlay: boolean;
|
||||
showKebab: boolean;
|
||||
isPinned?: boolean;
|
||||
onTogglePin?: () => void;
|
||||
onCopyPath?: () => void;
|
||||
onCopyBranchName?: () => void;
|
||||
onRename?: () => void;
|
||||
@@ -587,8 +678,8 @@ function StatusWorkspaceActionSlot({
|
||||
/>
|
||||
) : null}
|
||||
</SidebarWorkspaceTrailingActionBase>
|
||||
<SidebarWorkspaceTrailingActionOverlay visible={showOverlay}>
|
||||
{onArchive ? (
|
||||
<SidebarWorkspaceTrailingActionOverlay visible={showKebab}>
|
||||
{showKebab && onArchive ? (
|
||||
<SidebarWorkspaceMenu
|
||||
workspaceKey={workspace.workspaceKey}
|
||||
onCopyPath={onCopyPath}
|
||||
@@ -600,6 +691,8 @@ function StatusWorkspaceActionSlot({
|
||||
archiveStatus={archiveStatus}
|
||||
archivePendingLabel={archivePendingLabel}
|
||||
archiveShortcutKeys={archiveShortcutKeys}
|
||||
isPinned={isPinned}
|
||||
onTogglePin={onTogglePin}
|
||||
/>
|
||||
) : null}
|
||||
</SidebarWorkspaceTrailingActionOverlay>
|
||||
@@ -633,6 +726,9 @@ const styles = StyleSheet.create((theme) => ({
|
||||
paddingTop: theme.spacing[2],
|
||||
paddingBottom: theme.spacing[4],
|
||||
},
|
||||
pinnedSection: {
|
||||
marginBottom: theme.spacing[1],
|
||||
},
|
||||
statusGroupBlock: {
|
||||
marginBottom: theme.spacing[1],
|
||||
},
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { type PressableStateCallbackType } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { Archive, CircleCheck, Copy, MoreVertical, Pencil } from "lucide-react-native";
|
||||
import { Archive, CircleCheck, Copy, MoreVertical, Pencil, Pin, PinOff } from "lucide-react-native";
|
||||
import { isNative, isWeb } from "@/constants/platform";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import type { ShortcutKey } from "@/utils/format-shortcut";
|
||||
@@ -24,6 +24,8 @@ const ThemedCopy = withUnistyles(Copy);
|
||||
const ThemedArchive = withUnistyles(Archive);
|
||||
const ThemedPencil = withUnistyles(Pencil);
|
||||
const ThemedCircleCheck = withUnistyles(CircleCheck);
|
||||
const ThemedPin = withUnistyles(Pin);
|
||||
const ThemedPinOff = withUnistyles(PinOff);
|
||||
|
||||
const copyLeadingIcon = <ThemedCopy size={14} uniProps={foregroundMutedColorMapping} />;
|
||||
const renameLeadingIcon = <ThemedPencil size={14} uniProps={foregroundMutedColorMapping} />;
|
||||
@@ -31,6 +33,8 @@ const markAsReadLeadingIcon = (
|
||||
<ThemedCircleCheck size={14} uniProps={foregroundMutedColorMapping} />
|
||||
);
|
||||
const archiveLeadingIcon = <ThemedArchive size={14} uniProps={foregroundMutedColorMapping} />;
|
||||
const pinLeadingIcon = <ThemedPin size={14} uniProps={foregroundMutedColorMapping} />;
|
||||
const unpinLeadingIcon = <ThemedPinOff size={14} uniProps={foregroundMutedColorMapping} />;
|
||||
|
||||
function renderTriggerIcon({ hovered }: { hovered?: boolean }) {
|
||||
return (
|
||||
@@ -52,6 +56,8 @@ interface SidebarWorkspaceMenuProps {
|
||||
archiveStatus?: "idle" | "pending" | "success";
|
||||
archivePendingLabel?: string;
|
||||
archiveShortcutKeys?: ShortcutKey[][] | null;
|
||||
isPinned?: boolean;
|
||||
onTogglePin?: () => void;
|
||||
}
|
||||
|
||||
export function SidebarWorkspaceMenu({
|
||||
@@ -65,6 +71,8 @@ export function SidebarWorkspaceMenu({
|
||||
archiveStatus,
|
||||
archivePendingLabel,
|
||||
archiveShortcutKeys,
|
||||
isPinned,
|
||||
onTogglePin,
|
||||
}: SidebarWorkspaceMenuProps) {
|
||||
const { t } = useTranslation();
|
||||
const archiveTrailing = useMemo(
|
||||
@@ -120,6 +128,15 @@ export function SidebarWorkspaceMenu({
|
||||
Mark as read
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{onTogglePin ? (
|
||||
<DropdownMenuItem
|
||||
testID={`sidebar-workspace-menu-pin-${workspaceKey}`}
|
||||
leading={isPinned ? unpinLeadingIcon : pinLeadingIcon}
|
||||
onSelect={onTogglePin}
|
||||
>
|
||||
{isPinned ? t("sidebar.workspace.actions.unpin") : t("sidebar.workspace.actions.pin")}
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
<DropdownMenuItem
|
||||
testID={`sidebar-workspace-menu-archive-${workspaceKey}`}
|
||||
leading={archiveLeadingIcon}
|
||||
|
||||
@@ -100,6 +100,7 @@ export const SidebarWorkspaceRowContent = memo(function SidebarWorkspaceRowConte
|
||||
isCreating = false,
|
||||
shortcutNumber = null,
|
||||
showShortcutBadge = false,
|
||||
reserveIdleStatusIndicatorSpace = true,
|
||||
children,
|
||||
}: {
|
||||
workspace: SidebarWorkspaceEntry;
|
||||
@@ -110,6 +111,8 @@ export const SidebarWorkspaceRowContent = memo(function SidebarWorkspaceRowConte
|
||||
isCreating?: boolean;
|
||||
shortcutNumber?: number | null;
|
||||
showShortcutBadge?: boolean;
|
||||
/** Keep the empty leading slot when the workspace has no active status. */
|
||||
reserveIdleStatusIndicatorSpace?: boolean;
|
||||
children?: ReactNode;
|
||||
}) {
|
||||
const {
|
||||
@@ -133,6 +136,7 @@ export const SidebarWorkspaceRowContent = memo(function SidebarWorkspaceRowConte
|
||||
bucket={workspace.statusBucket}
|
||||
workspaceKind={workspace.workspaceKind}
|
||||
loading={isLoading}
|
||||
reserveIdleSpace={reserveIdleStatusIndicatorSpace}
|
||||
/>
|
||||
<View style={styles.workspaceContentColumn}>
|
||||
<View style={styles.workspaceTitleRow}>
|
||||
@@ -186,10 +190,12 @@ function WorkspaceStatusIndicator({
|
||||
bucket,
|
||||
workspaceKind,
|
||||
loading = false,
|
||||
reserveIdleSpace = true,
|
||||
}: {
|
||||
bucket: SidebarWorkspaceEntry["statusBucket"];
|
||||
workspaceKind: SidebarWorkspaceEntry["workspaceKind"];
|
||||
loading?: boolean;
|
||||
reserveIdleSpace?: boolean;
|
||||
}) {
|
||||
const shouldShowSyncedLoader = shouldRenderSyncedStatusLoader({ bucket });
|
||||
|
||||
@@ -226,7 +232,9 @@ function WorkspaceStatusIndicator({
|
||||
}
|
||||
|
||||
if (bucket === "done") {
|
||||
return <View style={styles.workspaceStatusDot} testID="workspace-status-indicator-done" />;
|
||||
return reserveIdleSpace ? (
|
||||
<View style={styles.workspaceStatusDot} testID="workspace-status-indicator-done" />
|
||||
) : null;
|
||||
}
|
||||
|
||||
let KindIcon: typeof ThemedMonitor;
|
||||
|
||||
Reference in New Issue
Block a user