Skip to content
Merged
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
2 changes: 1 addition & 1 deletion apps/app/bundle-budget.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@
"`node scripts/why-eager.mjs --from=views/SplitWorkspaceRoute.tsx <package>`",
"to print the static chain that pulled a package into the closure."
],
"maxBootBytes": 1737248,
"maxBootBytes": 1739296,
"maxBootBrotliBytes": 429072,
"forbiddenBootPackages": [
"@pierre/diffs",
Expand Down
48 changes: 40 additions & 8 deletions apps/app/src/components/sidebar/ProjectRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,7 @@ interface ThreadTreeNodeRowProps {
}

interface ThreadTreeItemRowProps {
isEnvGrouped?: boolean;
projectId: string;
item: ProjectThreadItem;
depthOffset: number;
Expand Down Expand Up @@ -354,6 +355,10 @@ function getItemProjectId(item: ProjectThreadItem): string {
}

interface EnvironmentThreadGroupRowProps {
sectionDnd?: SectionThreadDndState;
dragBindings?: SidebarSortableDragBindings;
sortableRef?: (element: HTMLDivElement | null) => void;
sortableStyle?: CSSProperties;
projectId: string;
environmentThreadGroup: EnvironmentThreadGroup;
depthOffset: number;
Expand Down Expand Up @@ -381,6 +386,7 @@ interface GetThreadNodeStickyLevelArgs {
}

interface EnvironmentThreadGroupHeaderProps {
dragBindings?: SidebarSortableDragBindings;
environmentId: string;
environmentProviderId: string | null;
representativeThread: ThreadListEntry;
Expand Down Expand Up @@ -627,7 +633,7 @@ const SectionDndItemRow = memo(function SectionDndItemRow({
sectionDnd,
...props
}: ThreadTreeItemRowProps) {
if (!sectionDnd || props.item.kind === "environment") {
if (!sectionDnd) {
return <ThreadTreeItemRow sectionDnd={sectionDnd} {...props} />;
}

Expand All @@ -649,7 +655,7 @@ const DraggableSectionThreadItemRow = memo(
disabled: false,
displace: false,
});
const isActive = sectionDnd.activeThread?.id === itemId;
const isActive = sectionDnd.activeItemId === itemId;
const hasProjectedDestination =
sectionDnd.dragOverParentKey !== null ||
sectionDnd.nestTarget?.state === "valid" ||
Expand Down Expand Up @@ -826,6 +832,7 @@ function EnvironmentThreadGroupHeaderActions({
}

function EnvironmentThreadGroupHeader({
dragBindings,
environmentId,
environmentProviderId,
representativeThread,
Expand Down Expand Up @@ -990,6 +997,9 @@ function EnvironmentThreadGroupHeader({
if (stickyLevel !== undefined) {
return (
<SidebarStickyTier
{...dragBindings?.attributes}
{...dragBindings?.listeners}
ref={dragBindings?.setActivatorNodeRef}
tier="parent"
data-sidebar-rename-row=""
level={stickyLevel}
Expand All @@ -1002,13 +1012,24 @@ function EnvironmentThreadGroupHeader({
}

return (
<div data-sidebar-rename-row="" className={className} style={style}>
<div
{...dragBindings?.attributes}
{...dragBindings?.listeners}
ref={dragBindings?.setActivatorNodeRef}
data-sidebar-rename-row=""
className={className}
style={style}
>
{content}
</div>
);
}

const EnvironmentThreadGroupRow = memo(function EnvironmentThreadGroupRow({
sectionDnd,
dragBindings,
sortableRef,
sortableStyle,
projectId,
environmentThreadGroup,
depthOffset,
Expand Down Expand Up @@ -1069,8 +1090,13 @@ const EnvironmentThreadGroupRow = memo(function EnvironmentThreadGroupRow({

return (
<>
<SidebarStickyGroup className="space-y-0.5">
<SidebarStickyGroup
ref={sortableRef}
style={sortableStyle}
className="space-y-0.5"
>
<EnvironmentThreadGroupHeader
dragBindings={dragBindings}
environmentId={environmentId}
environmentProviderId={environmentProviderId}
representativeThread={representativeThread}
Expand Down Expand Up @@ -1101,10 +1127,11 @@ const EnvironmentThreadGroupRow = memo(function EnvironmentThreadGroupRow({
return null;
}
return (
<ThreadTreeNodeRow
<SectionDndItemRow
key={node.thread.id}
projectId={projectId}
node={node}
item={nodeItems[index]}
sectionDnd={sectionDnd}
depthOffset={depthOffset + 1}
isEnvGrouped
selectedThreadId={selectedThreadId}
Expand All @@ -1126,6 +1153,7 @@ const EnvironmentThreadGroupRow = memo(function EnvironmentThreadGroupRow({
});

const ThreadTreeItemRow = memo(function ThreadTreeItemRow({
isEnvGrouped = false,
projectId,
item,
depthOffset,
Expand Down Expand Up @@ -1175,7 +1203,7 @@ const ThreadTreeItemRow = memo(function ThreadTreeItemRow({
projectId={projectId}
node={item.node}
depthOffset={depthOffset}
isEnvGrouped={false}
isEnvGrouped={isEnvGrouped}
selectedThreadId={selectedThreadId}
collapsedThreadIds={collapsedThreadIds}
collapsedEnvironmentIds={collapsedEnvironmentIds}
Expand All @@ -1196,6 +1224,10 @@ const ThreadTreeItemRow = memo(function ThreadTreeItemRow({
<EnvironmentThreadGroupRow
projectId={projectId}
environmentThreadGroup={item.group}
sectionDnd={sectionDnd}
dragBindings={dragBindings}
sortableRef={sortableRef}
sortableStyle={sortableStyle}
depthOffset={depthOffset}
selectedThreadId={selectedThreadId}
isCollapsed={collapsedEnvironmentIds.has(item.group.environmentId)}
Expand Down Expand Up @@ -1374,7 +1406,7 @@ const SectionTreeItemRow = memo(function SectionTreeItemRow({
sectionDnd.dragOverParentKey !== null &&
sectionDnd.dragOverParentKey !== sectionKey &&
sourceItemIds?.length === 1 &&
sourceItemIds[0] === sectionDnd.activeThread.id;
sourceItemIds[0] === sectionDnd.activeItemId;
const previewDepth = getThreadRowDepth({
depthOffset:
variant === "section" && depthOffset === 0 ? 0 : depthOffset + 1,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,89 @@ describe("useSectionThreadDnd nest projection", () => {
});
});

describe("worktree group drop collisions", () => {
it("keeps an empty Threads destination when hidden group children overlap the pointer", () => {
const rootItems = buildSectionThreadList(
[
createThread({
id: "first",
sectionId: "a",
environmentId: "env",
environmentIsWorktree: true,
createdAt: 3,
}),
createThread({
id: "second",
sectionId: "a",
environmentId: "env",
environmentIsWorktree: true,
createdAt: 2,
}),
createThread({
id: "child",
sectionId: "a",
environmentId: "env",
environmentIsWorktree: true,
parentThreadId: "second",
}),
],
undefined,
SECTIONS,
new Set(),
true,
);
const lookup = collectSectionThreadDndLookup(
rootItems,
CHRONOLOGICAL_CONTAINER_ID,
);
const activeId = [...lookup.groupThreadsByItemId.keys()][0];
const { result } = renderSectionThreadDnd(rootItems);
const props = () => result.current!.dndContextProps;
act(() => props().onDragStart?.(dragStart(activeId)));
act(() => props().onDragOver?.(dragOver(activeId, "threads")));
expect(result.current?.dragOverParentKey).toBe(CHRONOLOGICAL_CONTAINER_ID);

const sourceRect = {
top: 100,
left: 0,
width: 200,
height: 28,
right: 200,
bottom: 128,
};
const targetRect = { ...sourceRect, top: 40, bottom: 68 };
const sourceIds = [
activeId,
"first",
"second",
"child",
getSidebarThreadRowDroppableId("child"),
];
const droppableRects = new Map([
...sourceIds.map((id): [string, typeof sourceRect] => [id, sourceRect]),
[CHRONOLOGICAL_CONTAINER_ID, targetRect],
]);
for (const pointerCoordinates of [
{ x: 20, y: 114 },
{ x: 20, y: 150 },
]) {
const collisions = props().collisionDetection!({
active: { id: activeId },
collisionRect: sourceRect,
droppableRects,
droppableContainers: [...droppableRects.keys()].map((id) => ({ id })),
pointerCoordinates,
} as unknown as Parameters<CollisionDetection>[0]);
expect(collisions.map(({ id }) => id)).toEqual([
CHRONOLOGICAL_CONTAINER_ID,
]);
}
act(() =>
props().onDragCancel?.({ active: { id: activeId } } as DragCancelEvent),
);
});
});

describe("useSectionThreadDnd settled drop cleanup", () => {
const nestedRootItems = buildSectionThreadList(
[
Expand Down
113 changes: 113 additions & 0 deletions apps/app/src/components/sidebar/useSectionThreadDnd.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -705,3 +705,116 @@ describe("thread row nest collisions", () => {
).toEqual([groupCollision]);
});
});

describe("worktree group section dragging", () => {
function groupLookup() {
return collectSectionThreadDndLookup(
buildSectionThreadList(
[
createThread({
id: "first",
environmentId: "env",
environmentIsWorktree: true,
sectionId: "a",
createdAt: 10,
}),
createThread({
id: "second",
environmentId: "env",
environmentIsWorktree: true,
sectionId: "a",
createdAt: 9,
}),
createThread({
id: "child",
parentThreadId: "first",
environmentId: "env",
environmentIsWorktree: true,
sectionId: "a",
}),
createThread({
id: "other-section",
environmentId: "env",
environmentIsWorktree: true,
sectionId: "b",
}),
],
undefined,
[
{ id: "a", name: "A" },
{ id: "b", name: "B" },
],
new Set(),
true,
),
CHRONOLOGICAL_CONTAINER_ID,
);
}

it("moves only the represented group, including descendants, to another section", () => {
const lookup = groupLookup();
const activeId = [...lookup.groupThreadsByItemId.keys()][0];
const decision = resolveSectionThreadDropDecision(
lookup,
activeId,
"section:b",
);
expect(decision).toMatchObject({ kind: "move-group", sectionId: "b" });
expect(
decision?.kind === "move-group" && decision.threadIds.sort(),
).toEqual(["child", "first", "second"]);
});

it("moves the first and other group members independently", () => {
const lookup = groupLookup();
for (const id of ["first", "second"]) {
expect(
resolveSectionThreadDropDecision(lookup, id, "section:b"),
).toMatchObject({ kind: "move", activeId: id, sectionId: "b" });
}
expect(
resolveSectionThreadDropDecision(lookup, "child", "section:b"),
).toMatchObject({ kind: "detach", activeId: "child", sectionId: "b" });
expect(
resolveSectionThreadDropDecision(
lookup,
"first",
getSidebarThreadRowDroppableId("child"),
),
).toMatchObject({ kind: "rejected", reason: "own-subtree" });
expect(
resolveSectionThreadDropDecision(
lookup,
"second",
getSidebarThreadRowDroppableId("first"),
),
).toMatchObject({ kind: "nest", parentThreadId: "first" });
expect(
resolveSectionThreadDropDecision(lookup, "child", "pinned"),
).toMatchObject({ kind: "pin", detach: true });
});

it("allows moving back to Threads and ignores same-section and pinned drops", () => {
const lookup = groupLookup();
const activeId = [...lookup.groupThreadsByItemId.keys()][0];
expect(
resolveSectionThreadDropDecision(lookup, activeId, "threads"),
).toMatchObject({ kind: "move-group", sectionId: null });
expect(
resolveSectionThreadDropDecision(lookup, activeId, "section:a"),
).toBeNull();
expect(
resolveSectionThreadDropDecision(lookup, activeId, "pinned"),
).toBeNull();
expect(
resolveSectionThreadDropDecision(
lookup,
activeId,
"section:b",
null,
null,
{ groups: true },
),
).toBeNull();
});
});
Loading
Loading