From c037e1533415fdea5974080ea513474255e0efad Mon Sep 17 00:00:00 2001 From: Anton Vasiljev Date: Fri, 19 Jun 2026 00:34:19 +0300 Subject: [PATCH 1/3] fix(sync): detect CONDSTORE via SELECT highest_modseq, not just capabilities Gmail does not advertise CONDSTORE in its CAPABILITY response but does return HIGHESTMODSEQ in SELECT replies and honours CHANGEDSINCE. The previous code used only the capability check, so it always fell back to a full UID SEARCH ALL on every sync. This meant messages that arrived between the CHANGEDSINCE check and the SEARCH ALL could be silently missed on that run. Fix: treat a present highest_modseq in the SELECT response as evidence of CONDSTORE support, enabling incremental delta sync for Gmail. --- mailbrus-core/src/sync/imap.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) 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? From 9982cb057ef4f4dd27395264f910b40949508db4 Mon Sep 17 00:00:00 2001 From: Anton Vasiljev Date: Fri, 19 Jun 2026 01:30:16 +0300 Subject: [PATCH 2/3] openspec: add sync-status-bar-redesign change MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - proposal: three-state morphing UI (dot → button → spinner → popup) - design: state machine architecture, event log with localStorage persistence - specs: 18 requirements for compact UI and event log (40+ scenarios) - tasks: 42 implementation checkpoints across 9 phases - event log: 15 latest events displayed, 2000-line localStorage history --- .../sync-status-bar-redesign/.openspec.yaml | 2 + .../sync-status-bar-redesign/design.md | 110 +++++++++++++++++ .../sync-status-bar-redesign/proposal.md | 44 +++++++ .../specs/sync-event-log/spec.md | 116 ++++++++++++++++++ .../specs/sync-status-compact-ui/spec.md | 100 +++++++++++++++ .../changes/sync-status-bar-redesign/tasks.md | 84 +++++++++++++ 6 files changed, 456 insertions(+) create mode 100644 openspec/changes/sync-status-bar-redesign/.openspec.yaml create mode 100644 openspec/changes/sync-status-bar-redesign/design.md create mode 100644 openspec/changes/sync-status-bar-redesign/proposal.md create mode 100644 openspec/changes/sync-status-bar-redesign/specs/sync-event-log/spec.md create mode 100644 openspec/changes/sync-status-bar-redesign/specs/sync-status-compact-ui/spec.md create mode 100644 openspec/changes/sync-status-bar-redesign/tasks.md 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 From 9c647418b6f3ed805ed5c084033e50da1fca02bb Mon Sep 17 00:00:00 2001 From: Anton Vasiljev Date: Fri, 19 Jun 2026 02:23:26 +0300 Subject: [PATCH 3/3] feat(hotkeys): reader reply/forward/yank/headers + trimmed g-leader Implements the hotkeys-improvement OpenSpec change: - Reader actions: r (reply), R (reply-all), F (forward), y (yank body), Y (yank headers+body), g h (toggle headers menu). - Reader navigation leaders g f / g a (folder/account picker) so they work from the reader, not just the list. - List g-leader trimmed: remove g i/g s/g d/g A, rebind account picker to g a, keep g f/g g/G. - Backend: mailbrus-core gains split_address_list + Headers.cc; the message-detail response now emits structured to/cc for reply-all. - Pure reply.ts (Re:/Fwd: dedup, "> " quoting, reply-all dedup + own-address exclusion) + compose prefill via ui.composePrefill. - Scope-aware g-leader indicator (reader vs list). Validated: cargo (61) + deno unit (53) + e2e (156) all green. --- .../cur/alice-inbox-10-multi-recipient:2,S | 12 ++ e2e/fixtures/manifest.ts | 17 +++ e2e/pages/ComposePage.ts | 47 +++++++ e2e/pages/MailboxPage.ts | 19 +++ e2e/pages/MessagePage.ts | 56 ++++++++ e2e/specs/hotkeys-list-leader.spec.ts | 84 ++++++------ e2e/specs/reader-message-actions.spec.ts | 127 +++++++++++++++++ mailbrus-core/src/maildir_reader.rs | 96 +++++++++++-- mailbrus-server/src/mime.rs | 23 ++++ .../hotkeys-improvement/.openspec.yaml | 2 + .../changes/hotkeys-improvement/design.md | 129 ++++++++++++++++++ .../changes/hotkeys-improvement/proposal.md | 56 ++++++++ .../specs/reader-message-actions/spec.md | 99 ++++++++++++++ .../specs/ui-hotkeys/spec.md | 125 +++++++++++++++++ openspec/changes/hotkeys-improvement/tasks.md | 57 ++++++++ src/lib/api.ts | 4 + src/lib/clipboard.ts | 12 ++ src/lib/components/Compose.svelte | 18 +++ src/lib/components/HeadersPopover.svelte | 15 +- src/lib/components/Reader.svelte | 69 ++++++++++ src/lib/hotkeys/keymaps/list.ts | 11 +- src/lib/hotkeys/keymaps/reader.ts | 17 +++ src/lib/reply.test.ts | 85 ++++++++++++ src/lib/reply.ts | 109 +++++++++++++++ src/lib/ui-state.svelte.ts | 8 +- src/routes/[...path]/+page.svelte | 29 ++-- 26 files changed, 1255 insertions(+), 71 deletions(-) create mode 100644 e2e/fixtures/maildir/alice@example.com/Inbox/cur/alice-inbox-10-multi-recipient:2,S create mode 100644 e2e/pages/ComposePage.ts create mode 100644 e2e/specs/reader-message-actions.spec.ts create mode 100644 openspec/changes/hotkeys-improvement/.openspec.yaml create mode 100644 openspec/changes/hotkeys-improvement/design.md create mode 100644 openspec/changes/hotkeys-improvement/proposal.md create mode 100644 openspec/changes/hotkeys-improvement/specs/reader-message-actions/spec.md create mode 100644 openspec/changes/hotkeys-improvement/specs/ui-hotkeys/spec.md create mode 100644 openspec/changes/hotkeys-improvement/tasks.md create mode 100644 src/lib/clipboard.ts create mode 100644 src/lib/reply.test.ts create mode 100644 src/lib/reply.ts 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-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/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 @@