diff --git a/components/email/email-list.tsx b/components/email/email-list.tsx index 394384fc0..42914a057 100644 --- a/components/email/email-list.tsx +++ b/components/email/email-list.tsx @@ -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(); diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index 170d9c29a..2672d322e 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -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. diff --git a/hooks/__tests__/use-keyboard-shortcuts.test.ts b/hooks/__tests__/use-keyboard-shortcuts.test.ts index 7074abc79..9d20d9e0b 100644 --- a/hooks/__tests__/use-keyboard-shortcuts.test.ts +++ b/hooks/__tests__/use-keyboard-shortcuts.test.ts @@ -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(); + }); +}); diff --git a/hooks/use-keyboard-shortcuts.ts b/hooks/use-keyboard-shortcuts.ts index 8c8579114..30e9de777 100644 --- a/hooks/use-keyboard-shortcuts.ts +++ b/hooks/use-keyboard-shortcuts.ts @@ -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" }, diff --git a/locales/ar/common.json b/locales/ar/common.json index 0574e9019..b6333fac9 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -2625,7 +2625,8 @@ "help": "إظهار الاختصارات", "refresh": "تحديث الرسائل", "select_all": "تحديد الكل", - "global_search": "البحث في كل شيء" + "global_search": "البحث في كل شيء", + "extend_selection": "توسيع التحديد لأسفل / لأعلى" }, "threads": { "expand_collapse": "توسيع/طي المحادثة", diff --git a/locales/ca/common.json b/locales/ca/common.json index c37ce806e..166314246 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -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", diff --git a/locales/cs/common.json b/locales/cs/common.json index b86d7b6c0..be1175d69 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -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", diff --git a/locales/da/common.json b/locales/da/common.json index d564cd684..5e47ee71b 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -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", diff --git a/locales/de/common.json b/locales/de/common.json index 3ef83bf87..c0876d03b 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -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", diff --git a/locales/en/common.json b/locales/en/common.json index bea47352e..68028cbff 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -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", diff --git a/locales/es/common.json b/locales/es/common.json index 00af2bc88..9861f7bb3 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -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", diff --git a/locales/fa/common.json b/locales/fa/common.json index 8047731ff..15f3c42ce 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -2625,7 +2625,8 @@ "help": "نمایش میانبرها", "refresh": "بازنشانی ایمیل‌ها", "select_all": "انتخاب همه", - "global_search": "جستجو در همه جا" + "global_search": "جستجو در همه جا", + "extend_selection": "گسترش انتخاب به پایین / بالا" }, "threads": { "expand_collapse": "باز/بسته کردن رشته", diff --git a/locales/fr/common.json b/locales/fr/common.json index 6608cb9c3..7651271fe 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -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", diff --git a/locales/he/common.json b/locales/he/common.json index 852dd56b3..2a298f83c 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -2543,7 +2543,8 @@ "help": "הצג קיצורי דרך", "refresh": "רענן מיילים", "select_all": "בחר הכל", - "global_search": "חיפוש בכל מקום" + "global_search": "חיפוש בכל מקום", + "extend_selection": "הרחבת הבחירה למטה / למעלה" }, "threads": { "expand_collapse": "הרחב/כווץ את השרשור", diff --git a/locales/hu/common.json b/locales/hu/common.json index d680ae1b3..6edde12a6 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -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", diff --git a/locales/it/common.json b/locales/it/common.json index bb3de32e6..b19f0a543 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -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", diff --git a/locales/ja/common.json b/locales/ja/common.json index c4b4c64c9..bdd2cca20 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -2625,7 +2625,8 @@ "help": "ショートカットを表示", "refresh": "メールを更新", "select_all": "すべて選択", - "global_search": "すべてを検索" + "global_search": "すべてを検索", + "extend_selection": "選択範囲を下 / 上に広げる" }, "threads": { "expand_collapse": "スレッドの展開/折りたたみ", diff --git a/locales/ko/common.json b/locales/ko/common.json index 1f9ae6c00..aa75e5e85 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -2625,7 +2625,8 @@ "help": "단축키 보기", "refresh": "메일 새로고침", "select_all": "모두 선택", - "global_search": "전체 검색" + "global_search": "전체 검색", + "extend_selection": "선택 범위를 아래 / 위로 확장" }, "threads": { "expand_collapse": "대화 펼치기/접기", diff --git a/locales/lv/common.json b/locales/lv/common.json index 3b7b61d26..e91ecde6f 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -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", diff --git a/locales/mn/common.json b/locales/mn/common.json index a783015d8..b4b7e0d85 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -2625,7 +2625,8 @@ "help": "Товчлол харуулах", "refresh": "Имэйл сэргээх", "select_all": "Бүгдийг сонго", - "global_search": "Бүгдийг хайх" + "global_search": "Бүгдийг хайх", + "extend_selection": "Сонголтыг доош / дээш өргөтгөх" }, "threads": { "expand_collapse": "Хэлхээсийг дэлгэх/хумих", diff --git a/locales/nb/common.json b/locales/nb/common.json index bb5328de9..6d35a0a9e 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -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", diff --git a/locales/nl/common.json b/locales/nl/common.json index db6b242d4..2845b20d2 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -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", diff --git a/locales/pl/common.json b/locales/pl/common.json index d14172b27..e957fce90 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -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", diff --git a/locales/pt/common.json b/locales/pt/common.json index 784fd0456..6d92d4602 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -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", diff --git a/locales/ro/common.json b/locales/ro/common.json index 6890d530c..f0cf6b503 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -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", diff --git a/locales/ru/common.json b/locales/ru/common.json index 5df224fe4..2c4b9c474 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -2625,7 +2625,8 @@ "help": "Показать сочетания клавиш", "refresh": "Обновить письма", "select_all": "Выбрать все", - "global_search": "Искать везде" + "global_search": "Искать везде", + "extend_selection": "Расширить выделение вниз / вверх" }, "threads": { "expand_collapse": "Развернуть/свернуть цепочку", diff --git a/locales/sk/common.json b/locales/sk/common.json index b8ad26c65..216b75f86 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -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", diff --git a/locales/tr/common.json b/locales/tr/common.json index c76a6fb32..b40d8206f 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -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", diff --git a/locales/uk/common.json b/locales/uk/common.json index 3b507e5f2..580e25b0f 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -2625,7 +2625,8 @@ "help": "Показати ярлики", "refresh": "Оновити електронні листи", "select_all": "Вибрати все", - "global_search": "Шукати скрізь" + "global_search": "Шукати скрізь", + "extend_selection": "Розширити виділення вниз / вгору" }, "threads": { "expand_collapse": "Розгорнути/згорнути ланцюжок", diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index 7ec086cf6..593b9cfec 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -2625,7 +2625,8 @@ "help": "顯示快速鍵", "refresh": "重新整理郵件", "select_all": "全部選取", - "global_search": "全域搜尋" + "global_search": "全域搜尋", + "extend_selection": "向下 / 向上擴展選取" }, "threads": { "expand_collapse": "展開/收合對話串", diff --git a/locales/zh/common.json b/locales/zh/common.json index a735101ef..b8b0ab9c1 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -2625,7 +2625,8 @@ "help": "显示快捷方式", "refresh": "刷新邮件", "select_all": "全选", - "global_search": "全局搜索" + "global_search": "全局搜索", + "extend_selection": "向下 / 向上扩展选择" }, "threads": { "expand_collapse": "展开/折叠会话", diff --git a/stores/__tests__/email-selection.test.ts b/stores/__tests__/email-selection.test.ts index d46f0ff95..b5dbc7695 100644 --- a/stores/__tests__/email-selection.test.ts +++ b/stores/__tests__/email-selection.test.ts @@ -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(); diff --git a/stores/email-store.ts b/stores/email-store.ts index 14049f158..e508ca6b3 100644 --- a/stores/email-store.ts +++ b/stores/email-store.ts @@ -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((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((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((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: () => {