diff --git a/e2e/fixtures/maildir/alice@example.com/Inbox/cur/alice-inbox-10-multi-recipient:2,S b/e2e/fixtures/maildir/alice@example.com/Inbox/cur/alice-inbox-10-multi-recipient:2,S new file mode 100644 index 0000000..58b5374 --- /dev/null +++ b/e2e/fixtures/maildir/alice@example.com/Inbox/cur/alice-inbox-10-multi-recipient:2,S @@ -0,0 +1,12 @@ +From: Frank Team +To: alice@example.com, Bob Builder , Carol Finance +Subject: Team sync recap +Date: Sun, 24 May 2026 09:30:00 +0000 +Message-ID: +MIME-Version: 1.0 +Content-Type: text/plain; charset=utf-8 +Content-Transfer-Encoding: 8bit + +Thanks all for joining the sync. + +Action items are in the shared doc; please update your sections by Friday. diff --git a/e2e/fixtures/manifest.ts b/e2e/fixtures/manifest.ts index ede960c..2dd1d80 100644 --- a/e2e/fixtures/manifest.ts +++ b/e2e/fixtures/manifest.ts @@ -341,6 +341,23 @@ export const manifest: ManifestAccount[] = [ signature: 'unsigned', attachments: [] }, + { + // Multiple recipients incl. alice herself — exercises reader reply-all + // (Cc = other To/Cc recipients, own address excluded). + slug: 'alice-inbox-10-multi-recipient', + box: 'cur', + flags: 'S', + messageId: 'alice-inbox-10@example.com', + from: 'Frank Team', + fromAddr: 'frank@work.example', + to: 'alice@example.com, Bob Builder , Carol Finance ', + subject: 'Team sync recap', + date: utc(24, 9, 30), + bodyText: + 'Thanks all for joining the sync.\n\nAction items are in the shared doc; please update your sections by Friday.', + signature: 'unsigned', + attachments: [] + }, { slug: 'alice-inbox-xss-01-script-tag', box: 'cur', diff --git a/e2e/pages/ComposePage.ts b/e2e/pages/ComposePage.ts new file mode 100644 index 0000000..587811f --- /dev/null +++ b/e2e/pages/ComposePage.ts @@ -0,0 +1,47 @@ +/** Page object for the compose screen (incl. reply/forward prefill). */ +import { expect, type Locator, type Page } from '@playwright/test'; + +export class ComposePage { + constructor(private readonly page: Page) {} + + container(): Locator { + return this.page.getByTestId('compose.container'); + } + + /** Wait for the compose screen to be visible. */ + async waitVisible(): Promise { + await expect(this.container()).toBeVisible(); + } + + toInput(): Locator { + return this.page.getByTestId('compose.to-input'); + } + + ccInput(): Locator { + return this.page.getByTestId('compose.cc-input'); + } + + subjectInput(): Locator { + return this.page.getByTestId('compose.subject-input'); + } + + body(): Locator { + return this.page.getByTestId('compose.body'); + } + + async toValue(): Promise { + return (await this.toInput().inputValue()) ?? ''; + } + + async ccValue(): Promise { + return (await this.ccInput().inputValue()) ?? ''; + } + + async subjectValue(): Promise { + return (await this.subjectInput().inputValue()) ?? ''; + } + + async bodyValue(): Promise { + return (await this.body().inputValue()) ?? ''; + } +} diff --git a/e2e/pages/MailboxPage.ts b/e2e/pages/MailboxPage.ts index 10d51ce..750b8b5 100644 --- a/e2e/pages/MailboxPage.ts +++ b/e2e/pages/MailboxPage.ts @@ -57,6 +57,25 @@ export class MailboxPage { await this.page.keyboard.press('r'); } + /** The currently selected (active) row's 0-based index, or -1 if none. */ + async selectedIndex(): Promise { + const active = this.messages().and(this.page.locator('.active')).first(); + if ((await active.count()) === 0) return -1; + const idx = await active.getAttribute('data-msg-idx'); + return idx == null ? -1 : Number(idx); + } + + /** `g g` — jump selection to the top of the list. */ + async jumpTop(): Promise { + await this.page.keyboard.press('g'); + await this.page.keyboard.press('g'); + } + + /** `G` — jump selection to the bottom of the list. */ + async jumpBottom(): Promise { + await this.page.keyboard.press('Shift+G'); + } + // ── Search ───────────────────────────────────────────────────────────────── /** Open search (via `/` key), type a query, and submit it. */ diff --git a/e2e/pages/MessagePage.ts b/e2e/pages/MessagePage.ts index d5bf4da..5674bad 100644 --- a/e2e/pages/MessagePage.ts +++ b/e2e/pages/MessagePage.ts @@ -94,6 +94,62 @@ export class MessagePage { await this.page.keyboard.press('Escape'); } + // ── Message actions (reply / forward / yank / headers) ────────────────────── + // openspec/changes/hotkeys-improvement/specs/reader-message-actions/spec.md + + /** `r` — reply to sender (opens compose prefilled). */ + async reply(): Promise { + await this.page.keyboard.press('r'); + } + + /** `R` (Shift+r) — reply to all. */ + async replyAll(): Promise { + await this.page.keyboard.press('Shift+R'); + } + + /** `F` (Shift+f) — forward (distinct from `f` = hint mode). */ + async forward(): Promise { + await this.page.keyboard.press('Shift+F'); + } + + /** `y` — yank the message body to the clipboard. */ + async yankBody(): Promise { + await this.page.keyboard.press('y'); + } + + /** `Y` (Shift+y) — yank headers + body to the clipboard. */ + async yankHeaders(): Promise { + await this.page.keyboard.press('Shift+Y'); + } + + /** `g h` — toggle the headers popover. */ + async toggleHeaders(): Promise { + await this.page.keyboard.press('g'); + await this.page.keyboard.press('h'); + } + + /** `f` — activate vimium-style hint mode (text/simple modes only). */ + async activateHints(): Promise { + await this.page.keyboard.press('f'); + } + + /** The raw-headers popover (toggled by `g h` or the headers button). */ + headersPopover(): Locator { + return this.page.getByTestId('headers-popover.container'); + } + + /** `g f` — open the folder picker from the reader. */ + async gotoFolderPicker(): Promise { + await this.page.keyboard.press('g'); + await this.page.keyboard.press('f'); + } + + /** `g a` — open the account picker from the reader. */ + async gotoAccountPicker(): Promise { + await this.page.keyboard.press('g'); + await this.page.keyboard.press('a'); + } + // ── Folder-position counter ───────────────────────────────────────────────── /** Absolute message index number in the breadcrumb counter. */ diff --git a/e2e/specs/hotkeys-list-leader.spec.ts b/e2e/specs/hotkeys-list-leader.spec.ts index a9ea5fd..288520f 100644 --- a/e2e/specs/hotkeys-list-leader.spec.ts +++ b/e2e/specs/hotkeys-list-leader.spec.ts @@ -1,69 +1,67 @@ -/** List-scope `g X` leader sequences resolve folder names case-insensitively. */ +/** List-scope g-leader: trimmed to navigation primitives (g f / g a / g g / G). */ import { test, expect } from '../harness/fixtures.ts'; import { AccountsPage } from '../pages/AccountsPage.ts'; import { MailboxPage } from '../pages/MailboxPage.ts'; -import { manifest } from '../fixtures/manifest.ts'; +import { folderOf, manifest, messagesNewestFirst, PER_PAGE } from '../fixtures/manifest.ts'; const alice = manifest.find((a) => a.address === 'alice@example.com')!; +const archive = folderOf(alice, 'Archive'); -async function openAtArchive(page: import('@playwright/test').Page) { +async function openArchive(page: import('@playwright/test').Page): Promise { const accounts = new AccountsPage(page); await accounts.open(); await accounts.select(alice.address); const mailbox = new MailboxPage(page); await mailbox.openFolder('Archive'); await expect(page).toHaveURL(/\/folder\/Archive/); + // Park the cursor away from the rows — list rows set selectedIdx on + // mouseenter, which would otherwise race the keyboard-driven selection. + await page.mouse.move(0, 0); + return mailbox; } -// openspec/changes/isolate-hotkeys/specs/ui-hotkeys/spec.md: list `g i` -> Inbox -test('g i navigates to Inbox', async ({ page }) => { - await openAtArchive(page); +// openspec/changes/hotkeys-improvement/specs/ui-hotkeys/spec.md: g f opens the folder picker +test('g f opens the folder picker', async ({ page }) => { + await openArchive(page); await page.keyboard.press('g'); - await page.keyboard.press('i'); - await expect(page).toHaveURL(/\/folder\/Inbox/); + await page.keyboard.press('f'); + await expect(page.getByText('Open a folder')).toBeVisible(); }); -// openspec/changes/isolate-hotkeys/specs/ui-hotkeys/spec.md: list `g a` -> Archive -test('g a navigates to Archive', async ({ page }) => { - const accounts = new AccountsPage(page); - await accounts.open(); - await accounts.select(alice.address); - const mailbox = new MailboxPage(page); - await mailbox.openFolder('Inbox'); +// openspec/changes/hotkeys-improvement/specs/ui-hotkeys/spec.md: g a opens the account picker +test('g a opens the account picker', async ({ page }) => { + await openArchive(page); await page.keyboard.press('g'); await page.keyboard.press('a'); - await expect(page).toHaveURL(/\/folder\/Archive/); -}); - -// openspec/changes/isolate-hotkeys/specs/ui-hotkeys/spec.md: list `g s` -> Sent -test('g s navigates to Sent', async ({ page }) => { - await openAtArchive(page); - await page.keyboard.press('g'); - await page.keyboard.press('s'); - await expect(page).toHaveURL(/\/folder\/Sent/); + await expect(page.getByText('Open a maildir')).toBeVisible(); }); -// openspec/changes/isolate-hotkeys/specs/ui-hotkeys/spec.md: list `g f` -> Folder picker -test('g f opens the folder picker', async ({ page }) => { - await openAtArchive(page); - await page.keyboard.press('g'); - await page.keyboard.press('f'); - await expect(page.getByText('Open a folder')).toBeVisible(); +// openspec/changes/hotkeys-improvement/specs/ui-hotkeys/spec.md: g g jumps to top of list +test('g g jumps the selection to the top of the list', async ({ page }) => { + const mailbox = await openArchive(page); + // Move the selection to the bottom (keyboard), then g g returns it to 0. + await mailbox.jumpBottom(); + await expect.poll(() => mailbox.selectedIndex()).toBeGreaterThan(0); + await mailbox.jumpTop(); + await expect.poll(() => mailbox.selectedIndex()).toBe(0); }); -// openspec/changes/isolate-hotkeys/specs/ui-hotkeys/spec.md: list `g A` -> Account picker -test('g A opens the account picker', async ({ page }) => { - await openAtArchive(page); - await page.keyboard.press('g'); - await page.keyboard.press('Shift+A'); - await expect(page.getByText('Open a maildir')).toBeVisible(); +// openspec/changes/hotkeys-improvement/specs/ui-hotkeys/spec.md: G jumps to bottom of list +test('G jumps the selection to the bottom of the page', async ({ page }) => { + const mailbox = await openArchive(page); + // Archive has more messages than a page; G selects the last rendered row. + const onPage = Math.min(messagesNewestFirst(archive).length, PER_PAGE); + await mailbox.jumpBottom(); + await expect.poll(() => mailbox.selectedIndex()).toBe(onPage - 1); }); -// openspec/changes/isolate-hotkeys/specs/ui-hotkeys/spec.md: list `g d` -> Drafts (silent no-op if absent) -test('g d does nothing when the account has no Drafts folder', async ({ page }) => { - await openAtArchive(page); - await page.keyboard.press('g'); - await page.keyboard.press('d'); - // alice has no Drafts folder in the corpus — URL stays on Archive. - await expect(page).toHaveURL(/\/folder\/Archive/); +// openspec/changes/hotkeys-improvement/specs/ui-hotkeys/spec.md: removed follow-ups no longer navigate +test('removed leaders g i / g s / g d are no-ops', async ({ page }) => { + await openArchive(page); + for (const key of ['i', 's', 'd']) { + await page.keyboard.press('g'); + await page.keyboard.press(key); + // No folder navigation occurs — URL stays on Archive. + await expect(page).toHaveURL(/\/folder\/Archive/); + } }); diff --git a/e2e/specs/reader-message-actions.spec.ts b/e2e/specs/reader-message-actions.spec.ts new file mode 100644 index 0000000..8b1a737 --- /dev/null +++ b/e2e/specs/reader-message-actions.spec.ts @@ -0,0 +1,127 @@ +/** Reader message actions: reply / reply-all / forward / yank / headers menu. */ +import { test, expect } from '../harness/fixtures.ts'; +import { AccountsPage } from '../pages/AccountsPage.ts'; +import { MailboxPage } from '../pages/MailboxPage.ts'; +import { MessagePage } from '../pages/MessagePage.ts'; +import { ComposePage } from '../pages/ComposePage.ts'; +import { folderOf, manifest } from '../fixtures/manifest.ts'; + +const alice = manifest.find((a) => a.address === 'alice@example.com')!; +const inbox = folderOf(alice, 'Inbox'); +// Multiple recipients incl. alice — drives reply / reply-all / forward / yank. +const multi = inbox.messages.find((m) => m.slug === 'alice-inbox-10-multi-recipient')!; +// Has a URL in its plain-text body — drives the `f` hint-mode coexistence check. +const linky = inbox.messages.find((m) => m.slug === 'alice-inbox-08-multipart-alt')!; +const firstBodyLine = multi.bodyText.split('\n')[0]; + +// Clipboard for the yank specs (assert via navigator.clipboard.readText()). +test.use({ permissions: ['clipboard-read', 'clipboard-write'] }); + +async function openMessage( + page: import('@playwright/test').Page, + subject: string +): Promise { + const accounts = new AccountsPage(page); + await accounts.open(); + await accounts.select(alice.address); + const mailbox = new MailboxPage(page); + await mailbox.openFolder('Inbox'); + await mailbox.openMessage(subject); + const reader = new MessagePage(page); + // Body loads on a separate tick; reply/yank need it populated. + await expect(reader.bodyLocator()).not.toBeEmpty(); + return reader; +} + +// openspec/changes/hotkeys-improvement/specs/reader-message-actions/spec.md: r opens compose addressed to sender, Re: subject, quoted body +test('r replies to sender with Re: subject and quoted body', async ({ page }) => { + const reader = await openMessage(page, multi.subject); + await reader.reply(); + + const compose = new ComposePage(page); + await compose.waitVisible(); + expect(await compose.toValue()).toContain(multi.fromAddr); + expect(await compose.subjectValue()).toBe(`Re: ${multi.subject}`); + expect(await compose.bodyValue()).toContain(`> ${firstBodyLine}`); +}); + +// openspec/changes/hotkeys-improvement/specs/reader-message-actions/spec.md: R populates To/Cc from participants, excludes own address +test('R replies to all, Cc from participants, excluding the active account', async ({ page }) => { + const reader = await openMessage(page, multi.subject); + await reader.replyAll(); + + const compose = new ComposePage(page); + await compose.waitVisible(); + expect(await compose.toValue()).toContain(multi.fromAddr); + const cc = await compose.ccValue(); + expect(cc).toContain('bob@work.example'); + expect(cc).toContain('carol@work.example'); + // alice@example.com was a recipient but is the active account — excluded. + expect(cc).not.toContain(alice.address); +}); + +// openspec/changes/hotkeys-improvement/specs/reader-message-actions/spec.md: F forwards with empty To, Fwd: subject, headers + body +test('F forwards with empty To, Fwd: subject, and forwarded headers + body', async ({ page }) => { + const reader = await openMessage(page, multi.subject); + await reader.forward(); + + const compose = new ComposePage(page); + await compose.waitVisible(); + expect(await compose.toValue()).toBe(''); + expect(await compose.subjectValue()).toBe(`Fwd: ${multi.subject}`); + const body = await compose.bodyValue(); + expect(body).toContain('From:'); + expect(body).toContain('Subject:'); + expect(body).toContain(firstBodyLine); +}); + +// openspec/changes/hotkeys-improvement/specs/reader-message-actions/spec.md: f still activates hint mode (distinct from F) +test('f still activates hint mode and does not forward', async ({ page }) => { + const reader = await openMessage(page, linky.subject); + await reader.activateHints(); + await expect(page.getByTestId('hint-overlay')).toBeVisible(); + // Hint mode, not forward — compose did not open. + await expect(page.getByTestId('compose.container')).toHaveCount(0); +}); + +// openspec/changes/hotkeys-improvement/specs/reader-message-actions/spec.md: y copies body only; Y copies headers + body +test('y copies the body only; Y copies headers and body', async ({ page }) => { + const reader = await openMessage(page, multi.subject); + + await reader.yankBody(); + const bodyOnly = await page.evaluate(() => navigator.clipboard.readText()); + expect(bodyOnly).toContain(firstBodyLine); + expect(bodyOnly).not.toContain('From:'); + + await reader.yankHeaders(); + const withHeaders = await page.evaluate(() => navigator.clipboard.readText()); + expect(withHeaders).toContain('From:'); + expect(withHeaders).toContain('To:'); + expect(withHeaders).toContain('Subject:'); + expect(withHeaders).toContain(firstBodyLine); +}); + +// openspec/changes/hotkeys-improvement/specs/reader-message-actions/spec.md: g h toggles the headers popover open/closed +test('g h toggles the headers menu open and closed', async ({ page }) => { + const reader = await openMessage(page, multi.subject); + + await reader.toggleHeaders(); + await expect(reader.headersPopover()).toBeVisible(); + + await reader.toggleHeaders(); + await expect(reader.headersPopover()).toHaveCount(0); +}); + +// openspec/changes/hotkeys-improvement/specs/ui-hotkeys/spec.md: g f opens the folder picker from the reader +test('g f opens the folder picker from the reader', async ({ page }) => { + const reader = await openMessage(page, multi.subject); + await reader.gotoFolderPicker(); + await expect(page.getByText('Open a folder')).toBeVisible(); +}); + +// openspec/changes/hotkeys-improvement/specs/ui-hotkeys/spec.md: g a opens the account picker from the reader +test('g a opens the account picker from the reader', async ({ page }) => { + const reader = await openMessage(page, multi.subject); + await reader.gotoAccountPicker(); + await expect(page.getByText('Open a maildir')).toBeVisible(); +}); diff --git a/mailbrus-core/src/maildir_reader.rs b/mailbrus-core/src/maildir_reader.rs index 0dbf314..e34c896 100644 --- a/mailbrus-core/src/maildir_reader.rs +++ b/mailbrus-core/src/maildir_reader.rs @@ -12,12 +12,26 @@ pub struct Message { pub struct Headers { pub from: Option, pub to: Vec, + pub cc: Vec, pub subject: Option, pub date: Option, pub message_id: Option, pub in_reply_to: Option, } +/// Split an RFC 5322 address-list header value (`To`/`Cc`) into individual +/// recipient strings, trimming whitespace and dropping empties. +/// +/// Splitting on `,` is intentionally simple to match how the reader displays +/// recipients; it does not attempt to honour commas inside quoted display +/// names. Reply-all only needs addressable recipients, which this preserves. +pub fn split_address_list(raw: &str) -> Vec { + raw.split(',') + .map(|p| p.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect() +} + pub struct MaildirFlags { pub seen: bool, pub replied: bool, @@ -204,18 +218,13 @@ fn extract_message(msg: ¬much::Message) -> Result { .map_err(|e| MailboxError::QueryFailed(e.to_string())) }; - let to = get_header("To")? - .map(|s| { - s.split(',') - .map(|p| p.trim().to_string()) - .filter(|s| !s.is_empty()) - .collect() - }) - .unwrap_or_default(); + let to = get_header("To")?.map(|s| split_address_list(&s)).unwrap_or_default(); + let cc = get_header("Cc")?.map(|s| split_address_list(&s)).unwrap_or_default(); let headers = Headers { from: get_header("From")?, to, + cc, subject: get_header("Subject")?, date: Some(msg.date()), message_id: get_header("Message-ID")?, @@ -272,6 +281,77 @@ Hello!\r\n"; )) } + #[test] + fn split_address_list_parses_multiple_recipients() { + let parsed = split_address_list("Bob , Carol "); + assert_eq!( + parsed, + vec!["Bob ", "Carol "] + ); + } + + #[test] + fn split_address_list_empty_header_is_empty() { + assert!(split_address_list("").is_empty()); + assert!(split_address_list(" ").is_empty()); + } + + #[test] + fn split_address_list_retains_own_address() { + // Reply-all needs to *see* the active account address so the frontend can + // exclude it; parsing must not drop it. + let parsed = + split_address_list("alice@example.com, Bob , ,carol@example.com"); + assert_eq!( + parsed, + vec!["alice@example.com", "Bob ", "carol@example.com"] + ); + } + + #[test] + fn extract_message_parses_to_and_cc() { + let dir = unique_tmpdir(); + let inbox = dir.join("account@test").join("Inbox").join("cur"); + fs::create_dir_all(&inbox).unwrap(); + let raw = b"From: Alice \r\n\ +To: Bob , Carol \r\n\ +Cc: Dave \r\n\ +Subject: Multi\r\n\ +Date: Thu, 01 Jan 2026 12:00:00 +0000\r\n\ +Message-ID: \r\n\ +\r\n\ +Hello!\r\n"; + let msg_path = inbox.join("multi001:2,S"); + fs::write(&msg_path, raw).unwrap(); + let db = notmuch::Database::create(&dir).unwrap(); + db.index_file(&msg_path, None).unwrap(); + drop(db); + + let reader = MaildirReader::new(&dir).unwrap(); + let (messages, _) = reader + .list_messages("*", SortBy::Newest, PaginationOpts { limit: 10, offset: 0 }) + .unwrap(); + let m = &messages[0]; + assert_eq!(m.headers.to, vec!["Bob ", "Carol "]); + assert_eq!(m.headers.cc, vec!["Dave "]); + + fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn extract_message_missing_cc_is_empty() { + let dir = unique_tmpdir(); + setup_test_db(&dir); // TEST_EMAIL has no Cc header + + let reader = MaildirReader::new(&dir).unwrap(); + let (messages, _) = reader + .list_messages("*", SortBy::Newest, PaginationOpts { limit: 10, offset: 0 }) + .unwrap(); + assert!(messages[0].headers.cc.is_empty()); + + fs::remove_dir_all(&dir).ok(); + } + #[test] fn list_messages_returns_indexed_messages() { let dir = unique_tmpdir(); diff --git a/mailbrus-core/src/sync/imap.rs b/mailbrus-core/src/sync/imap.rs index f09b94c..f8d8f3f 100644 --- a/mailbrus-core/src/sync/imap.rs +++ b/mailbrus-core/src/sync/imap.rs @@ -254,13 +254,17 @@ impl ImapWorker { let stored_modseq = if full_resync { None } else { stored.as_ref().and_then(|s| s.highest_modseq) }; - let use_condstore = condstore_supported && !full_resync && stored_modseq.is_some(); + // Gmail returns HIGHESTMODSEQ in SELECT responses but does not advertise + // CONDSTORE in its capability list, so also treat a present highest_modseq + // as evidence of CONDSTORE support. + let condstore_effective = condstore_supported || highest_modseq.is_some(); + let use_condstore = condstore_effective && !full_resync && stored_modseq.is_some(); let target_uids = if use_condstore { let modseq = stored_modseq.unwrap(); self.fetch_changed_uids(&mut client, modseq).await? } else { - if !condstore_supported { + if !condstore_supported && !condstore_effective { warn!(account = %self.account_id, "server does not advertise CONDSTORE; full UID scan"); } self.fetch_all_uids(&mut client).await? diff --git a/mailbrus-server/src/mime.rs b/mailbrus-server/src/mime.rs index 64a84c7..c25665c 100644 --- a/mailbrus-server/src/mime.rs +++ b/mailbrus-server/src/mime.rs @@ -159,6 +159,21 @@ pub fn extract_message(raw: &[u8]) -> Option { }) } +/// Collect the recipients of a `To`/`Cc` style header out of the parsed header +/// map into a flat list of addressable strings. +fn recipient_list(headers: &Map, key: &str) -> Vec { + headers + .get(key) + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str()) + .flat_map(mailbrus_core::maildir_reader::split_address_list) + .collect() + }) + .unwrap_or_default() +} + pub fn build_body_response(id: &str, parsed: ParsedMessage, mode: &str) -> Value { let ParsedMessage { headers, @@ -200,6 +215,12 @@ pub fn build_body_response(id: &str, parsed: ParsedMessage, mode: &str) -> Value _ => (text_body, 0), }; + // Structured recipient lists for reply-all on the client. The raw `To`/`Cc` + // header strings live in `headers`; split them into addressable recipients + // using the same parser the list path uses. + let to = recipient_list(&headers, "To"); + let cc = recipient_list(&headers, "Cc"); + debug!( "[mime] build_body_response id={} mode={} has_plain={} has_html={} has_remote={}", id, resolved_mode, has_plain, has_html, has_remote @@ -209,6 +230,8 @@ pub fn build_body_response(id: &str, parsed: ParsedMessage, mode: &str) -> Value "id": id, "headers": headers, "body": body, + "to": to, + "cc": cc, "mode": resolved_mode, "has_plain": has_plain, "has_html": has_html, diff --git a/openspec/changes/hotkeys-improvement/.openspec.yaml b/openspec/changes/hotkeys-improvement/.openspec.yaml new file mode 100644 index 0000000..95ae5a2 --- /dev/null +++ b/openspec/changes/hotkeys-improvement/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-06-18 diff --git a/openspec/changes/hotkeys-improvement/design.md b/openspec/changes/hotkeys-improvement/design.md new file mode 100644 index 0000000..927e64f --- /dev/null +++ b/openspec/changes/hotkeys-improvement/design.md @@ -0,0 +1,129 @@ +## Context + +The reader (`src/lib/components/Reader.svelte`) renders an opened message and registers +a `reader`-scope keymap via `createReaderKeymap` (`src/lib/hotkeys/keymaps/reader.ts`). +The list registers `createListKeymap` in `src/routes/[...path]/+page.svelte`. Compose +(`Compose.svelte`) is a separate phase toggled by `ui.composeOpen` and initializes its +`to/cc/bcc/subject/body` from empty local `$state` — it has **no prefill path** today. + +Two relevant constraints surfaced while grounding this design: + +1. **No real recipient data on the client.** The `Message` model exposes `from`, `addr`, + `subject`, `time`, `unread`, `flags` — but no `To`/`Cc` list. `buildHeaders()` in + `utils.ts` *fabricates* `To`, `Received`, `Message-ID`, etc. for display. Reply-all + (`R`) needs the original recipients, which are not available frontend-side. +2. **Clipboard requires a secure context / permission.** `navigator.clipboard.writeText` + works in the SPA and the Tauri webview but needs the page served over a secure origin; + Playwright must be granted `clipboard-read`/`clipboard-write`. + +This change is frontend-only in its happy path (keymap edits + a reply/quote helper + +clipboard), with one possible backend touch for reply-all recipients (see Open Questions). + +## Goals / Non-Goals + +**Goals:** +- Trim the list g-leader to `g f` / `g a` / `g g` (+ `G`), removing `g i`/`g s`/`g d` and + the old `g a`=Archive / `g A`=account picker. +- Add reader actions `r` (reply), `R` (reply-all), `F` (forward), `y` (yank body), + `Y` (yank body+headers), and `g h` (toggle headers menu). +- Keep `f` = hint mode in the reader; `F` is a distinct binding. +- Pure, unit-testable reply/forward/quote construction; E2E for the navigation leaders + and the reader actions. + +**Non-Goals:** +- Threaded/conversation reply UI; reply reuses the existing compose phase. +- User-remappable keybindings. +- Rich-clipboard (HTML/markdown) — yank is plain text. +- Changing list `r` (stays "mark read"). + +## Decisions + +### D1: Reply/forward construction lives in a pure helper (`src/lib/reply.ts`) +A pure module exports `buildReply(message, account, body, {all})` and +`buildForward(message, account, body, headers)` returning +`{ to, cc, subject, body }`. Subject prefixing (`Re:`/`Fwd:`, de-duplicated, +case-insensitive) and `> `-quoting live here. +- **Why:** keeps `Reader.svelte` thin and makes the quoting/subject rules unit-testable + without DOM. Alternative (inline in Reader) was rejected as untestable and duplicated + across r/R/F. + +### D2: Compose prefill via a shared `composePrefill` value in `ui-state` +Add `composePrefill: ComposeDraft | null` to `ui-state.svelte.ts`. The reader sets it and +flips `composeOpen`; `Compose.svelte` initializes its `$state` fields from it on mount, +then clears it. +- **Why:** mirrors the existing `ui.composeOpen` toggle pattern; no new prop-drilling + through `+page.svelte`. Alternatives considered: URL query params (pollutes routing) and + a custom event (harder to test). + +### D3: `g h` reuses the reader's existing `showHeaders` state +`Reader.svelte` already has `let showHeaders = $state(false)` driving `HeadersPopover`. +`g h` toggles it; the keymap gets a `['g','h']` leader binding alongside the existing +`['g','g']`. +- **Why:** no new component or state; the popover already exists. + +### D4: Yank uses `navigator.clipboard.writeText` +`y` copies the plain-text `body`. `Y` prepends `From`/`To`/`Subject` (+`Date`/`Cc` when +present) drawn from the same `buildHeaders()` rows shown in the popover, then a blank line, +then the body. +- **Why:** standard web API, works in both SPA and Tauri webview, no desktop-only plugin. + Alternative (Tauri clipboard plugin) rejected — adds a desktop-only dependency for + behavior the web API already covers. + +### D5: Keymap edits +- `list.ts`: remove `g i`/`g a`/`g s`/`g d` and `g A`; keep `g f`, `g g`, `G`; add + `g a` → account picker (reuse existing `goAccountPicker`). Prune now-unused ctx + callbacks (`goInbox`/`goArchive`/`goSent`/`goDrafts`). +- `reader.ts`: extend `ReaderKeymapCtx` with `reply`/`replyAll`/`forward`/`yankBody`/ + `yankHeaders`/`toggleHeaders` plus `goFolderPicker`/`goAccountPicker`; add bindings + `r`/`R`/`F`/`y`/`Y`, the `['g','h']` leader, and the `['g','f']`/`['g','a']` + navigation leaders (wired to the reader's existing `onFolder`/`onAccount` props). + Help content updates automatically (keymaps are the single source). + +### D6: The g-leader indicator is scope-aware +The on-screen `g` indicator lives in `+page.svelte` and renders while `phase` is +`list` — but the reader keeps `phase === 'list'` (only `openMessage` is set), so the +indicator shows over the reader too. It MUST therefore reflect the active scope's +follow-ups: on the list, `f folder · a account · g top` plus the standalone +`h prev-page · l next-page` page hints; with the reader open, `f folder · a account · +g top · h headers` and no page hints (`h`/`l` page-nav are list-scope only). +- **Why:** the previous fixed text advertised list bindings (`h prev-page`, + `l next-page`) that do not fire in the reader, and omitted the reader's `g h` + headers leader — confusing and wrong. Keying the indicator on `openMessage` + matches what actually dispatches. + +### Reply / forward flow + +```mermaid +flowchart LR + R[Reader: r/R/F] --> H[reply.ts buildReply/buildForward] + H --> P[ui.composePrefill = draft] + P --> O[ui.composeOpen = true] + O --> C[Compose mounts, seeds fields from prefill, clears it] +``` + +## Risks / Trade-offs + +- **Reply-all needs real recipients (resolved → backend change).** `R` requires the + original `To`/`Cc`, which the frontend `Message` model lacks. **Decision:** extend + `mailbrus-core` → `mailbrus-server` → the message API to expose the real `To`/`Cc` + headers for an opened message, and surface them on the frontend `Message`/reader data so + `buildReply(..., {all:true})` can populate `Cc` and drop the active account's address. + This is the one cross-cutting (backend) part of the change. +- **Synthetic headers in `buildHeaders`** → `Y` copies the *displayed* headers (the same + ones in the popover), which are partly synthetic. Acceptable: yank mirrors what the user + sees; it is not asserted to be the verbatim wire headers. +- **Clipboard permission in tests** → grant `clipboard-read`/`clipboard-write` in the + Playwright context for the yank specs; assert via `navigator.clipboard.readText()`. +- **HTML-only messages** → quoting/yank operate on the plain-text `body` the reader + already holds; for html-mode messages the reader's text body is used (no HTML quoting). + +## Migration Plan + +Frontend-only and additive for the reader actions; the g-leader change is **BREAKING** for +muscle memory only (no data/API). Ship in one change. Rollback = revert the keymap and +helper edits; no persisted state or schema is touched. + +## Open Questions + +- **Forward attachments:** forwarding currently carries body+headers text only — should + original attachments be re-attached? Proposed: out of scope for this change. diff --git a/openspec/changes/hotkeys-improvement/proposal.md b/openspec/changes/hotkeys-improvement/proposal.md new file mode 100644 index 0000000..6928330 --- /dev/null +++ b/openspec/changes/hotkeys-improvement/proposal.md @@ -0,0 +1,56 @@ +## Why + +The reader has no keyboard actions for the most common email operations — reply, +reply-all, forward, copy message text — forcing mouse use for everyday tasks. At the +same time the list g-leader carries five direct folder-jump bindings (`g i`/`g a`/`g s`/`g d` +plus `g A`) that overlap with the folder picker and crowd the namespace. This change +trims the g-leader to navigation primitives and gives the reader a vim-flavoured action +set. + +## What Changes + +- **List g-leader cleanup** — **BREAKING**: remove `g i` (Inbox), `g a` (Archive), + `g s` (Sent), `g d` (Drafts), and `g A` (account picker). Keep `g f` (folder picker), + `g g` (top), and `G` (bottom). Rebind the account picker from `g A` to `g a`. +- **Reader actions** (reader scope, new): + - `r` — reply to sender (opens compose prefilled). + - `R` — reply to all. + - `F` — forward (keeps `f` = hint mode; no collision). + - `y` — yank: copy the message body text to the clipboard. + - `Y` — yank with headers: copy `From`, `To`, `Subject` and other common headers + plus the body. + - `g h` — open the headers menu (the existing `HeadersPopover`). + - `g f` / `g a` — open the folder / account picker from the reader (mirrors the + list g-leader so navigation works without first quitting to the list). +- **Keyboard help** updates automatically from the keymaps (single-source-of-truth); + removed bindings drop out, new reader bindings appear in the Reader section. +- **E2E coverage** — new specs validating `g f` (folder selector), `g a` (account + selector), `g g` (top), and `G` (bottom). + +## Capabilities + +### New Capabilities +- `reader-message-actions`: reply / reply-all / forward (open compose prefilled and + quoted) and yank / yank-with-headers (clipboard copy) and headers-menu toggle, + invoked from the reader. + +### Modified Capabilities +- `ui-hotkeys`: trim the g-leader keymap (remove `g i`/`g a`/`g s`/`g d`/`g A`, + rebind account picker to `g a`), add reader-scope bindings `r`/`R`/`F`/`y`/`Y`/`g h`, + and require E2E coverage of the retained navigation leaders. + +## Impact + +- Frontend keymaps: `src/lib/hotkeys/keymaps/list.ts`, `keymaps/reader.ts`. +- Reader wiring: `src/lib/components/Reader.svelte`, `HeadersPopover.svelte`, compose + prefill path, clipboard helper. +- E2E: new specs under `e2e/specs/` plus page-object/manifest support. +- No backend or server-API changes anticipated; reply/forward reuse the existing + compose + SMTP path. + +## Non-goals + +- No threaded/conversation reply view; reply opens the existing compose screen. +- No configurable/user-remappable keybindings. +- No list-scope reply/forward (`r` stays "mark read" on the list). +- No rich clipboard formats (HTML/markdown); yank copies plain text. diff --git a/openspec/changes/hotkeys-improvement/specs/reader-message-actions/spec.md b/openspec/changes/hotkeys-improvement/specs/reader-message-actions/spec.md new file mode 100644 index 0000000..699a574 --- /dev/null +++ b/openspec/changes/hotkeys-improvement/specs/reader-message-actions/spec.md @@ -0,0 +1,99 @@ +## ADDED Requirements + +### Requirement: Reply to sender +The reader SHALL provide an `r` action that opens the compose screen prefilled as a +reply to the open message's sender. The `To` field SHALL be set to the original +message's `From` address. The `Subject` SHALL be the original subject prefixed with +`Re: `, not duplicated if the subject already begins with `Re:` (case-insensitive). The +compose body SHALL contain the original message body quoted, with each line prefixed by +`> ` (greater-than then a single space). `r` SHALL be active only when the reader is the +active scope and focus is not in a text input. + +#### Scenario: r opens compose addressed to the sender +- **WHEN** the reader is open on a message and the user presses `r` +- **THEN** the compose screen opens with the `To` field set to the original message's `From` address + +#### Scenario: Original body is quoted with "> " prefix +- **WHEN** the user presses `r` in the reader +- **THEN** the compose body contains the original message text with each line prefixed by `> ` + +#### Scenario: Subject gets a single Re: prefix +- **WHEN** the user replies to a message whose subject is `Hello` +- **THEN** the compose subject is `Re: Hello` + +#### Scenario: Re: is not duplicated +- **WHEN** the user replies to a message whose subject is already `Re: Hello` +- **THEN** the compose subject remains `Re: Hello` (no `Re: Re:`) + +#### Scenario: r suppressed while typing +- **WHEN** focus is in an `input` or `textarea` and the user presses `r` +- **THEN** the character is typed normally and no reply is started + +### Requirement: Reply to all +The reader SHALL provide an `R` (Shift+r) action that opens the compose screen as a +reply to every participant of the open message. The `To` field SHALL contain the +original `From` address; the `Cc` field SHALL contain the union of the original `To` +and `Cc` recipients. The active account's own address SHALL be excluded from both +fields. Subject prefixing and body quoting SHALL follow the same rules as `r`. + +#### Scenario: R populates To and Cc from all participants +- **WHEN** the reader is open on a message with multiple recipients and the user presses `R` +- **THEN** the compose `To` is the original `From` and the `Cc` contains the other original `To`/`Cc` recipients + +#### Scenario: Own address excluded from reply-all +- **WHEN** the user presses `R` on a message that also listed the active account's own address as a recipient +- **THEN** the active account's address does not appear in the `To` or `Cc` fields + +#### Scenario: Reply-all quotes the original body +- **WHEN** the user presses `R` in the reader +- **THEN** the compose body contains the original message text with each line prefixed by `> ` + +### Requirement: Forward message +The reader SHALL provide an `F` (Shift+f) action that opens the compose screen to +forward the open message. The `To` field SHALL be empty, the `Subject` SHALL be the +original subject prefixed with `Fwd: ` (not duplicated), and the body SHALL contain the +forwarded original message including its `From`, `To`, `Subject`, and `Date` headers +followed by the original body. `F` SHALL NOT interfere with `f`, which remains hint mode. + +#### Scenario: F opens compose to forward +- **WHEN** the reader is open and the user presses `F` +- **THEN** the compose screen opens with an empty `To` and the subject prefixed with `Fwd: ` + +#### Scenario: Forwarded body includes original headers +- **WHEN** the user presses `F` in the reader +- **THEN** the compose body includes the original message's `From`, `To`, `Subject`, and `Date` followed by the original body + +#### Scenario: f still activates hint mode +- **WHEN** the reader is open (text/simple mode) and the user presses `f` +- **THEN** hint mode activates and no forward is started + +### Requirement: Yank message body +The reader SHALL provide a `y` action that copies the open message's plain-text body to +the system clipboard. The copied content SHALL be the body only, without headers. + +#### Scenario: y copies the body to the clipboard +- **WHEN** the reader is open and the user presses `y` +- **THEN** the system clipboard contains the message's plain-text body and no headers + +### Requirement: Yank message with headers +The reader SHALL provide a `Y` (Shift+y) action that copies the open message's common +headers followed by its body to the system clipboard. The headers SHALL include at +least `From`, `To`, and `Subject`, plus `Date` and `Cc` when present, each on its own +line, followed by a blank line and then the plain-text body. + +#### Scenario: Y copies headers and body +- **WHEN** the reader is open and the user presses `Y` +- **THEN** the system clipboard contains `From`, `To`, and `Subject` lines (and `Date`/`Cc` when present) followed by a blank line and the message body + +### Requirement: Headers menu toggle +The reader SHALL provide a `g h` leader sequence that toggles the headers menu (the +`HeadersPopover`) showing the message's full header set. Pressing `g h` again or +`Escape` SHALL close it. + +#### Scenario: g h opens the headers menu +- **WHEN** the reader is open and the user presses `g` then `h` within the leader timeout +- **THEN** the headers menu opens showing the message's full headers + +#### Scenario: g h closes an open headers menu +- **WHEN** the headers menu is open and the user presses `g` then `h` +- **THEN** the headers menu closes diff --git a/openspec/changes/hotkeys-improvement/specs/ui-hotkeys/spec.md b/openspec/changes/hotkeys-improvement/specs/ui-hotkeys/spec.md new file mode 100644 index 0000000..4577a45 --- /dev/null +++ b/openspec/changes/hotkeys-improvement/specs/ui-hotkeys/spec.md @@ -0,0 +1,125 @@ +## MODIFIED Requirements + +### Requirement: g-leader navigation +`g` SHALL act as a leader key with a 1.2 s timeout. A visual indicator SHALL appear while waiting for the follow-up key. Recognized follow-ups: + +| Follow-up | Action | +|-----------|--------| +| `f` | Open folder picker | +| `a` | Open account picker | +| `g` | Top of list (and scroll viewport to top) | + +The direct folder-jump follow-ups (`i` Inbox, `s` Sent, `d` Drafts) and the former +Archive jump on `a` are removed; folder switching is done through the folder picker +(`g f`). The account picker is reached with `g a` (formerly `g A`). An unrecognized key +or timeout SHALL cancel the leader with no action. + +The leader indicator SHALL reflect the **active scope's** follow-ups, not a fixed +list. On the list scope it SHALL show `f folder · a account · g top` and ALSO the +standalone (non-leader) page hints `h prev-page · l next-page`. While the reader is +open the indicator SHALL instead show the reader follow-ups `f folder · a account · +g top · h headers` and SHALL NOT show the `h prev-page · l next-page` page hints +(those keys are list-scope only). + +#### Scenario: Indicator visible while leader active +- **WHEN** the user presses `g` on the list +- **THEN** the leader indicator appears showing available follow-ups (`f`, `a`, `g`) + +#### Scenario: g f opens the folder picker +- **WHEN** the leader is active and the user presses `f` +- **THEN** the folder picker opens and the leader clears + +#### Scenario: g a opens the account picker +- **WHEN** the leader is active and the user presses `a` +- **THEN** the account picker opens and the leader clears + +#### Scenario: g g jumps to top of list +- **WHEN** the user presses `g` then `g` within 1.2 s on the list +- **THEN** the selected index is set to 0 and the list scroll container scrolls to the top + +#### Scenario: Removed follow-ups no longer navigate +- **WHEN** the leader is active and the user presses `i`, `s`, or `d` +- **THEN** the leader clears and no folder navigation occurs + +#### Scenario: Timeout cancels leader +- **WHEN** the user presses `g` and does not press a follow-up within 1.2 s +- **THEN** the leader clears and no navigation occurs + +#### Scenario: Unrecognized follow-up cancels leader +- **WHEN** the leader is active and the user presses a key that is not a recognized follow-up +- **THEN** the leader clears and no navigation occurs + +### Requirement: Keyboard help toggle +The app SHALL open the keyboard help overlay when `?` is pressed in any non-exclusive scope (`list`, +`reader`, `compose`), provided focus is not in a text input. Pressing `?` again or `Escape` SHALL close +it. The overlay SHALL render exactly two sections: a **Global** section listing the Global keymap, and +one section named for the active scope listing that scope's bindings (grouped by their `group` field). +The overlay SHALL NOT render bindings from inactive scopes; the previous "All hotkeys" union view is +removed. + +#### Scenario: Open help from list +- **WHEN** the active scope is `list`, no modal is open, and the user presses `?` +- **THEN** the keyboard help overlay opens showing the Global section and the List section only + +#### Scenario: Open help from reader +- **WHEN** the active scope is `reader`, no modal is open, and the user presses `?` +- **THEN** the keyboard help overlay opens showing the Global section and the Reader section only; + list-scope bindings (such as `/`, `c`, `g f`) are not rendered + +#### Scenario: Open help from compose +- **WHEN** the active scope is `compose` and the user presses `?` while focus is not in a text input +- **THEN** the keyboard help overlay opens showing the Global section and the Compose section only + +#### Scenario: Escape closes help +- **WHEN** the keyboard help overlay is open and the user presses `Escape` +- **THEN** the keyboard help overlay closes and the underlying scope becomes active again + +#### Scenario: ? toggles help closed +- **WHEN** the keyboard help overlay is open and the user presses `?` +- **THEN** the keyboard help overlay closes + +#### Scenario: ? suppressed in text inputs +- **WHEN** focus is in an `input` or `textarea` and the user presses `?` +- **THEN** the character is typed and the keyboard help overlay does not open + +## ADDED Requirements + +### Requirement: Reader message-action keys +The reader scope keymap SHALL declare bindings for the reader message actions so they +fire only while the reader is the active scope and are surfaced in keyboard help. The +bindings SHALL be: `r` (reply to sender), `R` (reply to all), `F` (forward), `y` (yank +body), `Y` (yank body with headers), and the `g h` leader sequence (toggle headers +menu). These bindings SHALL be subject to the typing guard. The reader's existing `f` +hint-mode binding SHALL be preserved and SHALL NOT collide with `F`. The behavior of +each action is defined in the `reader-message-actions` capability. + +#### Scenario: Reader action keys fire only in the reader scope +- **WHEN** the message list is the active scope and the user presses `r`, `R`, `F`, `y`, or `Y` +- **THEN** no reply/forward/yank is triggered (these are reader-scope bindings; `r` on the list still marks read) + +#### Scenario: Reader action keys appear in reader help +- **WHEN** the keyboard help overlay is opened while the reader is the active scope +- **THEN** the Reader section lists `r`, `R`, `F`, `y`, `Y`, and `g h` with their descriptions + +#### Scenario: Reader action keys suppressed while typing +- **WHEN** focus is in an `input` or `textarea` within the reader and the user presses `y` +- **THEN** the character is typed normally and no yank occurs + +#### Scenario: F and f coexist in the reader +- **WHEN** the reader is open and the user presses `F` +- **THEN** the forward action runs and hint mode does not activate; pressing `f` instead activates hint mode + +### Requirement: Reader navigation leaders +The reader scope keymap SHALL declare the `g f` (folder picker) and `g a` (account +picker) leader sequences so they work from the reader exactly as they do from the +list. These mirror the list g-leader navigation primitives; the reader's `g g` +(scroll to top) and `g h` (toggle headers) leaders are unaffected and continue to +coexist with them. + +#### Scenario: g f opens the folder picker from the reader +- **WHEN** the reader is open and the user presses `g` then `f` +- **THEN** the folder picker opens + +#### Scenario: g a opens the account picker from the reader +- **WHEN** the reader is open and the user presses `g` then `a` +- **THEN** the account picker opens diff --git a/openspec/changes/hotkeys-improvement/tasks.md b/openspec/changes/hotkeys-improvement/tasks.md new file mode 100644 index 0000000..780003b --- /dev/null +++ b/openspec/changes/hotkeys-improvement/tasks.md @@ -0,0 +1,57 @@ +## 1. Backend: expose original recipients (for reply-all) + +- [x] 1.1 In `mailbrus-core`, surface the original `To`/`Cc` recipients for a message (parse from the maildir/notmuch message), adding fields to the message detail model. +- [x] 1.2 In `mailbrus-server`, include `to`/`cc` recipient lists in the message-detail HTTP response. +- [x] 1.3 Add/extend Rust unit tests in core covering recipient parsing (multiple To/Cc, missing Cc, own-address present). + +## 2. Frontend data layer + +- [x] 2.1 Extend the frontend `Message`/message-detail types in `src/lib/data.ts` (and the API client in `src/lib/api.ts`) to carry real `to`/`cc` recipients. +- [x] 2.2 Thread the recipient data through to `Reader.svelte` props. + +## 3. Reply/forward/quote helper + +- [x] 3.1 Create `src/lib/reply.ts` with pure `buildReply(message, account, body, { all })` and `buildForward(message, account, body, headers)` returning `{ to, cc, subject, body }`. +- [x] 3.2 Implement subject prefixing (`Re:`/`Fwd:`, case-insensitive, no duplication) and `> `-per-line body quoting. +- [x] 3.3 Implement reply-all recipient computation: `To` = sender, `Cc` = union of original `To`/`Cc`, excluding the active account address (dedup). +- [x] 3.4 Unit-test `reply.ts` (subject de-dup, quoting, reply-all dedup + own-address exclusion, forward header block). + +## 4. Compose prefill + +- [x] 4.1 Add `composePrefill: ComposeDraft | null` to `src/lib/ui-state.svelte.ts`. +- [x] 4.2 Update `Compose.svelte` to seed `to/cc/bcc/subject/body` (and auto-show Cc when present) from `ui.composePrefill` on mount, then clear it. + +## 5. Reader actions wiring + +- [x] 5.1 Add a clipboard helper (e.g. `src/lib/clipboard.ts`) wrapping `navigator.clipboard.writeText`. +- [x] 5.2 Implement `yankBody` (body only) and `yankHeaders` (From/To/Subject + Date/Cc when present, blank line, body) in `Reader.svelte` using `buildHeaders`. +- [x] 5.3 Implement `reply`/`replyAll`/`forward` in `Reader.svelte` — build draft via `reply.ts`, set `ui.composePrefill`, open compose. +- [x] 5.4 Wire `toggleHeaders` to the existing `showHeaders` state. + +## 6. Keymap edits + +- [x] 6.1 `src/lib/hotkeys/keymaps/list.ts`: remove `g i`/`g a`/`g s`/`g d` and `g A`; keep `g f`/`g g`/`G`; add `g a` → account picker; prune unused ctx callbacks (`goInbox`/`goArchive`/`goSent`/`goDrafts`) and their wiring in `+page.svelte`. +- [x] 6.2 `src/lib/hotkeys/keymaps/reader.ts`: extend `ReaderKeymapCtx` and add bindings `r`/`R`/`F`/`y`/`Y` and the `['g','h']` leader; keep `f` = hint mode. +- [x] 6.3 Verify keyboard help renders the new reader bindings and no longer shows removed list leaders (single-source-of-truth; no hard-coded list). + +## 7. E2E tests (use mailbrus-e2e-author skill) + +- [x] 7.1 Update/extend page objects and manifest for compose prefill assertions and reader actions; add the `// openspec/...` reference comment to each spec. +- [x] 7.2 Spec: `g f` opens folder picker, `g a` opens account picker, `g g` jumps to top, `G` jumps to bottom; assert removed leaders (`g i`/`g s`/`g d`) are no-ops. +- [x] 7.3 Spec: reader `r` opens compose with `To` = sender, `Re:` subject (no dup), body quoted with `> `. +- [x] 7.4 Spec: reader `R` populates `To`/`Cc` from participants and excludes the active account address. +- [x] 7.5 Spec: reader `F` opens compose with empty `To`, `Fwd:` subject, forwarded headers+body; `f` still activates hint mode. +- [x] 7.6 Spec: reader `y` / `Y` copy body / headers+body (grant `clipboard-read`/`clipboard-write`; assert via `navigator.clipboard.readText()`). +- [x] 7.7 Spec: reader `g h` toggles the headers popover open/closed. + +## 8. Validation & cleanup + +- [x] 8.1 Run `deno task test:e2e`; debug failures via traces and fix until green. +- [x] 8.2 Run the hotkeys unit tests (`dispatcher-core.test.ts` + new `reply.ts` tests) and ensure they pass. +- [x] 8.3 Fix all compilation/lint warnings (Rust `cargo build` warnings, `deno task build`/svelte-check warnings). + +## 9. Post-review fixes (reader g-leader) + +- [x] 9.1 Add `g f` (folder picker) and `g a` (account picker) to `reader.ts` keymap + `Reader.svelte` wiring (use existing `onFolder`/`onAccount` props), so they work in the reader, not just the list. +- [x] 9.2 Make the `+page.svelte` g-leader indicator scope-aware: reader shows `f folder · a account · g top · h headers`; list keeps `… · h prev-page · l next-page`. +- [x] 9.3 E2E: reader `g f` opens the folder picker and `g a` opens the account picker. diff --git a/openspec/changes/sync-status-bar-redesign/.openspec.yaml b/openspec/changes/sync-status-bar-redesign/.openspec.yaml new file mode 100644 index 0000000..95ae5a2 --- /dev/null +++ b/openspec/changes/sync-status-bar-redesign/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-06-18 diff --git a/openspec/changes/sync-status-bar-redesign/design.md b/openspec/changes/sync-status-bar-redesign/design.md new file mode 100644 index 0000000..92f671d --- /dev/null +++ b/openspec/changes/sync-status-bar-redesign/design.md @@ -0,0 +1,110 @@ +## Context + +The current `StatusBar.svelte` component displays sync progress via a persistent button showing text + icon, with a large popup containing per-mailbox fetched/indexed counts and status badges. Even after sync completes, the UI lingers in an active state, creating visual clutter and confusion about when the operation is truly done. + +The redesign compresses this into a progressive disclosure model: a minimal idle dot expands into a button on demand, then into a spinner on action, and finally into a detailed log popup on click. This requires: +- State machine managing three UI states (idle dot, button, spinner) +- Event log system capturing sync events with timestamps +- localStorage persistence for event history (2000-line retention) +- Morphing animations between states + +## Goals / Non-Goals + +**Goals:** +- Reduce visual footprint at rest (idle dot only, ~8px) +- Progressive disclosure: button on click, spinner on action, log popup on demand +- Event-driven log with timestamps for all sync phases (checking password, connecting, fetching, indexed, etc.) +- Persistent event history across sessions (2000-line localStorage cap) +- Display 15 latest events in popup; expandable to view older events in current run +- Clear completion state (dot returns to idle immediately after sync finishes) + +**Non-Goals:** +- Changing the sync protocol or backend event emission +- Adding real-time streaming visualization or animated charts +- Configurable log verbosity or filtering UI +- Mobile/responsive redesign (desktop-first) + +## Decisions + +### 1. State Machine: Three-Button States (Idle → Button → Spinner) +**Decision**: Implement a local component state (`state: 'idle' | 'button' | 'spinner'`) that cycles through states on click, and only opens the popup on spinner click. + +**Rationale**: Gives users progressive control—they can invoke sync without immediately opening a large popup. The spinner itself becomes clickable to reveal details, reducing information overload at rest. + +**Alternatives Considered**: +- Always show button (rejected: wastes space at idle, violates "minimal footprint" goal) +- Dot → popup directly (rejected: no way to invoke sync without opening details) +- Keyboard shortcut to sync (rejected: less discoverable; UI morphing is more tactile) + +### 2. Event Log Architecture: In-Stream Events + localStorage +**Decision**: Capture events from `syncState` (password, connecting, fetching, indexed) and store them in a module-level array. Persist to localStorage on each event and load on mount. Display 15 latest; keep full current-run log in memory with older runs archived to localStorage. + +**Rationale**: +- Decouples event capture from UI rendering (events can arrive out of order or quickly) +- localStorage provides cross-session history without needing a backend database +- 2000-line cap prevents unbounded growth; 15-event display keeps popup compact +- Module-level array (reactive) allows popup to show live updates as events arrive + +**Alternatives Considered**: +- Stream events directly from server (rejected: adds complexity; client-side buffering is simpler for UI) +- IndexedDB instead of localStorage (rejected: overkill for 2000 lines; localStorage is simpler) +- Unlimited history (rejected: localStorage has practical limits; 2000 lines is ~100KB and safe) + +### 3. Event Shape and Timestamps +**Decision**: Each event is `{ timestamp: ISO8601, account: string, event: string, detail?: string }`. Events: "checking_password", "password_retrieved_", "connecting", "connected", "fetching", "fetched", "indexed". + +**Rationale**: ISO8601 timestamps are unambiguous and sortable. Account context is needed per-mailbox operations. Event names are CLI-friendly and human-readable. + +**Alternatives Considered**: +- Unix milliseconds + per-account logs (rejected: harder to debug; ISO8601 human-readable) +- Numeric event codes (rejected: not self-documenting) + +### 4. Popup Lifecycle: Click-to-Open, Auto-Close or Manual? +**Decision**: Popup opens on spinner click and stays open until user clicks the close button (×). The dot returns to idle state immediately after sync finishes, but popup remains visible if already open to allow log review. + +**Rationale**: Allows users to review detailed logs after sync completes without rushing. Closing and reopening the popup is cheap; keeping it open respects that reviewing logs is important. + +**Alternatives Considered**: +- Auto-close popup 3 seconds after completion (rejected: users may miss log; can be confusing) +- Spinner click toggles popup on/off (rejected: can't re-open easily if already dismissed) + +### 5. localStorage Key and Expiry +**Decision**: Use a single key `mailbrus_sync_events` storing a JSON array of `{ timestamp, account, event, detail?, archived: boolean }`. Events marked `archived: true` are past runs. On each new sync, start a new unmarked run. On mount, load from localStorage and trim to 2000 lines (FIFO from oldest). + +**Rationale**: Single key simplifies cleanup. Archival flag lets us show "current run" vs "history" without separate storage. 2000-line trim is O(n) but acceptable given infrequency. + +**Alternatives Considered**: +- Per-run keys (rejected: harder to enforce 2000-line cap; many keys to manage) +- Immediate trim on each event (rejected: excessive write churn) + +### 6. UI Morphing Animations +**Decision**: Use CSS transitions and Svelte reactive classes. Idle → button is a width/opacity transition. Button → spinner swaps content and adds rotation animation. Popup is positioned fixed, overlays with 0.5s slide-in. + +**Rationale**: CSS transitions are performant and smooth. No external animation library needed. Keeps component self-contained. + +**Alternatives Considered**: +- Framer Motion or gsap (rejected: adds dependency; CSS is sufficient) +- Instant state changes (rejected: jarring; transitions improve UX) + +## Risks / Trade-offs + +| Risk | Mitigation | +|------|-----------| +| **localStorage quota exceeded** | 2000-line cap enforced on load; trim is O(n) but runs infrequently (once per session load). Monitor localStorage size in practice. | +| **Events arrive out of order or duplicate** | Rely on server-side event ordering; if duplicates occur, dedup in event capture logic. Add test coverage. | +| **Popup not visible if button is off-screen** | Fixed positioning relative to viewport; ensure bottom-right corner is always in bounds. Test on small screens. | +| **User confusion if sync completes but popup still shows spinner** | Clarify in log that sync is done (add "sync_completed" event). Dot returns to idle immediately for clarity. | +| **Performance: 2000 events render slowly** | Show only 15 in popup; older events are in expandable section. If needed, virtualize the list later. | + +## Migration Plan + +1. **Phase 1 (this change)**: Implement new StatusBar with three-state UI and event log. Keep old `syncHistory` module alongside (don't remove yet). +2. **Phase 2 (future)**: Retire old per-mailbox summary display; archive old sync runs. Remove `syncHistory` module if no longer needed. +3. **Rollback**: Keep old StatusBar component in git history. If issues arise, revert to previous version and debug. + +## Open Questions + +- Should the 15-event display be scrollable, or show a "X more events" link to expand? (Recommend scrollable with max-height 200px) +- Should events be searchable/filterable (e.g., by account)? (Out of scope for now; could be added later) +- Do we need to expose event history via CLI (e.g., `mailbrus log last 50`)? (Out of scope; separate feature) +- Should password events redact sensitive data in the log? (Recommend: yes, log "password_retrieved_storage" not the value itself) diff --git a/openspec/changes/sync-status-bar-redesign/proposal.md b/openspec/changes/sync-status-bar-redesign/proposal.md new file mode 100644 index 0000000..4906190 --- /dev/null +++ b/openspec/changes/sync-status-bar-redesign/proposal.md @@ -0,0 +1,44 @@ +## Why + +The current sync status bar is visually bloated and confusing—it always shows a large pill badge ("Syncing…", "Started…") and a detailed popup with per-mailbox rows, fetched/indexed counts, and status badges. Even after sync completes on the backend, the UI lingers in a "running" state. Users need a more compact, progressively-disclosed experience: minimal at rest, action-oriented on demand, and information-rich only when explicitly requested. + +## What Changes + +- **Idle state**: Show only a compact status dot (minimal footprint, right-aligned bottom corner) +- **Progressive disclosure**: Clicking the dot morphs inline to a "Sync now" button; clicking the button morphs to a spinner; clicking the spinner opens the popup +- **Event log**: Replace per-mailbox summary rows with a timestamped event log. Events include: + - `checking password` + - `password retrieved from storage ` + - `connecting` + - `connected` + - `fetching` + - `fetched` + - `indexed` +- **Log display & persistence**: + - Popup shows 15 latest events with timestamps + - Additional events from current sync run kept in localStorage (expandable) + - Total 2000 history lines retained in localStorage for past sync runs +- **State clarity**: Remove ambiguity around completion—the popup closes automatically or requires explicit dismissal, and the dot returns to idle state immediately after sync finishes + +## Capabilities + +### New Capabilities +- `sync-status-compact-ui`: Redesigned sync status display with three-state morphing control (idle dot → button → spinner → popup) +- `sync-event-log`: Timestamped event log with localStorage persistence (15 latest events displayed, 2000-line total history retained) + +### Modified Capabilities +- `ui-sync-status`: Spec-level behavior changes to the sync status bar rendering, state transitions, and information display + +## Impact + +- **Components**: `src/lib/components/StatusBar.svelte` (complete redesign of UI and state machine) +- **Stores/Modules**: `src/lib/syncState.svelte.ts`, `src/lib/syncHistory.svelte.ts` (event log filtering/formatting, localStorage persistence for 2000-line event history) +- **Storage**: localStorage for current sync events and up to 2000 historical event lines +- **Styling**: Significant CSS changes for compact toggle button, morphing states, and simplified popup +- **No breaking changes**: This is a UI-only refinement; API and backend behavior remain unchanged + +## Non-goals + +- Changing how sync is triggered or the underlying sync protocol +- Modifying the server-side sync logic or event emission +- Adding configurable UI themes or density settings for this component diff --git a/openspec/changes/sync-status-bar-redesign/specs/sync-event-log/spec.md b/openspec/changes/sync-status-bar-redesign/specs/sync-event-log/spec.md new file mode 100644 index 0000000..840a9a7 --- /dev/null +++ b/openspec/changes/sync-status-bar-redesign/specs/sync-event-log/spec.md @@ -0,0 +1,116 @@ +## ADDED Requirements + +### Requirement: Event capture with timestamps +The system SHALL capture sync events with ISO8601 timestamps and persist them throughout the session. + +#### Scenario: Events include account context +- **WHEN** a sync event occurs +- **THEN** event record contains: timestamp (ISO8601), account ID, event type, optional detail field + +#### Scenario: Supported event types +- **WHEN** sync progresses through phases +- **THEN** system captures events: "checking_password", "password_retrieved_", "connecting", "connected", "fetching", "fetched", "indexed" + +#### Scenario: Events are stored in session memory +- **WHEN** sync events are captured +- **THEN** events are stored in a module-level reactive array (Svelte rune) for live UI updates + +### Requirement: Display 15 latest events in popup +The popup SHALL show the 15 most recent events from the current sync run. + +#### Scenario: Latest 15 events shown first +- **WHEN** popup is open +- **THEN** popup body displays events in reverse chronological order (newest at top) + +#### Scenario: Each event shows timestamp and type +- **WHEN** popup displays events +- **THEN** each event row shows: formatted time (HH:MM:SS), account, event type, and optional detail + +#### Scenario: Events update live during sync +- **WHEN** new events arrive during active sync +- **THEN** popup immediately reflects new events (no manual refresh needed) + +### Requirement: Expandable history within current run +Events beyond the 15 latest from the current run SHALL be accessible via expansion without opening another modal. + +#### Scenario: Show remaining events count +- **WHEN** more than 15 events exist in current run +- **THEN** popup shows "X more events" indicator or scrollable area + +#### Scenario: Scroll or expand to view older events in run +- **WHEN** user scrolls down in popup event list OR clicks expand button +- **THEN** earlier events from current run become visible + +### Requirement: Event log persistence to localStorage +The system SHALL persist events to localStorage with a 2000-line total capacity. + +#### Scenario: Events saved to localStorage on each arrival +- **WHEN** a sync event is captured +- **THEN** event is appended to `mailbrus_sync_events` localStorage key (within 100ms) + +#### Scenario: Load persisted events on app mount +- **WHEN** app initializes +- **THEN** system loads events from `mailbrus_sync_events` localStorage and restores session state + +#### Scenario: Trim history to 2000 lines +- **WHEN** persisted events exceed 2000 lines +- **THEN** oldest events are removed (FIFO) until total ≤ 2000 lines + +### Requirement: Mark completed sync runs in history +Completed sync runs SHALL be marked and archived in localStorage for historical review. + +#### Scenario: Add completion event on sync finish +- **WHEN** sync completes (success or error) +- **THEN** system adds "sync_completed" or "sync_failed" event with result summary + +#### Scenario: New runs separated in history +- **WHEN** next sync starts after previous one completed +- **THEN** new events begin a new logical run; prior run is marked archived in localStorage + +#### Scenario: Expand historical runs in popup +- **WHEN** user expands "History" section in popup +- **THEN** past completed runs are displayed with time and run summary (e.g., "3 accounts, 150 messages") + +### Requirement: Password event sanitization +Password-related events SHALL NOT include sensitive data in the log. + +#### Scenario: Password event redacted in log +- **WHEN** "password_retrieved_" event is logged +- **THEN** detail field shows type (e.g., "keyring", "file") but not the password value itself + +#### Scenario: No password or credentials in event detail +- **WHEN** any event is persisted to localStorage or displayed in popup +- **THEN** no plaintext passwords, tokens, or credentials appear in any event field + +### Requirement: Event log export for debugging +Event logs SHALL be accessible for support/debugging purposes. + +#### Scenario: Copy log to clipboard +- **WHEN** user opens popup +- **THEN** there is a "Copy log" button that copies all visible events as plain text to clipboard + +#### Scenario: Log export format is human-readable +- **WHEN** events are exported or copied +- **THEN** format is plain text with one event per line: `[HH:MM:SS] account: event_type (detail)` + +### Requirement: Clear history action +Users SHALL be able to clear the event history from localStorage. + +#### Scenario: Clear history button in popup +- **WHEN** popup is open and history section is visible +- **THEN** a "Clear history" button removes all historical runs from localStorage + +#### Scenario: Confirmation before clear +- **WHEN** user clicks "Clear history" +- **THEN** browser confirm dialog appears asking "Clear all sync history? This cannot be undone." before deletion + +### Requirement: Handle rapid events without loss +The system SHALL buffer and display events even if they arrive rapidly during sync. + +#### Scenario: Events captured in order even if rapid +- **WHEN** multiple events arrive within 100ms +- **THEN** all events are captured and stored in order (no loss, no duplication) + +#### Scenario: Popup renders all buffered events +- **WHEN** popup opens after rapid event burst +- **THEN** all events are visible and correctly ordered diff --git a/openspec/changes/sync-status-bar-redesign/specs/sync-status-compact-ui/spec.md b/openspec/changes/sync-status-bar-redesign/specs/sync-status-compact-ui/spec.md new file mode 100644 index 0000000..9e3167b --- /dev/null +++ b/openspec/changes/sync-status-bar-redesign/specs/sync-status-compact-ui/spec.md @@ -0,0 +1,100 @@ +## ADDED Requirements + +### Requirement: Compact idle state +The status bar SHALL display as a small circular dot (radius ~6px) when sync is not active and no errors exist. + +#### Scenario: Show idle dot when sync is not running +- **WHEN** page loads and no sync is active +- **THEN** status bar displays a compact idle dot in the bottom-right corner + +#### Scenario: Show idle dot after sync completes +- **WHEN** sync finishes successfully +- **THEN** the spinner morphs back to idle dot immediately, regardless of popup state + +### Requirement: Idle dot morphs to sync button +Clicking the idle dot SHALL morph it into a "Sync now" button inline (same location, no popup). + +#### Scenario: Click idle dot to reveal sync button +- **WHEN** user clicks the idle dot +- **THEN** dot animates (CSS transition) to become a "Sync now" button within 300ms + +#### Scenario: Sync button is clickable +- **WHEN** "Sync now" button is visible +- **THEN** button is enabled and clickable (cursor: pointer) + +### Requirement: Sync button morphs to spinner +Clicking the "Sync now" button SHALL initiate sync and morph the button into a spinner. + +#### Scenario: Click sync button to start sync and show spinner +- **WHEN** user clicks "Sync now" button +- **THEN** sync request is sent AND button animates to spinner within 300ms + +#### Scenario: Spinner displays rotation animation +- **WHEN** sync is active and spinner is visible +- **THEN** spinner rotates continuously (0.7s per rotation) indicating activity + +### Requirement: Spinner morphs to popup on click +Clicking the spinner SHALL open a detailed popup modal (below the spinner). + +#### Scenario: Click spinner to open popup +- **WHEN** user clicks the active spinner +- **THEN** popup appears positioned below spinner (fixed position, no click propagation) + +#### Scenario: Popup contains close button +- **WHEN** popup is open +- **THEN** popup header has an × button to close it + +### Requirement: Return to idle after sync completion +After sync completes, the spinner SHALL morph back to idle dot while popup remains open (if it was open). + +#### Scenario: Spinner returns to idle dot after sync success +- **WHEN** sync finishes (backend signals completion) +- **THEN** spinner animates back to idle dot within 300ms + +#### Scenario: Idle dot returned to initial position +- **WHEN** sync completes and morphing completes +- **THEN** dot is in same location as initial idle state (no repositioning) + +### Requirement: Error state styling +When sync encounters an error, the dot SHALL display in red with error styling. + +#### Scenario: Error dot visible during failed sync +- **WHEN** sync fails or backend reports an error +- **THEN** idle dot is colored red (destructive color) and styled as error state + +#### Scenario: Error state persists until next successful sync +- **WHEN** error occurs and user views status later +- **THEN** error dot remains visible until next sync starts and succeeds + +### Requirement: Morphing animations are smooth +All state transitions (dot ↔ button ↔ spinner) SHALL use CSS transitions for smooth morphing. + +#### Scenario: Smooth transition from dot to button +- **WHEN** idle dot is clicked +- **THEN** width, opacity, and content smoothly transition over 300ms (no jarring changes) + +#### Scenario: Smooth transition from button to spinner +- **WHEN** sync button is clicked +- **THEN** content morphs to spinner with smooth rotation animation start + +### Requirement: Minimal footprint at rest +The idle dot SHALL occupy minimal screen space and not interfere with other UI elements. + +#### Scenario: Idle dot fits in bottom-right corner +- **WHEN** page renders with idle dot +- **THEN** dot is positioned fixed at bottom-right with 12px margin, total ~20px footprint + +#### Scenario: Morphed button does not overflow +- **WHEN** "Sync now" button is displayed +- **THEN** button width auto-adjusts but stays within 100px and does not overlap other UI + +### Requirement: Popup positioning below spinner +The popup modal SHALL position itself directly below the spinner/button without overlapping. + +#### Scenario: Popup appears below without repositioning on small screens +- **WHEN** user clicks spinner on a mobile viewport (320px width) +- **THEN** popup positions below spinner and adjusts width to fit (max 80vw) + +#### Scenario: Popup z-index ensures visibility +- **WHEN** popup is open +- **THEN** popup has sufficient z-index (≥50) to appear above other UI elements diff --git a/openspec/changes/sync-status-bar-redesign/tasks.md b/openspec/changes/sync-status-bar-redesign/tasks.md new file mode 100644 index 0000000..d9b400e --- /dev/null +++ b/openspec/changes/sync-status-bar-redesign/tasks.md @@ -0,0 +1,84 @@ +## 1. Event Log Module + +- [ ] 1.1 Create `src/lib/syncEventLog.svelte.ts` module for capturing and persisting events +- [ ] 1.2 Implement event interface: `{ timestamp: string, account: string, event: string, detail?: string, archived?: boolean }` +- [ ] 1.3 Add `addEvent(account, eventType, detail?)` function to capture events in memory +- [ ] 1.4 Implement localStorage persistence: save events on each capture to `mailbrus_sync_events` +- [ ] 1.5 Implement load from localStorage on module initialization +- [ ] 1.6 Implement 2000-line FIFO trim when loading from localStorage +- [ ] 1.7 Add event deduplication check (prevent duplicate events within 100ms) +- [ ] 1.8 Export reactive runes: `allEvents`, `currentRunEvents`, `historyRuns` for UI consumption + +## 2. Event Capture Integration + +- [ ] 2.1 Integrate event capture into syncState: emit `checking_password`, `password_retrieved_`, `connecting`, `connected` events +- [ ] 2.2 Emit `fetching`, `fetched`, `indexed` events from sync lifecycle +- [ ] 2.3 Emit `sync_completed` or `sync_failed` event on sync finish +- [ ] 2.4 Mark completed runs as archived in syncEventLog for history display +- [ ] 2.5 Test event capture end-to-end with mock sync run (no real mail server) + +## 3. StatusBar Component Refactor + +- [ ] 3.1 Redesign StatusBar.svelte state machine: add `state: 'idle' | 'button' | 'spinner'` local state +- [ ] 3.2 Implement state transitions: idle → button (click), button → spinner (click), spinner → popup open (click) +- [ ] 3.3 Remove old summary rows (fetched/indexed counts) from popup body +- [ ] 3.4 Add "Sync now" button that only appears in button state +- [ ] 3.5 Ensure spinner returns to idle immediately when sync completes +- [ ] 3.6 Keep popup open after sync completes (allow manual review before closing) + +## 4. Event Log Popup Display + +- [ ] 4.1 Redesign popup body to show event log instead of per-mailbox summary +- [ ] 4.2 Display 15 latest events from currentRunEvents in reverse chronological order +- [ ] 4.3 Format each event as: `[HH:MM:SS] account: event_type (detail)` in popup +- [ ] 4.4 Add scrollable area for events (max-height 200px if more than fit) +- [ ] 4.5 Display "X more events" indicator if current run has more than 15 events +- [ ] 4.6 Add clickable expand/collapse for historical runs (archived events) +- [ ] 4.7 Add "Copy log" button to copy all visible events as plain text to clipboard +- [ ] 4.8 Add "Clear history" button with confirmation dialog + +## 5. Styling and Animations + +- [ ] 5.1 Create CSS for idle dot state (~6px circular dot, muted color) +- [ ] 5.2 Create CSS for button state ("Sync now" text, border, padding) +- [ ] 5.3 Create CSS for spinner state (rotated icon or animated spinner, 0.7s rotation) +- [ ] 5.4 Implement CSS transitions for morphing: dot ↔ button ↔ spinner (300ms) +- [ ] 5.5 Implement error state styling (red dot, red border) +- [ ] 5.6 Ensure popup positioning is fixed, below spinner, with proper z-index (50+) +- [ ] 5.7 Test popup positioning on small screens (80vw max width) + +## 6. Error Handling and Edge Cases + +- [ ] 6.1 Handle password events: ensure detail field only shows type (keyring/file), never password value +- [ ] 6.2 Handle localStorage quota exceeded: gracefully trim or warn if quota approaches +- [ ] 6.3 Handle rapid event arrivals: ensure no loss or duplication +- [ ] 6.4 Handle sync failures: display error in event log and show error-state dot +- [ ] 6.5 Handle race conditions: ensure state transitions don't overlap or cause flickering + +## 7. Testing + +- [ ] 7.1 Write unit tests for syncEventLog: add event, load from storage, trim on quota +- [ ] 7.2 Write unit tests for state machine: verify idle → button → spinner transitions +- [ ] 7.3 Write E2E test: verify idle dot is visible at startup (openspec/changes/sync-status-bar-redesign/e2e-sync-status-dot.spec.ts) +- [ ] 7.4 Write E2E test: verify clicking idle dot morphs to button and button is clickable +- [ ] 7.5 Write E2E test: verify clicking button starts sync and morphs to spinner +- [ ] 7.6 Write E2E test: verify clicking spinner opens popup with events +- [ ] 7.7 Write E2E test: verify event log displays timestamps, account, and event types correctly +- [ ] 7.8 Write E2E test: verify popup closes and spinner returns to idle after sync completes +- [ ] 7.9 Write E2E test: verify error dot appears on sync failure +- [ ] 7.10 Write E2E test: verify "Clear history" button works with confirmation + +## 8. E2E Test Validation and Fixes + +- [ ] 8.1 Run full E2E test suite: `deno task test:e2e` +- [ ] 8.2 Fix any failing tests (trace viewer available via `deno task e2e:debug`) +- [ ] 8.3 Verify no regressions in existing tests (status bar, sync, mailbox views) +- [ ] 8.4 Manual smoke test: verify UI feels responsive and smooth in browser + +## 9. Cleanup and Verification + +- [ ] 9.1 Remove old syncHistory UI code if no longer used elsewhere +- [ ] 9.2 Fix any TypeScript compilation warnings +- [ ] 9.3 Run `deno lint` and fix any style/lint issues +- [ ] 9.4 Verify localStorage keys are consistent and documented in module +- [ ] 9.5 Add JSDoc comments to syncEventLog module exports diff --git a/src/lib/api.ts b/src/lib/api.ts index eae2644..0b2de9c 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -6,6 +6,10 @@ export type RenderMode = 'text' | 'simple' | 'html'; export interface MessageBody extends Message { body: string; + /** Original `To` recipients (addressable strings), for reply-all. */ + to: string[]; + /** Original `Cc` recipients (addressable strings), for reply-all. */ + cc: string[]; attachments: Attachment[]; mode: RenderMode; has_plain: boolean; diff --git a/src/lib/clipboard.ts b/src/lib/clipboard.ts new file mode 100644 index 0000000..e9f67c0 --- /dev/null +++ b/src/lib/clipboard.ts @@ -0,0 +1,12 @@ +// openspec/changes/hotkeys-improvement/specs/reader-message-actions/spec.md +// Thin wrapper over the async Clipboard API. Works in the SPA and the Tauri +// webview when served over a secure origin; resolves false if the write is +// rejected (no permission / insecure context) so callers can degrade quietly. +export async function copyText(text: string): Promise { + try { + await navigator.clipboard.writeText(text); + return true; + } catch { + return false; + } +} diff --git a/src/lib/components/Compose.svelte b/src/lib/components/Compose.svelte index dd2b60e..fb2da21 100644 --- a/src/lib/components/Compose.svelte +++ b/src/lib/components/Compose.svelte @@ -2,9 +2,12 @@ import Breadcrumbs from './Breadcrumbs.svelte'; import RecipientInput from './RecipientInput.svelte'; import type { Account, Folder } from '$lib/data.js'; + import { onMount } from 'svelte'; // openspec/changes/isolate-hotkeys/specs/ui-hotkeys/spec.md + // openspec/changes/hotkeys-improvement/specs/reader-message-actions/spec.md (compose prefill) import { useScopedKeymap } from '$lib/hotkeys/scope-bind.svelte.ts'; import { createComposeKeymap } from '$lib/hotkeys/keymaps/compose.ts'; + import { ui } from '$lib/ui-state.svelte.ts'; let { account, @@ -32,6 +35,21 @@ let showCc = $state(false); let showBcc = $state(false); + // Seed fields from a reader reply/forward prefill, then clear it so a later + // plain compose starts blank. Cc auto-expands when the prefill carries a Cc. + onMount(() => { + const pre = ui.composePrefill; + if (!pre) return; + to = pre.to ?? ''; + cc = pre.cc ?? ''; + bcc = pre.bcc ?? ''; + subject = pre.subject ?? ''; + body = pre.body ?? ''; + if (cc) showCc = true; + if (bcc) showBcc = true; + ui.composePrefill = null; + }); + let isDirty = $derived(!!(to || cc || bcc || subject || body)); let wordCount = $derived(body.trim() ? body.trim().split(/\s+/).length : 0); let charCount = $derived(body.length); diff --git a/src/lib/components/HeadersPopover.svelte b/src/lib/components/HeadersPopover.svelte index 8be7bb5..b67b4a6 100644 --- a/src/lib/components/HeadersPopover.svelte +++ b/src/lib/components/HeadersPopover.svelte @@ -1,9 +1,10 @@