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
35 changes: 31 additions & 4 deletions packages/ui/src/__tests__/session-history-multi-select.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -76,13 +76,13 @@ function installDomStubs(window: ReturnType<typeof parseHTML>['window']): void {
*/
function pointerEvent(
window: ReturnType<typeof parseHTML>['window'],
type: 'click' | 'contextmenu',
type: 'click' | 'contextmenu' | 'pointerdown' | 'pointerup',
modifiers: Record<string, unknown> = {},
): Event {
const event = new window.Event(type, { bubbles: true, cancelable: true });
Object.assign(event, {
detail: 1,
button: type === 'contextmenu' ? 2 : 0,
button: type === 'contextmenu' || type.startsWith('pointer') ? 2 : 0,
metaKey: false,
ctrlKey: false,
shiftKey: false,
Expand Down Expand Up @@ -129,6 +129,8 @@ interface Harness {
archives: number;
document: Document;
clickRow(sessionId: string, modifiers?: Record<string, unknown>): Promise<void>;
beginRightClickRow(sessionId: string): Promise<boolean>;
endRightClickRow(sessionId: string): Promise<void>;
rightClickRow(sessionId: string): Promise<boolean>;
openRowMenu(sessionId: string): Promise<void>;
clickMenuItem(index: number): Promise<void>;
Expand Down Expand Up @@ -264,15 +266,32 @@ async function mount(
rowButton(sessionId).dispatchEvent(pointerEvent(window, 'click', modifiers));
});
},
rightClickRow: async (sessionId) => {
beginRightClickRow: async (sessionId) => {
const event = pointerEvent(window, 'contextmenu');
await act(() => {
rowButton(sessionId).dispatchEvent(pointerEvent(window, 'pointerdown'));
rowButton(sessionId).dispatchEvent(event);
});
// Whether the rail claimed the press. Unclaimed, it goes on to the native
// menu, which is the whole answer for a row the rail cannot act on.
return event.defaultPrevented;
},
endRightClickRow: async (sessionId) => {
await act(async () => {
rowButton(sessionId).dispatchEvent(pointerEvent(window, 'pointerup'));
await new Promise((resolve) => window.setTimeout(resolve, 0));
});
},
rightClickRow: async (sessionId) => {
const event = pointerEvent(window, 'contextmenu');
await act(async () => {
rowButton(sessionId).dispatchEvent(pointerEvent(window, 'pointerdown'));
rowButton(sessionId).dispatchEvent(event);
rowButton(sessionId).dispatchEvent(pointerEvent(window, 'pointerup'));
await new Promise((resolve) => window.setTimeout(resolve, 0));
});
return event.defaultPrevented;
},
openRowMenu: async (sessionId) => {
const trigger = document.querySelector(
`[data-session-id="${sessionId}"] .maka-session-row-action button`,
Expand Down Expand Up @@ -520,8 +539,16 @@ test('right-clicking a pickable row opens its menu', async () => {
// right-click cannot drift into two lists of items that disagree.
const harness = await mount({ selectedIds: ['b'] });
try {
const prevented = await harness.rightClickRow('b');
const prevented = await harness.beginRightClickRow('b');
assert.equal(prevented, true);
assert.equal(
harness.document
.querySelector('[data-session-id="b"] .maka-session-row-action')
?.getAttribute('data-menu-open'),
null,
'the unfinished secondary-button gesture must not light-dismiss the menu it opens',
);
await harness.endRightClickRow('b');
assert.equal(
harness.document
.querySelector('[data-session-id="b"] .maka-session-row-action')
Expand Down
44 changes: 44 additions & 0 deletions packages/ui/src/session-history-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -188,8 +188,43 @@ export function SessionHistoryList() {
const selection = useSessionRailSelection();
const locale = useUiLocale();
const listRef = useRef<HTMLDivElement>(null);
const secondaryPointerDownRef = useRef(false);
const pendingContextMenuTriggerRef = useRef<HTMLElement | null>(null);
const commands = selection?.commands;

useEffect(() => {
function handlePointerDown(event: PointerEvent) {
if (event.button === 2) secondaryPointerDownRef.current = true;
}

function handlePointerUp(event: PointerEvent) {
if (event.button !== 2) return;
secondaryPointerDownRef.current = false;
const trigger = pendingContextMenuTriggerRef.current;
pendingContextMenuTriggerRef.current = null;
// Run after the pointerup dispatch and its native light-dismiss default
// action have both completed. Opening inside this listener is still too
// early: the default action would dismiss the newly opened popover.
window.setTimeout(() => trigger?.click(), 0);
}

function cancelPendingContextMenu() {
secondaryPointerDownRef.current = false;
pendingContextMenuTriggerRef.current = null;
}

document.addEventListener('pointerdown', handlePointerDown, { capture: true });
document.addEventListener('pointerup', handlePointerUp, { capture: true });
document.addEventListener('pointercancel', cancelPendingContextMenu, { capture: true });
window.addEventListener('blur', cancelPendingContextMenu);
return () => {
document.removeEventListener('pointerdown', handlePointerDown, { capture: true });
document.removeEventListener('pointerup', handlePointerUp, { capture: true });
document.removeEventListener('pointercancel', cancelPendingContextMenu, { capture: true });
window.removeEventListener('blur', cancelPendingContextMenu);
};
}, []);

/**
* The rail's rendered order, read from the DOM at the moment of a click.
*
Expand Down Expand Up @@ -280,6 +315,15 @@ export function SessionHistoryList() {
const trigger = row.querySelector<HTMLElement>('.maka-session-row-action button');
event.preventDefault();
adoptForMenu(sessionId);
// Chromium fires `contextmenu` before the secondary-button pointerup on
// macOS. Opening a light-dismiss popover here lets that unfinished press
// dismiss the new menu as soon as the button is released. Other platforms
// may deliver `contextmenu` after pointerup, and keyboard invocations have
// no secondary press, so only defer while that pointer is observably down.
if (event.button === 2 && secondaryPointerDownRef.current) {
pendingContextMenuTriggerRef.current = trigger;
return;
}
// Opening the menu re-enters `handleListClickCapture` with a synthetic
// click, so the adoption is dispatched twice — harmless only because both
// of its branches are idempotent: `replace` sets the same one row, and
Expand Down