feat: vim-style jump list and alternate buffer navigation #67

Closed
opened 2026-03-23 22:17:19 +00:00 by barrettruth · 2 comments
barrettruth commented 2026-03-23 22:17:19 +00:00

Problem

Delta has solid vim-style motions within views (j/k, gg/G, h/l in kanban/calendar) and view-switching hotkeys (Q/K/C), but there is no way to retrace your steps. Once you navigate away from what you were looking at, you have to manually reconstruct your path. Browser back/forward is unreliable in an SPA — it conflates app navigation with page-level history and breaks when views update via state rather than URL changes (e.g. opening a task detail dialog, selecting a calendar date, filtering by category).

Vim solves this with two complementary mechanisms: the jump list (<C-o> / <C-i>) and the alternate buffer (<C-^>). Both map cleanly onto delta's navigation model.

Proposed features

1. Alternate buffer — <C-6> (or <C-^>)

In vim, <C-^> switches to the "alternate file" — the last buffer you edited before the current one. It is always exactly one location; it is not a stack. Every time you navigate to a new buffer, the previous buffer becomes the alternate.

In delta, this translates to: the last view you were in before the current one. The "view" includes enough state to restore context — not just the route, but any meaningful sub-state.

Examples:

  • You are in Queue filtered to category "Work". You press C to go to Calendar. Pressing <C-6> returns to Queue with the "Work" filter active.
  • You are in Kanban with task #42's detail open. You press Q to go to Queue. Pressing <C-6> returns to Kanban (and optionally reopens task #42's detail).
  • You are in Calendar month view on April 2026. You press Enter on a day to view it in Queue. <C-6> returns to Calendar on April 2026 with that day selected.

The alternate buffer is always a single pointer — it does not accumulate. Pressing <C-6> twice returns you to where you started (it swaps current and alternate).

2. Jump list — <C-o> / <C-i>

In vim, the jump list is an ordered stack of locations recorded whenever you make a "jump" — opening a file, searching, using gg/G/marks/etc. <C-o> moves backward through the stack, <C-i> moves forward. The list has a cursor; going back and then navigating somewhere new truncates the forward history (same as browser history semantics).

Key vim behaviors to replicate:

  • The list has a maximum size (vim uses 100 entries). Oldest entries are evicted.
  • Going backward with <C-o> and then making a new jump truncates everything forward of the current position.
  • Consecutive duplicate entries are collapsed (navigating to the same place twice does not push two entries).

What constitutes a "jump" in delta

Not every state change is a jump. In vim, cursor movements within a file (j/k/w/b) are not jumps, but gg, G, /search, and :edit file are. The principle: jumps cross a meaningful boundary; motions move within one.

Action Is a jump? Why
Q / K / C (view switch) Yes Equivalent to :edit — switching buffers
Enter on a task (open detail) Yes Equivalent to gd / :edit — drilling into an entity
Enter on a calendar day (view in queue) Yes Cross-view navigation
1-9 (category filter) Yes Changes the visible dataset, like :cd
Escape (close task detail) No Returning to the view you were already in
j / k (move cursor) No Motion within a view
h / l (calendar day, kanban col) No Motion within a view
[m / ]m / [w / ]w (calendar nav) No Scrolling within a view
w / m (calendar view mode toggle) No Sub-view state, not a navigation boundary
gg / G (jump to top/bottom of list) Debatable Vim counts these as jumps; could go either way in delta given list sizes

3. Future: marks — m{a-z} / '{a-z}

Lower priority, but worth noting for completeness. In vim, m{a-z} sets a named mark at the current cursor position, and '{a-z} jumps to it. In delta, this would let you bookmark specific views or tasks:

  • ma while viewing task #42 → mark a = task #42's detail
  • 'a from anywhere → reopen task #42's detail
  • mb in Queue filtered to "School" → mark b = Queue + School filter
  • 'b from Calendar → jump to Queue with School filter

'' (double apostrophe) jumps to the position before the last jump — a simpler version of <C-o> that only goes back one step. This is effectively the same as the alternate buffer for most cases, but in vim it specifically means "position before last jump" rather than "last buffer."

Marks are a stretch goal. The jump list and alternate buffer cover 90% of the navigation pain.

Technical notes

Navigation location type

A "location" needs to capture enough state to restore a view:

type NavLocation = {
  path: string           // e.g. "/", "/kanban", "/calendar"
  params?: URLSearchParams // category filter, date filter
  taskId?: number        // if a task detail was open
  viewState?: {          // optional sub-view state
    calendarDate?: string
    calendarMode?: "week" | "month"
    kanbanColumn?: number
    cursorIndex?: number
  }
}

State management

The jump list and alternate buffer should be app-level state, separate from browser history. Reasons:

  • router.back() uses the browser history stack, which includes external navigations, page refreshes, and other noise. It is not a clean representation of in-app navigation.
  • Next.js router.push() adds to browser history. The jump list must be orthogonal — pressing <C-o> should not also trigger a browser back.
  • The jump list needs to store richer state than what fits in a URL (e.g. cursor position, open task detail, calendar selection).

Implementation options:

  1. React context + useRef — simplest. A NavigationProvider wrapping the app holds the jump list array and alternate buffer pointer in refs (not state, to avoid re-renders on every push). Expose pushJump(), jumpBack(), jumpForward(), getAlternate(), and goAlternate() via context. Navigation functions call router.push() internally when the path changes.

  2. Zustand store — if the app adopts Zustand later, the jump list fits naturally as a store slice. Same API surface.

  3. sessionStorage — persist the jump list across page refreshes (but not across tabs). Useful if the user refreshes and wants to retain their navigation trail. Can be combined with option 1 — write-through to sessionStorage on every push.

Option 1 is the right starting point. sessionStorage persistence can be layered on later.

Integration with global-keyboard.tsx

The <C-o>, <C-i>, and <C-6> handlers belong in GlobalKeyboard since they are view-agnostic. The current VIEW_KEYS handler that calls router.push() would need to also call pushJump() before navigating. Same for category filter keys (1-9).

View-specific jump triggers (e.g. Enter to open task detail, Enter on calendar day) would call pushJump() from their respective components via the context.

Interaction with isInputFocused()

<C-o> and <C-i> use the Ctrl modifier, so they are unlikely to conflict with text input. But <C-6> could conflict with some input fields. The existing isInputFocused() guard (currently duplicated in 4 files — tracked separately) should gate these bindings the same way it gates other shortcuts.

Keymap help update

Add a "Navigation" section to KeymapHelp with <C-o>, <C-i>, and <C-6>.

References

  • :help jumplist — vim's jump list documentation
  • :help alternate-file — vim's alternate buffer (<C-^>) documentation
  • :help mark-motions — vim's mark system
## Problem Delta has solid vim-style motions within views (`j`/`k`, `gg`/`G`, `h`/`l` in kanban/calendar) and view-switching hotkeys (`Q`/`K`/`C`), but there is no way to retrace your steps. Once you navigate away from what you were looking at, you have to manually reconstruct your path. Browser back/forward is unreliable in an SPA — it conflates app navigation with page-level history and breaks when views update via state rather than URL changes (e.g. opening a task detail dialog, selecting a calendar date, filtering by category). Vim solves this with two complementary mechanisms: the **jump list** (`<C-o>` / `<C-i>`) and the **alternate buffer** (`<C-^>`). Both map cleanly onto delta's navigation model. ## Proposed features ### 1. Alternate buffer — `<C-6>` (or `<C-^>`) In vim, `<C-^>` switches to the "alternate file" — the last buffer you edited before the current one. It is always exactly one location; it is not a stack. Every time you navigate to a new buffer, the previous buffer becomes the alternate. In delta, this translates to: **the last view you were in before the current one**. The "view" includes enough state to restore context — not just the route, but any meaningful sub-state. Examples: - You are in **Queue** filtered to category "Work". You press `C` to go to **Calendar**. Pressing `<C-6>` returns to **Queue with the "Work" filter active**. - You are in **Kanban** with task #42's detail open. You press `Q` to go to Queue. Pressing `<C-6>` returns to **Kanban** (and optionally reopens task #42's detail). - You are in **Calendar** month view on April 2026. You press `Enter` on a day to view it in Queue. `<C-6>` returns to **Calendar on April 2026 with that day selected**. The alternate buffer is always a single pointer — it does not accumulate. Pressing `<C-6>` twice returns you to where you started (it swaps current and alternate). ### 2. Jump list — `<C-o>` / `<C-i>` In vim, the jump list is an ordered stack of locations recorded whenever you make a "jump" — opening a file, searching, using `gg`/`G`/marks/etc. `<C-o>` moves backward through the stack, `<C-i>` moves forward. The list has a cursor; going back and then navigating somewhere new truncates the forward history (same as browser history semantics). Key vim behaviors to replicate: - The list has a **maximum size** (vim uses 100 entries). Oldest entries are evicted. - Going backward with `<C-o>` and then making a new jump **truncates** everything forward of the current position. - Consecutive duplicate entries are collapsed (navigating to the same place twice does not push two entries). #### What constitutes a "jump" in delta Not every state change is a jump. In vim, cursor movements within a file (`j`/`k`/`w`/`b`) are not jumps, but `gg`, `G`, `/search`, and `:edit file` are. The principle: **jumps cross a meaningful boundary; motions move within one**. | Action | Is a jump? | Why | |---|---|---| | `Q` / `K` / `C` (view switch) | Yes | Equivalent to `:edit` — switching buffers | | `Enter` on a task (open detail) | Yes | Equivalent to `gd` / `:edit` — drilling into an entity | | `Enter` on a calendar day (view in queue) | Yes | Cross-view navigation | | `1`-`9` (category filter) | Yes | Changes the visible dataset, like `:cd` | | `Escape` (close task detail) | No | Returning to the view you were already in | | `j` / `k` (move cursor) | No | Motion within a view | | `h` / `l` (calendar day, kanban col) | No | Motion within a view | | `[m` / `]m` / `[w` / `]w` (calendar nav) | No | Scrolling within a view | | `w` / `m` (calendar view mode toggle) | No | Sub-view state, not a navigation boundary | | `gg` / `G` (jump to top/bottom of list) | Debatable | Vim counts these as jumps; could go either way in delta given list sizes | ### 3. Future: marks — `m{a-z}` / `'{a-z}` Lower priority, but worth noting for completeness. In vim, `m{a-z}` sets a named mark at the current cursor position, and `'{a-z}` jumps to it. In delta, this would let you bookmark specific views or tasks: - `ma` while viewing task #42 → mark `a` = task #42's detail - `'a` from anywhere → reopen task #42's detail - `mb` in Queue filtered to "School" → mark `b` = Queue + School filter - `'b` from Calendar → jump to Queue with School filter `''` (double apostrophe) jumps to the position before the last jump — a simpler version of `<C-o>` that only goes back one step. This is effectively the same as the alternate buffer for most cases, but in vim it specifically means "position before last jump" rather than "last buffer." Marks are a stretch goal. The jump list and alternate buffer cover 90% of the navigation pain. ## Technical notes ### Navigation location type A "location" needs to capture enough state to restore a view: ``` type NavLocation = { path: string // e.g. "/", "/kanban", "/calendar" params?: URLSearchParams // category filter, date filter taskId?: number // if a task detail was open viewState?: { // optional sub-view state calendarDate?: string calendarMode?: "week" | "month" kanbanColumn?: number cursorIndex?: number } } ``` ### State management The jump list and alternate buffer should be **app-level state, separate from browser history**. Reasons: - `router.back()` uses the browser history stack, which includes external navigations, page refreshes, and other noise. It is not a clean representation of in-app navigation. - Next.js `router.push()` adds to browser history. The jump list must be orthogonal — pressing `<C-o>` should not also trigger a browser back. - The jump list needs to store richer state than what fits in a URL (e.g. cursor position, open task detail, calendar selection). Implementation options: 1. **React context + `useRef`** — simplest. A `NavigationProvider` wrapping the app holds the jump list array and alternate buffer pointer in refs (not state, to avoid re-renders on every push). Expose `pushJump()`, `jumpBack()`, `jumpForward()`, `getAlternate()`, and `goAlternate()` via context. Navigation functions call `router.push()` internally when the path changes. 2. **Zustand store** — if the app adopts Zustand later, the jump list fits naturally as a store slice. Same API surface. 3. **`sessionStorage`** — persist the jump list across page refreshes (but not across tabs). Useful if the user refreshes and wants to retain their navigation trail. Can be combined with option 1 — write-through to `sessionStorage` on every push. Option 1 is the right starting point. `sessionStorage` persistence can be layered on later. ### Integration with `global-keyboard.tsx` The `<C-o>`, `<C-i>`, and `<C-6>` handlers belong in `GlobalKeyboard` since they are view-agnostic. The current `VIEW_KEYS` handler that calls `router.push()` would need to also call `pushJump()` before navigating. Same for category filter keys (`1`-`9`). View-specific jump triggers (e.g. `Enter` to open task detail, `Enter` on calendar day) would call `pushJump()` from their respective components via the context. ### Interaction with `isInputFocused()` `<C-o>` and `<C-i>` use the `Ctrl` modifier, so they are unlikely to conflict with text input. But `<C-6>` could conflict with some input fields. The existing `isInputFocused()` guard (currently duplicated in 4 files — tracked separately) should gate these bindings the same way it gates other shortcuts. ### Keymap help update Add a "Navigation" section to `KeymapHelp` with `<C-o>`, `<C-i>`, and `<C-6>`. ## References - `:help jumplist` — vim's jump list documentation - `:help alternate-file` — vim's alternate buffer (`<C-^>`) documentation - `:help mark-motions` — vim's mark system
barrettruth commented 2026-03-25 17:06:36 +00:00

DO NOT IMPLEMENT MARKS.

DO NOT IMPLEMENT MARKS.
barrettruth commented 2026-03-25 21:49:21 +00:00

Implemented in recent commits. NavigationProvider in src/contexts/navigation.tsx provides pushJump, jumpBack (Ctrl+o), jumpForward (Ctrl+i), goAlternate (Ctrl+6). Jump list has max 100 entries, duplicate collapsing, forward truncation on new jump. Integrated across queue, kanban, calendar views, and global-keyboard.tsx. Keymap help section added. Marks (m{a-z}) were scoped as a stretch goal and are not included.

Implemented in recent commits. `NavigationProvider` in `src/contexts/navigation.tsx` provides `pushJump`, `jumpBack` (Ctrl+o), `jumpForward` (Ctrl+i), `goAlternate` (Ctrl+6). Jump list has max 100 entries, duplicate collapsing, forward truncation on new jump. Integrated across queue, kanban, calendar views, and `global-keyboard.tsx`. Keymap help section added. Marks (`m{a-z}`) were scoped as a stretch goal and are not included.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
barrettruth/delta#67
No description provided.