Skip to content

Commit 2169cab

Browse files
author
PostHog Code
committed
feat(inbox): add Shift+Arrow keyboard selection for reports
Arrow Up/Down now extends selection when Shift is held, following the standard desktop pattern (Finder, Mail.app). A new `selectExactRange` store method replaces (rather than merges) the selection so that reversing direction correctly contracts the range. Generated-By: PostHog Code Task-Id: c0bd48e9-c803-4835-8e1d-a18797660c05
1 parent c9184bc commit 2169cab

3 files changed

Lines changed: 135 additions & 13 deletions

File tree

apps/code/src/renderer/features/inbox/components/InboxSignalsTab.tsx

Lines changed: 40 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,9 @@ export function InboxSignalsTab() {
146146
(s) => s.toggleReportSelection,
147147
);
148148
const selectRange = useInboxReportSelectionStore((s) => s.selectRange);
149+
const selectExactRange = useInboxReportSelectionStore(
150+
(s) => s.selectExactRange,
151+
);
149152
const pruneSelection = useInboxReportSelectionStore((s) => s.pruneSelection);
150153
const clearSelection = useInboxReportSelectionStore((s) => s.clearSelection);
151154

@@ -292,25 +295,49 @@ export function InboxSignalsTab() {
292295
}
293296
}, [focusListPane, showTwoPaneLayout]);
294297

298+
// Tracks the cursor position for keyboard navigation (the "moving end" of
299+
// Shift+Arrow selection). Separated from `lastClickedId` which acts as the
300+
// anchor so that the anchor stays fixed while the cursor extends the range.
301+
const keyboardCursorIdRef = useRef<string | null>(null);
302+
295303
const navigateReport = useCallback(
296-
(direction: 1 | -1) => {
304+
(direction: 1 | -1, shift: boolean) => {
297305
const list = reportsRef.current;
298306
if (list.length === 0) return;
299307

300-
// Find the current position based on the last selected report
301-
const currentIds = selectedReportIdsRef.current;
302-
const currentId =
303-
currentIds.length > 0 ? currentIds[currentIds.length - 1] : null;
304-
const currentIndex = currentId
305-
? list.findIndex((r) => r.id === currentId)
308+
// Determine cursor position — the item to navigate away from
309+
const cursorId =
310+
keyboardCursorIdRef.current ??
311+
(selectedReportIdsRef.current.length > 0
312+
? selectedReportIdsRef.current[
313+
selectedReportIdsRef.current.length - 1
314+
]
315+
: null);
316+
const cursorIndex = cursorId
317+
? list.findIndex((r) => r.id === cursorId)
306318
: -1;
307319
const nextIndex =
308-
currentIndex === -1
320+
cursorIndex === -1
309321
? 0
310-
: Math.max(0, Math.min(list.length - 1, currentIndex + direction));
322+
: Math.max(0, Math.min(list.length - 1, cursorIndex + direction));
311323
const nextId = list[nextIndex].id;
312324

313-
setSelectedReportIds([nextId]);
325+
if (shift) {
326+
// Anchor is the store's lastClickedId — the point where shift-selection started.
327+
// selectExactRange replaces the selection with the exact range from anchor to cursor,
328+
// so reversing direction correctly contracts the selection.
329+
const anchor =
330+
useInboxReportSelectionStore.getState().lastClickedId ?? nextId;
331+
selectExactRange(
332+
anchor,
333+
nextId,
334+
list.map((r) => r.id),
335+
);
336+
keyboardCursorIdRef.current = nextId;
337+
} else {
338+
setSelectedReportIds([nextId]);
339+
keyboardCursorIdRef.current = nextId;
340+
}
314341

315342
const container = leftPaneRef.current;
316343
const row = container?.querySelector<HTMLElement>(
@@ -326,7 +353,7 @@ export function InboxSignalsTab() {
326353
row.style.scrollMarginTop = `${stickyHeaderHeight}px`;
327354
row.scrollIntoView({ block: "nearest" });
328355
},
329-
[setSelectedReportIds],
356+
[setSelectedReportIds, selectExactRange],
330357
);
331358

332359
// Window-level keyboard handler so arrow keys work regardless of which
@@ -347,10 +374,10 @@ export function InboxSignalsTab() {
347374

348375
if (e.key === "ArrowDown") {
349376
e.preventDefault();
350-
navigateReport(1);
377+
navigateReport(1, e.shiftKey);
351378
} else if (e.key === "ArrowUp") {
352379
e.preventDefault();
353-
navigateReport(-1);
380+
navigateReport(-1, e.shiftKey);
354381
} else if (
355382
e.key === "Escape" &&
356383
selectedReportIdsRef.current.length > 0

apps/code/src/renderer/features/inbox/stores/inboxReportSelectionStore.test.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,4 +164,78 @@ describe("inboxReportSelectionStore", () => {
164164
expect(useInboxReportSelectionStore.getState().lastClickedId).toBe("r3");
165165
});
166166
});
167+
168+
describe("selectExactRange", () => {
169+
const orderedIds = ["r1", "r2", "r3", "r4", "r5"];
170+
171+
it("selects exactly the range from anchor to target", () => {
172+
useInboxReportSelectionStore
173+
.getState()
174+
.selectExactRange("r2", "r4", orderedIds);
175+
176+
expect(useInboxReportSelectionStore.getState().selectedReportIds).toEqual(
177+
["r2", "r3", "r4"],
178+
);
179+
});
180+
181+
it("replaces existing selection instead of merging", () => {
182+
useInboxReportSelectionStore.setState({
183+
selectedReportIds: ["r1", "r5"],
184+
});
185+
186+
useInboxReportSelectionStore
187+
.getState()
188+
.selectExactRange("r2", "r4", orderedIds);
189+
190+
expect(useInboxReportSelectionStore.getState().selectedReportIds).toEqual(
191+
["r2", "r3", "r4"],
192+
);
193+
});
194+
195+
it("keeps lastClickedId as the anchor", () => {
196+
useInboxReportSelectionStore
197+
.getState()
198+
.selectExactRange("r2", "r4", orderedIds);
199+
200+
expect(useInboxReportSelectionStore.getState().lastClickedId).toBe("r2");
201+
});
202+
203+
it("contracts selection when cursor moves back toward anchor", () => {
204+
// Simulate: anchor=r2, extend to r4, then contract back to r3
205+
useInboxReportSelectionStore
206+
.getState()
207+
.selectExactRange("r2", "r4", orderedIds);
208+
expect(useInboxReportSelectionStore.getState().selectedReportIds).toEqual(
209+
["r2", "r3", "r4"],
210+
);
211+
212+
useInboxReportSelectionStore
213+
.getState()
214+
.selectExactRange("r2", "r3", orderedIds);
215+
expect(useInboxReportSelectionStore.getState().selectedReportIds).toEqual(
216+
["r2", "r3"],
217+
);
218+
});
219+
220+
it("works in reverse direction", () => {
221+
useInboxReportSelectionStore
222+
.getState()
223+
.selectExactRange("r4", "r2", orderedIds);
224+
225+
expect(useInboxReportSelectionStore.getState().selectedReportIds).toEqual(
226+
["r2", "r3", "r4"],
227+
);
228+
expect(useInboxReportSelectionStore.getState().lastClickedId).toBe("r4");
229+
});
230+
231+
it("selects just the target when anchor is not in the ordered list", () => {
232+
useInboxReportSelectionStore
233+
.getState()
234+
.selectExactRange("r99", "r3", orderedIds);
235+
236+
expect(useInboxReportSelectionStore.getState().selectedReportIds).toEqual(
237+
["r3"],
238+
);
239+
});
240+
});
167241
});

apps/code/src/renderer/features/inbox/stores/inboxReportSelectionStore.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,13 @@ interface InboxReportSelectionActions {
1414
/** Select a contiguous range from the last-clicked report to `toId` within the given ordered list.
1515
* Existing selection outside the range is preserved (shift-click behavior). */
1616
selectRange: (toId: string, orderedIds: string[]) => void;
17+
/** Select exactly the contiguous range from `anchorId` to `toId`, replacing the entire selection.
18+
* Unlike `selectRange`, this does not merge with existing selection — used for Shift+Arrow keyboard navigation. */
19+
selectExactRange: (
20+
anchorId: string,
21+
toId: string,
22+
orderedIds: string[],
23+
) => void;
1724
isReportSelected: (reportId: string) => boolean;
1825
clearSelection: () => void;
1926
pruneSelection: (visibleReportIds: string[]) => void;
@@ -67,6 +74,20 @@ export const useInboxReportSelectionStore = create<InboxReportSelectionStore>()(
6774
return { selectedReportIds: merged, lastClickedId: toId };
6875
}),
6976

77+
selectExactRange: (anchorId, toId, orderedIds) =>
78+
set(() => {
79+
const anchorIndex = orderedIds.indexOf(anchorId);
80+
const toIndex = orderedIds.indexOf(toId);
81+
if (anchorIndex === -1 || toIndex === -1) {
82+
return { selectedReportIds: [toId], lastClickedId: toId };
83+
}
84+
const start = Math.min(anchorIndex, toIndex);
85+
const end = Math.max(anchorIndex, toIndex);
86+
const rangeIds = orderedIds.slice(start, end + 1);
87+
// Keep lastClickedId as the anchor — the caller manages cursor position
88+
return { selectedReportIds: rangeIds, lastClickedId: anchorId };
89+
}),
90+
7091
isReportSelected: (reportId) => get().selectedReportIds.includes(reportId),
7192

7293
clearSelection: () => set({ selectedReportIds: [], lastClickedId: null }),

0 commit comments

Comments
 (0)