Go to a folder by name, with g then o

Requested in #233. The `g` shortcuts cover the handful of folders every
account has -- inbox, sent, drafts -- and nothing reaches the dozens a Sieve
rule fills, which is where somebody with a real folder tree spends their time.
`g o` opens the picker, you type part of a name, and you are there.

The picker is the one the move action already uses, with one difference that
only shows up on shared mail: it selected folders by `mayAddItems`, which is
right for a destination and wrong for a place to go. A shared folder you may
read but not file into is somewhere you can visit. The right is now a
parameter, named for what it is asking rather than for which caller wants it.

Hosted in AppShell rather than in the mail view, because the `g` shortcuts are
global and the mail view is not mounted to hear about it -- pressing this from
the calendar should still take you to a folder, and now does.

`o` on its own opens a conversation and does not clash: a pending prefix is
tried before a bare key. That was already true and nothing said so, so there
are now five tests for the sequence machinery -- including that an abandoned
prefix costs the prefix and not the keystroke after it, which is the nicer
behaviour of the two and was undocumented.

Checked in a browser against the mock: opened from the calendar, filtered to a
nested folder, landed on it, and `o` still opened a conversation afterwards.

Closes #233.
This commit is contained in:
2026-09-02 13:03:55 -07:00
parent a8cf8ce3d7
commit 483aac849a
13 changed files with 149 additions and 5 deletions
@@ -0,0 +1,101 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { keyboard } from "@/lib/keyboard";
/*
* Two-key sequences against the single keys they start with.
*
* "Go to folder" is `g o` while `o` on its own opens a conversation (#233), so
* the whole feature rests on a pending prefix being tried before a bare key.
* That was true when it was written and nothing said so out loud, which is the
* kind of thing a later refactor quietly reverses.
*/
const press = (key: string) => {
const e = new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true });
window.dispatchEvent(e);
return e;
};
let pop: (() => void) | null = null;
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
pop?.();
pop = null;
vi.useRealTimers();
vi.restoreAllMocks();
});
describe("a sequence sharing its second key with a single binding", () => {
it("runs the sequence, not the single key", () => {
const seq = vi.fn();
const single = vi.fn();
pop = keyboard.pushScope("t", [
{ keys: "g o", description: "Go to folder", group: "Navigation", handler: seq },
{ keys: "o", description: "Open", group: "Mail", handler: single },
]);
press("g");
press("o");
expect(seq).toHaveBeenCalledOnce();
expect(single).not.toHaveBeenCalled();
});
it("runs the single key when no prefix is pending", () => {
const seq = vi.fn();
const single = vi.fn();
pop = keyboard.pushScope("t", [
{ keys: "g o", description: "Go to folder", group: "Navigation", handler: seq },
{ keys: "o", description: "Open", group: "Mail", handler: single },
]);
press("o");
expect(single).toHaveBeenCalledOnce();
expect(seq).not.toHaveBeenCalled();
});
it("forgets the prefix after a pause, so a later key means itself again", () => {
const seq = vi.fn();
const single = vi.fn();
pop = keyboard.pushScope("t", [
{ keys: "g o", description: "Go to folder", group: "Navigation", handler: seq },
{ keys: "o", description: "Open", group: "Mail", handler: single },
]);
press("g");
vi.advanceTimersByTime(2000);
press("o");
expect(seq).not.toHaveBeenCalled();
expect(single).toHaveBeenCalledOnce();
});
it("swallows the prefix rather than letting it act on its own", () => {
// `g` is not a binding by itself; pressing it must not fall through to
// anything, or holding it would type into the page.
const seq = vi.fn();
pop = keyboard.pushScope("t", [
{ keys: "g o", description: "Go to folder", group: "Navigation", handler: seq },
]);
const e = press("g");
expect(e.defaultPrevented).toBe(true);
expect(seq).not.toHaveBeenCalled();
});
it("lets a key that completes no sequence still act as itself", () => {
/*
* `g` then `z`, where `g z` is nothing. The prefix is dropped and `z` runs
* on that same press rather than being eaten — so a mistyped prefix costs
* the prefix and not the keystroke after it.
*/
const seq = vi.fn();
const single = vi.fn();
pop = keyboard.pushScope("t", [
{ keys: "g o", description: "Go to folder", group: "Navigation", handler: seq },
{ keys: "z", description: "Zed", group: "Mail", handler: single },
]);
press("g");
press("z");
expect(seq).not.toHaveBeenCalled();
expect(single).toHaveBeenCalledOnce();
});
});