feat: refactor sidebar to nested draggable lists with long-press gesture arbitration
This commit is contained in:
@@ -0,0 +1,78 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
decideLongPressMove,
|
||||
shouldOpenContextMenuOnPressOut,
|
||||
} from "./sidebar-gesture-arbitration";
|
||||
|
||||
describe("decideLongPressMove", () => {
|
||||
it("keeps long press pending for small movement before long-press arm", () => {
|
||||
expect(
|
||||
decideLongPressMove({
|
||||
longPressArmed: false,
|
||||
didStartDrag: false,
|
||||
startPoint: { x: 0, y: 0 },
|
||||
currentPoint: { x: 3, y: 2 },
|
||||
})
|
||||
).toBe("none");
|
||||
});
|
||||
|
||||
it("cancels long press when movement exceeds cancel slop before arm", () => {
|
||||
expect(
|
||||
decideLongPressMove({
|
||||
longPressArmed: false,
|
||||
didStartDrag: false,
|
||||
startPoint: { x: 0, y: 0 },
|
||||
currentPoint: { x: 12, y: 0 },
|
||||
})
|
||||
).toBe("cancel_long_press");
|
||||
});
|
||||
|
||||
it("starts drag when movement exceeds drag slop after long-press arm", () => {
|
||||
expect(
|
||||
decideLongPressMove({
|
||||
longPressArmed: true,
|
||||
didStartDrag: false,
|
||||
startPoint: { x: 0, y: 0 },
|
||||
currentPoint: { x: 0, y: 9 },
|
||||
})
|
||||
).toBe("start_drag");
|
||||
});
|
||||
|
||||
it("does nothing when drag already started", () => {
|
||||
expect(
|
||||
decideLongPressMove({
|
||||
longPressArmed: true,
|
||||
didStartDrag: true,
|
||||
startPoint: { x: 0, y: 0 },
|
||||
currentPoint: { x: 20, y: 20 },
|
||||
})
|
||||
).toBe("none");
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldOpenContextMenuOnPressOut", () => {
|
||||
it("opens menu only when long-press is armed and drag did not start", () => {
|
||||
expect(
|
||||
shouldOpenContextMenuOnPressOut({
|
||||
longPressArmed: true,
|
||||
didStartDrag: false,
|
||||
})
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
shouldOpenContextMenuOnPressOut({
|
||||
longPressArmed: false,
|
||||
didStartDrag: false,
|
||||
})
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
shouldOpenContextMenuOnPressOut({
|
||||
longPressArmed: true,
|
||||
didStartDrag: true,
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
export type LongPressMoveDecision = "none" | "cancel_long_press" | "start_drag";
|
||||
|
||||
export function decideLongPressMove(input: {
|
||||
longPressArmed: boolean;
|
||||
didStartDrag: boolean;
|
||||
startPoint: { x: number; y: number } | null;
|
||||
currentPoint: { x: number; y: number };
|
||||
cancelSlopPx?: number;
|
||||
dragSlopPx?: number;
|
||||
}): LongPressMoveDecision {
|
||||
const cancelSlopPx = input.cancelSlopPx ?? 10;
|
||||
const dragSlopPx = input.dragSlopPx ?? 8;
|
||||
|
||||
if (!input.startPoint) {
|
||||
return "none";
|
||||
}
|
||||
if (input.didStartDrag) {
|
||||
return "none";
|
||||
}
|
||||
|
||||
const dx = input.currentPoint.x - input.startPoint.x;
|
||||
const dy = input.currentPoint.y - input.startPoint.y;
|
||||
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||
|
||||
if (!input.longPressArmed) {
|
||||
return distance > cancelSlopPx ? "cancel_long_press" : "none";
|
||||
}
|
||||
return distance > dragSlopPx ? "start_drag" : "none";
|
||||
}
|
||||
|
||||
export function shouldOpenContextMenuOnPressOut(input: {
|
||||
longPressArmed: boolean;
|
||||
didStartDrag: boolean;
|
||||
}): boolean {
|
||||
return input.longPressArmed && !input.didStartDrag;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { hasVisibleOrderChanged, mergeWithRemainder } from "./sidebar-reorder";
|
||||
|
||||
describe("hasVisibleOrderChanged", () => {
|
||||
it("returns false when visible order is unchanged", () => {
|
||||
expect(
|
||||
hasVisibleOrderChanged({
|
||||
currentOrder: ["a", "b", "c", "d"],
|
||||
reorderedVisibleKeys: ["a", "b", "c"],
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true when visible items are reordered", () => {
|
||||
expect(
|
||||
hasVisibleOrderChanged({
|
||||
currentOrder: ["a", "b", "c", "d"],
|
||||
reorderedVisibleKeys: ["b", "a", "c"],
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true when a visible key is missing from current order", () => {
|
||||
expect(
|
||||
hasVisibleOrderChanged({
|
||||
currentOrder: ["a", "b"],
|
||||
reorderedVisibleKeys: ["a", "c"],
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mergeWithRemainder", () => {
|
||||
it("appends non-visible stored keys after reordered visible keys", () => {
|
||||
expect(
|
||||
mergeWithRemainder({
|
||||
currentOrder: ["a", "x", "b", "y"],
|
||||
reorderedVisibleKeys: ["b", "a"],
|
||||
})
|
||||
).toEqual(["b", "a", "x", "y"]);
|
||||
});
|
||||
|
||||
it("keeps unknown current keys when no visible keys are reordered", () => {
|
||||
expect(
|
||||
mergeWithRemainder({
|
||||
currentOrder: ["stale", "hidden"],
|
||||
reorderedVisibleKeys: [],
|
||||
})
|
||||
).toEqual(["stale", "hidden"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
export function mergeWithRemainder(input: {
|
||||
currentOrder: string[]
|
||||
reorderedVisibleKeys: string[]
|
||||
}): string[] {
|
||||
const reorderedSet = new Set(input.reorderedVisibleKeys)
|
||||
const remainder = input.currentOrder.filter((key) => !reorderedSet.has(key))
|
||||
return [...input.reorderedVisibleKeys, ...remainder]
|
||||
}
|
||||
|
||||
export function hasVisibleOrderChanged(input: {
|
||||
currentOrder: string[]
|
||||
reorderedVisibleKeys: string[]
|
||||
}): boolean {
|
||||
const visibleSet = new Set(input.reorderedVisibleKeys)
|
||||
const currentVisible = input.currentOrder.filter((key) => visibleSet.has(key))
|
||||
if (currentVisible.length !== input.reorderedVisibleKeys.length) {
|
||||
return true
|
||||
}
|
||||
return input.reorderedVisibleKeys.some((key, index) => currentVisible[index] !== key)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { SidebarProjectEntry, SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
|
||||
|
||||
import { buildSidebarWorkspaceViewModel } from "./sidebar-shortcuts";
|
||||
import { buildSidebarShortcutModel } from "./sidebar-shortcuts";
|
||||
|
||||
function workspace(serverId: string, cwd: string): SidebarWorkspaceEntry {
|
||||
return {
|
||||
@@ -27,20 +27,18 @@ function project(projectKey: string, workspaces: SidebarWorkspaceEntry[]): Sideb
|
||||
};
|
||||
}
|
||||
|
||||
describe("buildSidebarWorkspaceViewModel", () => {
|
||||
it("builds visible rows and shortcut targets in visual order", () => {
|
||||
describe("buildSidebarShortcutModel", () => {
|
||||
it("builds shortcut targets in visual order and excludes collapsed projects", () => {
|
||||
const projects = [
|
||||
project("p1", [workspace("s1", "/repo/main"), workspace("s1", "/repo/feat-a")]),
|
||||
project("p2", [workspace("s1", "/repo2/main")]),
|
||||
];
|
||||
|
||||
const model = buildSidebarWorkspaceViewModel({
|
||||
const model = buildSidebarShortcutModel({
|
||||
projects,
|
||||
collapsedProjectKeys: new Set<string>(["p2"]),
|
||||
getProjectDisplayName: (entry) => entry.projectName,
|
||||
});
|
||||
|
||||
expect(model.rows.map((row) => row.kind)).toEqual(["project", "workspace", "workspace", "project"]);
|
||||
expect(model.shortcutTargets).toEqual([
|
||||
{ serverId: "s1", workspaceId: "/repo/main" },
|
||||
{ serverId: "s1", workspaceId: "/repo/feat-a" },
|
||||
@@ -54,10 +52,9 @@ describe("buildSidebarWorkspaceViewModel", () => {
|
||||
const workspaces = Array.from({ length: 20 }, (_, index) => workspace("s", `/repo/w${index + 1}`));
|
||||
const projects = [project("p", workspaces)];
|
||||
|
||||
const model = buildSidebarWorkspaceViewModel({
|
||||
const model = buildSidebarShortcutModel({
|
||||
projects,
|
||||
collapsedProjectKeys: new Set<string>(),
|
||||
getProjectDisplayName: (entry) => entry.projectName,
|
||||
});
|
||||
|
||||
expect(model.shortcutTargets).toHaveLength(9);
|
||||
|
||||
@@ -8,46 +8,21 @@ export interface SidebarShortcutWorkspaceTarget {
|
||||
workspaceId: string
|
||||
}
|
||||
|
||||
export type SidebarWorkspaceTreeRow =
|
||||
| {
|
||||
kind: 'project'
|
||||
rowKey: string
|
||||
project: SidebarProjectEntry
|
||||
displayName: string
|
||||
}
|
||||
| {
|
||||
kind: 'workspace'
|
||||
rowKey: string
|
||||
projectKey: string
|
||||
workspace: SidebarWorkspaceEntry
|
||||
shortcutNumber: number | null
|
||||
}
|
||||
|
||||
export interface SidebarWorkspaceViewModel {
|
||||
rows: SidebarWorkspaceTreeRow[]
|
||||
export interface SidebarShortcutModel {
|
||||
shortcutTargets: SidebarShortcutWorkspaceTarget[]
|
||||
shortcutIndexByWorkspaceKey: Map<string, number>
|
||||
}
|
||||
|
||||
export function buildSidebarWorkspaceViewModel(input: {
|
||||
export function buildSidebarShortcutModel(input: {
|
||||
projects: SidebarProjectEntry[]
|
||||
collapsedProjectKeys: ReadonlySet<string>
|
||||
getProjectDisplayName: (project: SidebarProjectEntry) => string
|
||||
shortcutLimit?: number
|
||||
}): SidebarWorkspaceViewModel {
|
||||
}): SidebarShortcutModel {
|
||||
const maxShortcuts = Math.max(0, Math.floor(input.shortcutLimit ?? 9))
|
||||
const rows: SidebarWorkspaceTreeRow[] = []
|
||||
const shortcutTargets: SidebarShortcutWorkspaceTarget[] = []
|
||||
const shortcutIndexByWorkspaceKey = new Map<string, number>()
|
||||
|
||||
for (const project of input.projects) {
|
||||
rows.push({
|
||||
kind: 'project',
|
||||
rowKey: `project:${project.projectKey}`,
|
||||
project,
|
||||
displayName: input.getProjectDisplayName(project),
|
||||
})
|
||||
|
||||
if (input.collapsedProjectKeys.has(project.projectKey)) {
|
||||
continue
|
||||
}
|
||||
@@ -62,16 +37,8 @@ export function buildSidebarWorkspaceViewModel(input: {
|
||||
})
|
||||
shortcutIndexByWorkspaceKey.set(workspace.workspaceKey, shortcutNumber)
|
||||
}
|
||||
|
||||
rows.push({
|
||||
kind: 'workspace',
|
||||
rowKey: `workspace:${project.projectKey}:${workspace.workspaceKey}`,
|
||||
projectKey: project.projectKey,
|
||||
workspace,
|
||||
shortcutNumber,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return { rows, shortcutTargets, shortcutIndexByWorkspaceKey }
|
||||
return { shortcutTargets, shortcutIndexByWorkspaceKey }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user