feat: extend the message selection with shift+arrow keys #992

This commit is contained in:
Linus Rath
2026-10-17 19:18:15 +02:00
parent b76624466f
commit 3fd93df4b2
33 changed files with 250 additions and 31 deletions
+9
View File
@@ -683,6 +683,15 @@ export function EmailList({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedEmailId]);
// Keep the moving end of a Shift+Arrow range on screen (#992).
const selectionFocusKey = useEmailStore((state) => state.selectionFocus?.focus ?? null);
useEffect(() => {
if (!selectionFocusKey) return;
const index = threadGroups.findIndex((thread) => thread.emails.some((e) => emailKeyFor(e) === selectionFocusKey));
if (index >= 0) virtualizer.scrollToIndex(index, { align: 'auto' });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectionFocusKey]);
// Re-measure all items when density or preview settings change
useEffect(() => {
virtualizer.measure();
+6
View File
@@ -1007,6 +1007,12 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
onDeselectAll: () => {
clearSelection();
},
// Shift + ↓/↑ (and Shift + j/k): grow or shrink the ticked range from the
// open message without opening each row on the way. (#992)
onExtendSelection: (direction: 1 | -1) => {
if (isScheduledView) return;
useEmailStore.getState().extendSelection(direction);
},
// `x` under Mountain View: put the focused conversation in or out of the
// selection. Only reachable there, where `x` is handed over from thread
// expansion.
@@ -41,3 +41,52 @@ describe('useKeyboardShortcuts: x toggles thread expansion', () => {
expect(onToggleThreadExpansion).not.toHaveBeenCalled();
});
});
// #992: Shift + arrow (or j/k) extends the selection instead of moving to the
// next message; plain arrows still move.
describe('useKeyboardShortcuts: Shift + arrow extends the selection', () => {
function press(key: string, code: string, shiftKey: boolean) {
document.dispatchEvent(new KeyboardEvent('keydown', { key, code, shiftKey, bubbles: true }));
}
it.each([
['ArrowDown', 'ArrowDown', 1],
['ArrowUp', 'ArrowUp', -1],
['J', 'KeyJ', 1],
['K', 'KeyK', -1],
] as const)('Shift + %s (%s) extends by %i', (key, code, direction) => {
const onExtendSelection = vi.fn();
const onNextEmail = vi.fn();
const onPreviousEmail = vi.fn();
renderHook(() =>
useKeyboardShortcuts({
emails: [],
selectedEmailId: 'e1',
handlers: { onExtendSelection, onNextEmail, onPreviousEmail },
}),
);
press(key, code, true);
expect(onExtendSelection).toHaveBeenCalledWith(direction);
expect(onNextEmail).not.toHaveBeenCalled();
expect(onPreviousEmail).not.toHaveBeenCalled();
});
it('plain ArrowDown still moves to the next message', () => {
const onExtendSelection = vi.fn();
const onNextEmail = vi.fn();
renderHook(() =>
useKeyboardShortcuts({
emails: [],
selectedEmailId: 'e1',
handlers: { onExtendSelection, onNextEmail },
}),
);
press('ArrowDown', 'ArrowDown', false);
expect(onNextEmail).toHaveBeenCalledTimes(1);
expect(onExtendSelection).not.toHaveBeenCalled();
});
});
+15 -2
View File
@@ -33,6 +33,8 @@ export interface KeyboardShortcutHandlers {
// Selection
onSelectAll?: () => void;
onDeselectAll?: () => void;
/** Shift + ↓/j and Shift + ↑/k: grow or shrink the selection by one row. */
onExtendSelection?: (direction: 1 | -1) => void;
// Thread actions
onToggleThreadExpansion?: () => void;
@@ -174,13 +176,23 @@ export function useKeyboardShortcuts({
case "j":
case "arrowdown":
event.preventDefault();
run("next-email", h.onNextEmail);
if (event.shiftKey && h.onExtendSelection) {
const extend = h.onExtendSelection;
run("extend-selection-down", () => extend(1));
} else {
run("next-email", h.onNextEmail);
}
break;
case "k":
case "arrowup":
event.preventDefault();
run("previous-email", h.onPreviousEmail);
if (event.shiftKey && h.onExtendSelection) {
const extend = h.onExtendSelection;
run("extend-selection-up", () => extend(-1));
} else {
run("previous-email", h.onPreviousEmail);
}
break;
case "enter":
@@ -396,6 +408,7 @@ export const KEYBOARD_SHORTCUTS = {
{ key: "?", description: "shortcuts.global.help" },
{ key: "Shift + G", description: "shortcuts.global.refresh" },
{ key: "Ctrl + A", description: "shortcuts.global.select_all" },
{ key: "Shift + ↓ / ↑", description: "shortcuts.global.extend_selection" },
],
threads: [
{ key: "x", description: "shortcuts.threads.expand_collapse", layout: "default" },
+2 -1
View File
@@ -2625,7 +2625,8 @@
"help": "إظهار الاختصارات",
"refresh": "تحديث الرسائل",
"select_all": "تحديد الكل",
"global_search": "البحث في كل شيء"
"global_search": "البحث في كل شيء",
"extend_selection": "توسيع التحديد لأسفل / لأعلى"
},
"threads": {
"expand_collapse": "توسيع/طي المحادثة",
+2 -1
View File
@@ -2625,7 +2625,8 @@
"help": "Mostra les dreceres",
"refresh": "Actualitza els correus",
"select_all": "Selecciona-ho tot",
"global_search": "Cerca-ho tot"
"global_search": "Cerca-ho tot",
"extend_selection": "Amplia la selecció cap avall / amunt"
},
"threads": {
"expand_collapse": "Desplega/replega el fil",
+2 -1
View File
@@ -2625,7 +2625,8 @@
"help": "Zobrazit zkratky",
"refresh": "Obnovit zprávy",
"select_all": "Vybrat vše",
"global_search": "Hledat ve všem"
"global_search": "Hledat ve všem",
"extend_selection": "Rozšířit výběr dolů / nahoru"
},
"threads": {
"expand_collapse": "Rozbalit/sbalit vlákno",
+2 -1
View File
@@ -2625,7 +2625,8 @@
"help": "Vis genveje",
"refresh": "Opdatér e-mails",
"select_all": "Vælg alle",
"global_search": "Søg i alt"
"global_search": "Søg i alt",
"extend_selection": "Udvid markering ned / op"
},
"threads": {
"expand_collapse": "Udvid/skjul tråd",
+2 -1
View File
@@ -2625,7 +2625,8 @@
"help": "Tastaturkürzel anzeigen",
"refresh": "E-Mails aktualisieren",
"select_all": "Alle auswählen",
"global_search": "Alles durchsuchen"
"global_search": "Alles durchsuchen",
"extend_selection": "Auswahl nach unten / oben erweitern"
},
"threads": {
"expand_collapse": "Unterhaltung erweitern/einklappen",
+2 -1
View File
@@ -2625,7 +2625,8 @@
"help": "Show shortcuts",
"refresh": "Refresh emails",
"select_all": "Select all",
"global_search": "Search everything"
"global_search": "Search everything",
"extend_selection": "Extend selection down / up"
},
"threads": {
"expand_collapse": "Expand/collapse thread",
+2 -1
View File
@@ -2625,7 +2625,8 @@
"help": "Mostrar atajos",
"refresh": "Actualizar correos",
"select_all": "Seleccionar todo",
"global_search": "Buscar en todo"
"global_search": "Buscar en todo",
"extend_selection": "Ampliar la selección hacia abajo / arriba"
},
"threads": {
"expand_collapse": "Expandir/contraer conversación",
+2 -1
View File
@@ -2625,7 +2625,8 @@
"help": "نمایش میانبرها",
"refresh": "بازنشانی ایمیل‌ها",
"select_all": "انتخاب همه",
"global_search": "جستجو در همه جا"
"global_search": "جستجو در همه جا",
"extend_selection": "گسترش انتخاب به پایین / بالا"
},
"threads": {
"expand_collapse": "باز/بسته کردن رشته",
+2 -1
View File
@@ -2625,7 +2625,8 @@
"help": "Afficher les raccourcis",
"refresh": "Actualiser les emails",
"select_all": "Tout sélectionner",
"global_search": "Tout rechercher"
"global_search": "Tout rechercher",
"extend_selection": "Étendre la sélection vers le bas / le haut"
},
"threads": {
"expand_collapse": "Développer/réduire la conversation",
+2 -1
View File
@@ -2543,7 +2543,8 @@
"help": "הצג קיצורי דרך",
"refresh": "רענן מיילים",
"select_all": "בחר הכל",
"global_search": "חיפוש בכל מקום"
"global_search": "חיפוש בכל מקום",
"extend_selection": "הרחבת הבחירה למטה / למעלה"
},
"threads": {
"expand_collapse": "הרחב/כווץ את השרשור",
+2 -1
View File
@@ -2625,7 +2625,8 @@
"help": "Parancsok mutatása",
"refresh": "E-mailek frissítése",
"select_all": "Összes kijelölése",
"global_search": "Keresés mindenben"
"global_search": "Keresés mindenben",
"extend_selection": "Kijelölés bővítése lefelé / felfelé"
},
"threads": {
"expand_collapse": "Beszélgetés kibontása/összecsukása",
+2 -1
View File
@@ -2625,7 +2625,8 @@
"help": "Mostra scorciatoie",
"refresh": "Aggiorna messaggi",
"select_all": "Seleziona tutto",
"global_search": "Cerca ovunque"
"global_search": "Cerca ovunque",
"extend_selection": "Estendi la selezione in basso / in alto"
},
"threads": {
"expand_collapse": "Espandi/comprimi conversazione",
+2 -1
View File
@@ -2625,7 +2625,8 @@
"help": "ショートカットを表示",
"refresh": "メールを更新",
"select_all": "すべて選択",
"global_search": "すべてを検索"
"global_search": "すべてを検索",
"extend_selection": "選択範囲を下 / 上に広げる"
},
"threads": {
"expand_collapse": "スレッドの展開/折りたたみ",
+2 -1
View File
@@ -2625,7 +2625,8 @@
"help": "단축키 보기",
"refresh": "메일 새로고침",
"select_all": "모두 선택",
"global_search": "전체 검색"
"global_search": "전체 검색",
"extend_selection": "선택 범위를 아래 / 위로 확장"
},
"threads": {
"expand_collapse": "대화 펼치기/접기",
+2 -1
View File
@@ -2625,7 +2625,8 @@
"help": "Rādīt īsinājumtaustiņus",
"refresh": "Atsvaidzināt vēstules",
"select_all": "Atlasīt visas",
"global_search": "Meklēt visā"
"global_search": "Meklēt visā",
"extend_selection": "Paplašināt atlasi uz leju / uz augšu"
},
"threads": {
"expand_collapse": "Izvērst/sairt sarunu",
+2 -1
View File
@@ -2625,7 +2625,8 @@
"help": "Товчлол харуулах",
"refresh": "Имэйл сэргээх",
"select_all": "Бүгдийг сонго",
"global_search": "Бүгдийг хайх"
"global_search": "Бүгдийг хайх",
"extend_selection": "Сонголтыг доош / дээш өргөтгөх"
},
"threads": {
"expand_collapse": "Хэлхээсийг дэлгэх/хумих",
+2 -1
View File
@@ -2625,7 +2625,8 @@
"help": "Vis tastatursnarveier",
"refresh": "Oppdater e-poster",
"select_all": "Velg alle",
"global_search": "Søk i alt"
"global_search": "Søk i alt",
"extend_selection": "Utvid markeringen ned / opp"
},
"threads": {
"expand_collapse": "Utvid eller fold sammen tråden",
+2 -1
View File
@@ -2625,7 +2625,8 @@
"help": "Sneltoetsen tonen",
"refresh": "E-mails vernieuwen",
"select_all": "Alles selecteren",
"global_search": "Alles doorzoeken"
"global_search": "Alles doorzoeken",
"extend_selection": "Selectie omlaag / omhoog uitbreiden"
},
"threads": {
"expand_collapse": "Gesprek uitklappen/inklappen",
+2 -1
View File
@@ -2625,7 +2625,8 @@
"help": "Pokaż skróty",
"refresh": "Odśwież wiadomości",
"select_all": "Zaznacz wszystko",
"global_search": "Szukaj wszędzie"
"global_search": "Szukaj wszędzie",
"extend_selection": "Rozszerz zaznaczenie w dół / w górę"
},
"threads": {
"expand_collapse": "Rozwiń/zwiń wątek",
+2 -1
View File
@@ -2625,7 +2625,8 @@
"help": "Mostrar atalhos",
"refresh": "Atualizar e-mails",
"select_all": "Selecionar todos",
"global_search": "Pesquisar em tudo"
"global_search": "Pesquisar em tudo",
"extend_selection": "Expandir a seleção para baixo / cima"
},
"threads": {
"expand_collapse": "Expandir/recolher conversa",
+2 -1
View File
@@ -2625,7 +2625,8 @@
"help": "Afișați comenzile rapide",
"refresh": "Reîmprospătați e-mailurile",
"select_all": "Selectează tot",
"global_search": "Caută în tot"
"global_search": "Caută în tot",
"extend_selection": "Extinde selecția în jos / în sus"
},
"threads": {
"expand_collapse": "Extindeți/reduceți firul de discuție",
+2 -1
View File
@@ -2625,7 +2625,8 @@
"help": "Показать сочетания клавиш",
"refresh": "Обновить письма",
"select_all": "Выбрать все",
"global_search": "Искать везде"
"global_search": "Искать везде",
"extend_selection": "Расширить выделение вниз / вверх"
},
"threads": {
"expand_collapse": "Развернуть/свернуть цепочку",
+2 -1
View File
@@ -2625,7 +2625,8 @@
"help": "Zobraziť skratky",
"refresh": "Obnoviť e-maily",
"select_all": "Vybrať všetko",
"global_search": "Hľadať všade"
"global_search": "Hľadať všade",
"extend_selection": "Rozšíriť výber nadol / nahor"
},
"threads": {
"expand_collapse": "Rozbaliť/zbaliť vlákno",
+2 -1
View File
@@ -2625,7 +2625,8 @@
"help": "Kısayolları göster",
"refresh": "E-postaları yenile",
"select_all": "Tümünü seç",
"global_search": "Her yerde ara"
"global_search": "Her yerde ara",
"extend_selection": "Seçimi aşağı / yukarı genişlet"
},
"threads": {
"expand_collapse": "İleti dizisini genişlet/daralt",
+2 -1
View File
@@ -2625,7 +2625,8 @@
"help": "Показати ярлики",
"refresh": "Оновити електронні листи",
"select_all": "Вибрати все",
"global_search": "Шукати скрізь"
"global_search": "Шукати скрізь",
"extend_selection": "Розширити виділення вниз / вгору"
},
"threads": {
"expand_collapse": "Розгорнути/згорнути ланцюжок",
+2 -1
View File
@@ -2625,7 +2625,8 @@
"help": "顯示快速鍵",
"refresh": "重新整理郵件",
"select_all": "全部選取",
"global_search": "全域搜尋"
"global_search": "全域搜尋",
"extend_selection": "向下 / 向上擴展選取"
},
"threads": {
"expand_collapse": "展開/收合對話串",
+2 -1
View File
@@ -2625,7 +2625,8 @@
"help": "显示快捷方式",
"refresh": "刷新邮件",
"select_all": "全选",
"global_search": "全局搜索"
"global_search": "全局搜索",
"extend_selection": "向下 / 向上扩展选择"
},
"threads": {
"expand_collapse": "展开/折叠会话",
+63
View File
@@ -146,6 +146,69 @@ describe('email-store selection', () => {
});
});
// #992: Shift+Arrow grows the selection from the open message one row at a
// time and shrinks it again when the direction reverses.
describe('extendSelection', () => {
const keys = () => [...useEmailStore.getState().selectedEmailKeys].sort();
beforeEach(() => {
// Opening a message makes it the anchor, as selectEmail does.
useEmailStore.setState({ lastSelectedEmailKey: 'b', selectionFocus: null });
});
it('selects the open message and the next one on the first press', () => {
useEmailStore.getState().extendSelection(1);
expect(keys()).toEqual(['b', 'c']);
});
it('keeps growing in the same direction', () => {
useEmailStore.getState().extendSelection(1);
useEmailStore.getState().extendSelection(1);
expect(keys()).toEqual(['b', 'c', 'd']);
});
it('shrinks back when the direction reverses', () => {
useEmailStore.getState().extendSelection(1);
useEmailStore.getState().extendSelection(1);
useEmailStore.getState().extendSelection(-1);
expect(keys()).toEqual(['b', 'c']);
});
it('crosses the anchor and grows the other way', () => {
useEmailStore.getState().extendSelection(1);
useEmailStore.getState().extendSelection(-1);
useEmailStore.getState().extendSelection(-1);
expect(keys()).toEqual(['a', 'b']);
});
it('stops at the end of the list', () => {
for (let i = 0; i < 10; i++) useEmailStore.getState().extendSelection(1);
expect(keys()).toEqual(['b', 'c', 'd', 'e']);
});
it('continues a shift-click range', () => {
useEmailStore.getState().selectRangeEmails({ id: 'd' });
useEmailStore.getState().extendSelection(-1);
expect(keys()).toEqual(['b', 'c']);
});
it('walks list rows, taking a whole thread with its row', () => {
useEmailStore.setState({
emails: ['t2', 'x', 't1', 'y'].map((id) => makeEmail(id, id.startsWith('t') ? 'thread-t' : `thread-${id}`)),
listRows: [
{ key: 'x', members: ['x'] },
{ key: 't2', members: ['t2', 't1'] },
{ key: 'y', members: ['y'] },
],
lastSelectedEmailKey: 'x',
});
useEmailStore.getState().extendSelection(1);
expect(keys()).toEqual(['t1', 't2', 'x']);
useEmailStore.getState().extendSelection(-1);
expect(keys()).toEqual(['x']);
});
});
describe('selectAllEmails', () => {
it('should select all emails', () => {
useEmailStore.getState().selectAllEmails();
+54 -2
View File
@@ -283,7 +283,17 @@ interface EmailStore {
/** Take the email, not its id: the id alone collides across accounts. */
toggleEmailSelection: (email: SelectableEmail) => void;
selectRangeEmails: (target: SelectableEmail) => void;
/**
* Shift+Arrow (and Shift+J/K): grow or shrink the selection by one row,
* from the open or last ticked message, like a desktop mail client. (#992)
*/
extendSelection: (direction: 1 | -1) => void;
lastSelectedEmailKey: string | null;
/**
* The moving end of a keyboard range and the anchor it was taken from.
* Only valid while the anchor is still lastSelectedEmailKey.
*/
selectionFocus: { anchor: string; focus: string } | null;
/**
* The message list's rows in screen order, each with the keys a click on it
* selects. Shift-click ranges walk these, so a thread is in a range only
@@ -2140,6 +2150,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
selectedEmailKeys: new Set(),
retainedInViewIds: new Set(),
lastSelectedEmailKey: null,
selectionFocus: null,
listRows: null,
hasMoreEmails: false,
totalEmails: 0,
@@ -2411,7 +2422,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
for (let i = Math.min(anchorRow, targetRow); i <= Math.max(anchorRow, targetRow); i++) {
for (const key of listRows[i].members) newSelection.add(key);
}
set({ selectedEmailKeys: newSelection });
set({ selectedEmailKeys: newSelection, selectionFocus: { anchor: anchorKey, focus: targetKeyForRows } });
return;
}
}
@@ -2425,7 +2436,48 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
for (let i = start; i <= end; i++) {
newSelection.add(emailKeyFor(emails[i]));
}
set({ selectedEmailKeys: newSelection });
set({ selectedEmailKeys: newSelection, selectionFocus: { anchor: anchorKey, focus: targetKey } });
},
extendSelection: (direction) => {
const { emails, listRows, lastSelectedEmailKey, selectedEmail, selectedEmailKeys, selectionFocus } = get();
// Walk the rows on screen, as shift-click does; without them, the emails.
const rows: ListRow[] = listRows && listRows.length > 0
? listRows
: emails.map((e) => ({ key: emailKeyFor(e), members: [emailKeyFor(e)] }));
if (rows.length === 0) return;
const membersOf = (from: number, to: number) => {
const keys: string[] = [];
for (let i = Math.min(from, to); i <= Math.max(from, to); i++) keys.push(...rows[i].members);
return keys;
};
const anchorKey = lastSelectedEmailKey ?? (selectedEmail ? emailKeyFor(selectedEmail) : null);
const anchor = anchorKey ? findListRow(rows, anchorKey) : -1;
if (!anchorKey || anchor === -1) {
// Nothing to extend from: start with the row at the end the key points from.
const start = rows[direction > 0 ? 0 : rows.length - 1];
set({
selectedEmailKeys: new Set([...selectedEmailKeys, ...start.members]),
lastSelectedEmailKey: start.key,
selectionFocus: { anchor: start.key, focus: start.key },
});
return;
}
// Continue the range this anchor already has, or begin one at the anchor.
const previous = selectionFocus && selectionFocus.anchor === anchorKey && selectedEmailKeys.size > 0
? findListRow(rows, selectionFocus.focus)
: -1;
const focus = previous === -1 ? anchor : previous;
const nextFocus = Math.max(0, Math.min(rows.length - 1, focus + direction));
const newSelection = new Set(selectedEmailKeys);
if (previous !== -1) {
for (const key of membersOf(anchor, previous)) newSelection.delete(key);
}
for (const key of membersOf(anchor, nextFocus)) newSelection.add(key);
set({ selectedEmailKeys: newSelection, selectionFocus: { anchor: anchorKey, focus: rows[nextFocus].key } });
},
selectAllEmails: () => {