Skip to content

Commit df86b97

Browse files
fix(web): resolve logical-to-physical key mismatch in project drag reorder (#1904)
Co-authored-by: Julius Marminge <julius0216@outlook.com>
1 parent ade125b commit df86b97

File tree

3 files changed

+144
-14
lines changed

3 files changed

+144
-14
lines changed

apps/web/src/components/Sidebar.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2696,7 +2696,9 @@ export default function Sidebar() {
26962696
const activeProject = sidebarProjects.find((project) => project.projectKey === active.id);
26972697
const overProject = sidebarProjects.find((project) => project.projectKey === over.id);
26982698
if (!activeProject || !overProject) return;
2699-
reorderProjects(activeProject.projectKey, overProject.projectKey);
2699+
const activeMemberKeys = activeProject.memberProjectRefs.map(scopedProjectKey);
2700+
const overMemberKeys = overProject.memberProjectRefs.map(scopedProjectKey);
2701+
reorderProjects(activeMemberKeys, overMemberKeys);
27002702
},
27012703
[sidebarProjectSortOrder, reorderProjects, sidebarProjects],
27022704
);

apps/web/src/uiStateStore.test.ts

Lines changed: 108 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,11 +58,118 @@ describe("uiStateStore pure functions", () => {
5858
projectOrder: [project1, project2, project3],
5959
});
6060

61-
const next = reorderProjects(initialState, project1, project3);
61+
const next = reorderProjects(initialState, [project1], [project3]);
6262

6363
expect(next.projectOrder).toEqual([project2, project3, project1]);
6464
});
6565

66+
it("reorderProjects is a no-op when dragged key is not in projectOrder", () => {
67+
const project1 = ProjectId.make("project-1");
68+
const project2 = ProjectId.make("project-2");
69+
const initialState = makeUiState({
70+
projectOrder: [project1, project2],
71+
});
72+
73+
const next = reorderProjects(initialState, [ProjectId.make("missing")], [project2]);
74+
75+
expect(next).toBe(initialState);
76+
});
77+
78+
it("reorderProjects moves all member keys of a multi-member group together", () => {
79+
const keyALocal = "env-local:proj-a";
80+
const keyARemote = "env-remote:proj-a";
81+
const keyB = "env-local:proj-b";
82+
const keyC = "env-local:proj-c";
83+
const initialState = makeUiState({
84+
projectOrder: [keyALocal, keyARemote, keyB, keyC],
85+
});
86+
87+
const next = reorderProjects(initialState, [keyALocal, keyARemote], [keyC]);
88+
89+
expect(next.projectOrder).toEqual([keyB, keyC, keyALocal, keyARemote]);
90+
});
91+
92+
it("reorderProjects handles member keys scattered across projectOrder", () => {
93+
const keyALocal = "env-local:proj-a";
94+
const keyB = "env-local:proj-b";
95+
const keyARemote = "env-remote:proj-a";
96+
const keyC = "env-local:proj-c";
97+
const initialState = makeUiState({
98+
projectOrder: [keyALocal, keyB, keyARemote, keyC],
99+
});
100+
101+
const next = reorderProjects(initialState, [keyALocal, keyARemote], [keyC]);
102+
103+
expect(next.projectOrder).toEqual([keyB, keyC, keyALocal, keyARemote]);
104+
});
105+
106+
it("reorderProjects places group after target when dragged from before a non-last target", () => {
107+
const keyALocal = "env-local:proj-a";
108+
const keyARemote = "env-remote:proj-a";
109+
const keyB = "env-local:proj-b";
110+
const keyC = "env-local:proj-c";
111+
const keyD = "env-local:proj-d";
112+
const initialState = makeUiState({
113+
projectOrder: [keyALocal, keyARemote, keyB, keyC, keyD],
114+
});
115+
116+
const next = reorderProjects(initialState, [keyALocal, keyARemote], [keyC]);
117+
118+
expect(next.projectOrder).toEqual([keyB, keyC, keyALocal, keyARemote, keyD]);
119+
});
120+
121+
it("reorderProjects places group before target when dragged from after", () => {
122+
const keyB = "env-local:proj-b";
123+
const keyC = "env-local:proj-c";
124+
const keyALocal = "env-local:proj-a";
125+
const keyARemote = "env-remote:proj-a";
126+
const initialState = makeUiState({
127+
projectOrder: [keyB, keyC, keyALocal, keyARemote],
128+
});
129+
130+
const next = reorderProjects(initialState, [keyALocal, keyARemote], [keyB]);
131+
132+
expect(next.projectOrder).toEqual([keyALocal, keyARemote, keyB, keyC]);
133+
});
134+
135+
it("reorderProjects with multi-member target inserts after first target occurrence", () => {
136+
const keyALocal = "env-local:proj-a";
137+
const keyARemote = "env-remote:proj-a";
138+
const keyBLocal = "env-local:proj-b";
139+
const keyBRemote = "env-remote:proj-b";
140+
const initialState = makeUiState({
141+
projectOrder: [keyALocal, keyARemote, keyBLocal, keyBRemote],
142+
});
143+
144+
const next = reorderProjects(initialState, [keyALocal, keyARemote], [keyBLocal, keyBRemote]);
145+
146+
// Target members may become non-contiguous; this is fine because the
147+
// sidebar groups by logical key using first-occurrence positioning.
148+
expect(next.projectOrder).toEqual([keyBLocal, keyALocal, keyARemote, keyBRemote]);
149+
});
150+
151+
it("reorderProjects is a no-op when dragged group equals target group", () => {
152+
const key1 = "env-local:proj-a";
153+
const key2 = "env-remote:proj-a";
154+
const initialState = makeUiState({
155+
projectOrder: [key1, key2, "env-local:proj-b"],
156+
});
157+
158+
const next = reorderProjects(initialState, [key1, key2], [key1, key2]);
159+
160+
expect(next).toBe(initialState);
161+
});
162+
163+
it("reorderProjects is a no-op when dragged keys are not in projectOrder", () => {
164+
const initialState = makeUiState({
165+
projectOrder: ["env-local:proj-a", "env-local:proj-b"],
166+
});
167+
168+
const next = reorderProjects(initialState, ["env-local:missing"], ["env-local:proj-b"]);
169+
170+
expect(next).toBe(initialState);
171+
});
172+
66173
it("syncProjects preserves current project order during snapshot recovery", () => {
67174
const project1 = ProjectId.make("project-1");
68175
const project2 = ProjectId.make("project-2");

apps/web/src/uiStateStore.ts

Lines changed: 33 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -482,23 +482,41 @@ export function setProjectExpanded(state: UiState, projectId: string, expanded:
482482

483483
export function reorderProjects(
484484
state: UiState,
485-
draggedProjectId: string,
486-
targetProjectId: string,
485+
draggedProjectIds: readonly string[],
486+
targetProjectIds: readonly string[],
487487
): UiState {
488-
if (draggedProjectId === targetProjectId) {
488+
if (draggedProjectIds.length === 0) {
489489
return state;
490490
}
491-
const draggedIndex = state.projectOrder.findIndex((projectId) => projectId === draggedProjectId);
492-
const targetIndex = state.projectOrder.findIndex((projectId) => projectId === targetProjectId);
493-
if (draggedIndex < 0 || targetIndex < 0) {
491+
const draggedSet = new Set(draggedProjectIds);
492+
const targetSet = new Set(targetProjectIds);
493+
if (draggedProjectIds.every((id) => targetSet.has(id))) {
494494
return state;
495495
}
496+
497+
const originalTargetIndex = state.projectOrder.findIndex((id) => targetSet.has(id));
498+
if (originalTargetIndex < 0) {
499+
return state;
500+
}
501+
496502
const projectOrder = [...state.projectOrder];
497-
const [draggedProject] = projectOrder.splice(draggedIndex, 1);
498-
if (!draggedProject) {
503+
504+
const removed: string[] = [];
505+
let draggedBeforeTarget = 0;
506+
for (let i = projectOrder.length - 1; i >= 0; i--) {
507+
if (draggedSet.has(projectOrder[i]!)) {
508+
removed.unshift(projectOrder.splice(i, 1)[0]!);
509+
if (i < originalTargetIndex) {
510+
draggedBeforeTarget++;
511+
}
512+
}
513+
}
514+
if (removed.length === 0) {
499515
return state;
500516
}
501-
projectOrder.splice(targetIndex, 0, draggedProject);
517+
518+
const insertIndex = originalTargetIndex - Math.max(0, draggedBeforeTarget - 1);
519+
projectOrder.splice(insertIndex, 0, ...removed);
502520
return {
503521
...state,
504522
projectOrder,
@@ -514,7 +532,10 @@ interface UiStateStore extends UiState {
514532
setThreadChangedFilesExpanded: (threadId: string, turnId: string, expanded: boolean) => void;
515533
toggleProject: (projectId: string) => void;
516534
setProjectExpanded: (projectId: string, expanded: boolean) => void;
517-
reorderProjects: (draggedProjectId: string, targetProjectId: string) => void;
535+
reorderProjects: (
536+
draggedProjectIds: readonly string[],
537+
targetProjectIds: readonly string[],
538+
) => void;
518539
}
519540

520541
export const useUiStateStore = create<UiStateStore>((set) => ({
@@ -531,8 +552,8 @@ export const useUiStateStore = create<UiStateStore>((set) => ({
531552
toggleProject: (projectId) => set((state) => toggleProject(state, projectId)),
532553
setProjectExpanded: (projectId, expanded) =>
533554
set((state) => setProjectExpanded(state, projectId, expanded)),
534-
reorderProjects: (draggedProjectId, targetProjectId) =>
535-
set((state) => reorderProjects(state, draggedProjectId, targetProjectId)),
555+
reorderProjects: (draggedProjectIds, targetProjectIds) =>
556+
set((state) => reorderProjects(state, draggedProjectIds, targetProjectIds)),
536557
}));
537558

538559
useUiStateStore.subscribe((state) => debouncedPersistState.maybeExecute(state));

0 commit comments

Comments
 (0)