Skip to content
Open
Show file tree
Hide file tree
Changes from 7 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
63 changes: 63 additions & 0 deletions apps/web/src/components/Sidebar.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,16 @@ import {
isContextMenuPointerDown,
isTrailingDoubleClick,
orderItemsByPreferredIds,
prioritizeThreadsByPinnedKeys,
resolveProjectStatusIndicator,
resolveSidebarNewThreadSeedContext,
resolveSidebarNewThreadEnvMode,
resolveSidebarStageBadgeLabel,
resolveThreadRowClassName,
resolveThreadStatusPill,
shouldClearThreadSelectionOnMouseDown,
shouldBlockThreadDragActivation,
resolveThreadPinCrossingAction,
sortProjectsForSidebar,
THREAD_JUMP_HINT_SHOW_DELAY_MS,
} from "./Sidebar.logic";
Expand All @@ -37,6 +40,66 @@ import {

const localEnvironmentId = EnvironmentId.make("environment-local");

describe("shouldBlockThreadDragActivation", () => {
it("blocks drag activation from interactive descendants", () => {
const input = { closest: vi.fn(() => ({})) } as unknown as HTMLElement;
const button = { closest: vi.fn(() => ({})) } as unknown as HTMLElement;
const row = { closest: vi.fn(() => null) } as unknown as HTMLElement;

expect(shouldBlockThreadDragActivation(input)).toBe(true);
expect(shouldBlockThreadDragActivation(button)).toBe(true);
expect(shouldBlockThreadDragActivation(row)).toBe(false);
});
});

describe("prioritizeThreadsByPinnedKeys", () => {
it("moves only pinned threads to the front while preserving the current sort order", () => {
const threads = [{ id: "newest" }, { id: "pinned" }, { id: "oldest" }];

expect(prioritizeThreadsByPinnedKeys(threads, ["pinned"], (thread) => thread.id)).toEqual([
{ id: "pinned" },
{ id: "newest" },
{ id: "oldest" },
]);
});
});

describe("resolveThreadPinCrossingAction", () => {
it("pins an unpinned thread after its center crosses above the divider", () => {
expect(
resolveThreadPinCrossingAction({
activeIsPinned: false,
draggedCenterY: 90,
dividerCenterY: 100,
}),
).toBe("pin");
expect(
resolveThreadPinCrossingAction({
activeIsPinned: false,
draggedCenterY: 110,
dividerCenterY: 100,
}),
).toBeNull();
});

it("unpins a pinned thread after its center crosses below the divider", () => {
expect(
resolveThreadPinCrossingAction({
activeIsPinned: true,
draggedCenterY: 110,
dividerCenterY: 100,
}),
).toBe("unpin");
expect(
resolveThreadPinCrossingAction({
activeIsPinned: true,
draggedCenterY: 90,
dividerCenterY: 100,
}),
).toBeNull();
});
});

describe("resolveSidebarStageBadgeLabel", () => {
it("returns Nightly for nightly primary server versions", () => {
expect(
Expand Down
33 changes: 33 additions & 0 deletions apps/web/src/components/Sidebar.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,32 @@ type SidebarProject = {

export type ThreadTraversalDirection = "previous" | "next";

export function prioritizeThreadsByPinnedKeys<T>(
threads: readonly T[],
pinnedKeys: readonly string[],
getKey: (thread: T) => string,
): T[] {
if (pinnedKeys.length === 0) return [...threads];
const pinned = new Set(pinnedKeys);
return [
...threads.filter((thread) => pinned.has(getKey(thread))),
...threads.filter((thread) => !pinned.has(getKey(thread))),
];
}

export type ThreadPinDropAction = "pin" | "unpin";

export function resolveThreadPinCrossingAction(input: {
activeIsPinned: boolean;
draggedCenterY: number;
dividerCenterY: number;
}): ThreadPinDropAction | null {
if (input.activeIsPinned) {
return input.draggedCenterY > input.dividerCenterY ? "unpin" : null;
}
return input.draggedCenterY < input.dividerCenterY ? "pin" : null;
}

export interface ThreadStatusPill {
label:
| "Working"
Expand Down Expand Up @@ -168,6 +194,13 @@ export function shouldClearThreadSelectionOnMouseDown(target: HTMLElement | null
return !target.closest(THREAD_SELECTION_SAFE_SELECTOR);
}

const THREAD_DRAG_BLOCKED_SELECTOR =
"input, textarea, select, button, a, [contenteditable='true'], [data-thread-selection-safe]";

export function shouldBlockThreadDragActivation(target: HTMLElement | null): boolean {
return target !== null && target.closest(THREAD_DRAG_BLOCKED_SELECTOR) !== null;
}

// A double-click dispatches two `click` events before `dblclick`: the first has
// `detail === 1`, the second `detail === 2`. The second click must not run the
// row's single-click navigation, otherwise double-click-to-rename would also
Expand Down
Loading
Loading