Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 80 additions & 35 deletions packages/happy-app/sources/components/ProjectGroup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ import { StyleSheet, useUnistyles } from 'react-native-unistyles';
import { Text } from '@/components/StyledText';
import { Typography } from '@/constants/Typography';
import { t } from '@/text';
import { ProjectGroupData, ProjectWorkspaceGroup, useSessionGitStatus } from '@/sync/storage';
import { ProjectGroupData, ProjectWorkspaceGroup, useLocalSettingMutable, useSessionGitStatus } from '@/sync/storage';
import { projectWorkspaceCollapseKey } from '@/sync/projectGroups';
import { CompactSessionRow } from './ActiveSessionsGroupCompact';
import { Avatar } from './Avatar';
import { requestHomeDockFocus } from './homeDockFocus';
Expand Down Expand Up @@ -81,6 +82,16 @@ const WorkspaceSection = React.memo(({ project, workspace, selectedSessionId }:
})
: null;

// Collapsing follows the header, and the header is per checkout: a project
// with three worktrees renders four of these sections, so one toggle folds
// exactly the card it sits on rather than every checkout of the project.
const collapseKey = projectWorkspaceCollapseKey(project.id, workspace.id);
const [collapsedProjects, setCollapsedProjects] = useLocalSettingMutable('collapsedProjects');
const collapsed = !!collapsedProjects[collapseKey];
const toggleCollapsed = React.useCallback(() => {
setCollapsedProjects({ ...collapsedProjects, [collapseKey]: !collapsed });
}, [collapsed, collapsedProjects, collapseKey, setCollapsedProjects]);

// Point the draft at this exact checkout before opening the composer, so
// the dock's machine, project and worktree rows already read correctly.
// `setMachineId` clears the path and worktree, so the order matters.
Expand Down Expand Up @@ -109,32 +120,51 @@ const WorkspaceSection = React.memo(({ project, workspace, selectedSessionId }:
return (
<View style={styles.section}>
<View style={styles.header}>
{firstSession && (
<Avatar id={firstSession.avatarId} size={HEADER_AVATAR_SIZE} flavor={null} imageUrl={firstSession.projectAvatarUri} thumbhash={firstSession.projectAvatarThumbhash} />
)}
<View style={styles.headerText}>
<Text style={styles.title} numberOfLines={1}>
{project.name}
</Text>
<View style={styles.branchLine}>
<Text style={styles.branchText} numberOfLines={1}>
{branchName}
<Pressable
onPress={toggleCollapsed}
hitSlop={{ top: 8, bottom: 8 }}
accessibilityRole="button"
accessibilityState={{ expanded: !collapsed }}
accessibilityLabel={worktreeName ? `${project.name} / ${worktreeName}` : project.name}
style={styles.headerPress}
>
<Ionicons
name={collapsed ? 'chevron-forward' : 'chevron-down'}
size={14}
color={theme.colors.textSecondary}
/>
{firstSession && (
<Avatar id={firstSession.avatarId} size={HEADER_AVATAR_SIZE} flavor={null} imageUrl={firstSession.projectAvatarUri} thumbhash={firstSession.projectAvatarThumbhash} />
)}
<View style={styles.headerText}>
<Text style={styles.title} numberOfLines={1}>
{project.name}
</Text>
{changes && (
<View style={styles.branchChanges}>
{changes.approximate && (
<Text style={styles.approximateText}>≈</Text>
)}
{changes.insertions > 0 && (
<Text style={styles.addedText}>+{compactCount(changes.insertions)}</Text>
)}
{changes.deletions > 0 && (
<Text style={styles.removedText}>-{compactCount(changes.deletions)}</Text>
)}
</View>
)}
<View style={styles.branchLine}>
<Text style={styles.branchText} numberOfLines={1}>
{branchName}
</Text>
{changes && (
<View style={styles.branchChanges}>
{changes.approximate && (
<Text style={styles.approximateText}>≈</Text>
)}
{changes.insertions > 0 && (
<Text style={styles.addedText}>+{compactCount(changes.insertions)}</Text>
)}
{changes.deletions > 0 && (
<Text style={styles.removedText}>-{compactCount(changes.deletions)}</Text>
)}
</View>
)}
</View>
</View>
</View>
{collapsed && (
<Text style={styles.count}>
{workspace.sessions.length}
</Text>
)}
</Pressable>
<Pressable
onPress={handleNewSession}
hitSlop={12}
Expand All @@ -146,16 +176,18 @@ const WorkspaceSection = React.memo(({ project, workspace, selectedSessionId }:
</Pressable>
</View>

<View style={styles.workspaceCard}>
{workspace.sessions.map((session, index) => (
<CompactSessionRow
key={session.id}
session={session}
selected={session.id === selectedSessionId}
showBorder={index < workspace.sessions.length - 1}
/>
))}
</View>
{!collapsed && (
<View style={styles.workspaceCard}>
{workspace.sessions.map((session, index) => (
<CompactSessionRow
key={session.id}
session={session}
selected={session.id === selectedSessionId}
showBorder={index < workspace.sessions.length - 1}
/>
))}
</View>
)}
</View>
);
});
Expand All @@ -181,10 +213,23 @@ const stylesheet = StyleSheet.create((theme) => ({
paddingRight: Platform.select({ ios: 26, default: 22 }),
gap: 8,
},
headerPress: {
flex: 1,
minWidth: 0,
flexDirection: 'row',
alignItems: 'center',
gap: 8,
},
headerText: {
flex: 1,
minWidth: 0,
},
count: {
fontSize: 12,
lineHeight: 16,
color: theme.colors.textSecondary,
...Typography.default('regular'),
},
title: {
color: theme.colors.groupped.sectionTitle,
fontSize: Platform.select({ ios: 13, default: 14 }),
Expand Down
18 changes: 18 additions & 0 deletions packages/happy-app/sources/sync/projectGroups.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
buildPathProjectGroups,
buildProjectGroups,
filterProjectGroupSessions,
projectWorkspaceCollapseKey,
} from './projectGroups';
import type { ProjectGroupData } from './projectGroups';
import type { Session } from './storageTypes';
Expand Down Expand Up @@ -249,3 +250,20 @@ describe('filterProjectGroupSessions', () => {
});
});
});

describe('projectWorkspaceCollapseKey', () => {
it('keys the primary checkout on the project alone, so existing state survives', () => {
expect(projectWorkspaceCollapseKey('path:["m1","/repo"]', '')).toBe('path:["m1","/repo"]');
});

it('gives every worktree its own key', () => {
const primary = projectWorkspaceCollapseKey('p', '');
const a = projectWorkspaceCollapseKey('p', '/repo-wt-a');
const b = projectWorkspaceCollapseKey('p', '/repo-wt-b');
expect(new Set([primary, a, b]).size).toBe(3);
});

it('does not collide across projects that share a worktree path', () => {
expect(projectWorkspaceCollapseKey('p1', '/wt')).not.toBe(projectWorkspaceCollapseKey('p2', '/wt'));
});
});
13 changes: 13 additions & 0 deletions packages/happy-app/sources/sync/projectGroups.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,3 +182,16 @@ export function buildProjectGroups(

return [...projects.values()];
}

/**
* The key a workspace's collapsed state is stored under. The primary checkout
* IS the project, so it keys on the project id alone — that keeps whatever
* state a user already has for a project with no worktrees. A worktree appends
* its own path, so each checkout collapses independently.
*/
export function projectWorkspaceCollapseKey(
projectId: string,
workspaceId: string,
): string {
return workspaceId ? projectId + '\u0000' + workspaceId : projectId;
}
Loading