feat: add editor tabs
This commit is contained in:
@@ -28,6 +28,8 @@ Vamp starts in Normal mode. Press `i`, type text, then Esc to return to Normal.
|
||||
| Undo/redo in Normal | `u` / `U` (Shift+u) |
|
||||
| Split panes in Normal | `_` above/below; `\|` left/right; Option/Alt+h/j/k/l changes focus |
|
||||
| Close a pane | `:pane.close`; `:pane.force_close` explicitly discards exclusively displayed unsaved edits |
|
||||
| Open/switch tabs in Normal | `:tab.new` opens the current buffer; Ctrl+Option/Alt+h/l switches previous/next |
|
||||
| Close a tab | `:tab.close`; `:tab.force_close` explicitly discards exclusively displayed unsaved edits |
|
||||
| Delete/change whole lines in Normal | `dd` / `cc`; leading counts work (`3dd`, `2cc`) |
|
||||
| Search in Normal | `/`, type a literal query, Enter; `n`/`N` for next/previous |
|
||||
| Select/copy/cut | `v` characterwise or `V` whole rows, move to extend, then `y`/`d` in Visual |
|
||||
@@ -48,7 +50,7 @@ Ruby configuration and plugins run with your account's filesystem/process permis
|
||||
|
||||
## Current limits
|
||||
|
||||
- One visible buffer at a time; no splits/tabs or graphical frontend.
|
||||
- Terminal frontend only; no graphical frontend.
|
||||
- Modal editing, not full Vim compatibility: characterwise and linewise Visual selections and counted `dd`/`cc`, but no block selection, named registers, or general operator–motion grammar. Search is literal, case-sensitive, and single-line.
|
||||
- Native opt-in LSP supports diagnostics, go-to-definition, plain-text hover, manual primary-caret completion with snippet arguments, and automatic semantic highlighting—not rename, formatting, or code actions. Completion has no automatic popup or acceptance step; other carets are preserved and translated through the chosen edits. Snippet regex transforms and unresolved variables without defaults are omitted.
|
||||
- System clipboard access supports macOS and Linux. Separate clipboard mode keeps internal editing available on unsupported/headless environments.
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
- [x] remove diff viewer's file explorer's `A added M modified...` explanation - it's standard and the explanation doesn't fit in the pane, so it can go.
|
||||
- no replacement help footer in diff file lists; reclaim its row for file entries.
|
||||
- [x] diff view: add character-wise change highlight (enabled by default).
|
||||
- [ ] add tabs: tab support, editor action to open a buffer in a new tab, default tab navigation is `ctrl+option+h/l`, close tab action.
|
||||
- [x] add tabs: tab support, editor action to open a buffer in a new tab, default tab navigation is `ctrl+option+h/l`, close tab action.
|
||||
- per-tab pane layouts, views, explorers, and comparisons; shared buffers, tab bar, and guarded close/force-close actions.
|
||||
- [ ] diff view: lsp actions should act as they normally would. if an lsp action would open another file, then the file should be opened in another tab outside the diff view - if the referenced file/symbol is part of the changes in the diff, it should just be opened in the diff view.
|
||||
- this will require tab support.
|
||||
- [ ] diff view: change diff view modes; choose between split and unified diff view (split by default).
|
||||
|
||||
@@ -8,7 +8,7 @@ Contributor-facing module boundaries and implementation contracts. Project termi
|
||||
|
||||
One editor crate; the workspace also includes its patched Crossterm dependency. Terminal-coordinate types remain outside the editing backend:
|
||||
|
||||
- `editor`: owns buffer lifecycle and per-buffer text, saved state, primary/selection set, modes, search, and undo/redo; session-wide clipboard policy, independent registers and owned-system fragment metadata, semantic native repeat, action metadata, command-palette and file-picker focus/dispatch, key notation, key-sequence/count prefixes and binding resolution, modal paste policy, saving, command ownership, capabilities, and validated edit effects. Its low-level interface returns an `Invocation` when an external command is selected.
|
||||
- `editor`: owns shared buffer lifecycle/text/saved state/undo history and independent tab/pane layouts, focused views, modes, selections, searches, explorers, and comparisons; session-wide clipboard policy, independent registers and owned-system fragment metadata, semantic native repeat, action metadata, command-palette and file-picker focus/dispatch, key notation, key-sequence/count prefixes and binding resolution, modal paste policy, saving, command ownership, capabilities, and validated edit effects. Its low-level interface returns an `Invocation` when an external command is selected.
|
||||
- `files`: owns the gitignore-aware directory/file catalog, authoritative OS paths, compact tree projection, query filtering, browse/search focus, expansion/selection restoration, navigation, and scan/error state shared by floating and docked presentations.
|
||||
Virtual file leaves retain their original catalog input index for caller-owned annotations; scanned entries and directories remain unannotated.
|
||||
- `diff`: owns searchable Before/After selection, category, focus and explorer visibility, request freshness, read-only comparison DTOs, aligned rows with cached grapheme-level change ranges, and shared vertical/independent horizontal offsets. Changed paths form a virtual `files::FilePicker` catalog, reusing the normal tree projection, filtering, and navigation without scanning disk. It has no subprocess or terminal dependencies.
|
||||
@@ -19,7 +19,7 @@ One editor crate; the workspace also includes its patched Crossterm dependency.
|
||||
- `plugin`: owns generic document snapshots/events, zero-based grapheme positions, diagnostics, frontend-independent semantic token kinds/modifiers and completion edit/item DTOs, and versioned asynchronous update contracts. Semantic token and completion updates are native-only, not part of the Ruby wire protocol; it has no LSP protocol types.
|
||||
- `lsp`: owns opt-in native server workers, bounded framed JSON-RPC, synchronization/capability negotiation, settings, UTF-16/file-URI conversion, definition/hover results, completion decoding, and semantic token requests/legend decoding. Its private configuration resolver supplies presets without restricting language IDs.
|
||||
- `snippet`: owns bounded, frontend-independent template parsing and expansion into plain replacement text and byte-relative tabstops with per-occurrence ancestry. It never edits documents or owns input policy.
|
||||
- `session`: `EditorSession` owns the editor, native LSP, and Ruby hosts together. It loads configuration once, registers eligible plugins before installing shared bindings, tracks document open/change/save/close events, polls native and plugin results, validates versioned diagnostics/navigation/completion, and retires failed plugin ownership. A cancellable diff worker owns repository I/O off the input thread; tagged results cannot reopen closed or superseded selections. Diff focus suppresses asynchronous editing/navigation effects without retiring healthy plugins.
|
||||
- `session`: `EditorSession` owns the editor, native LSP, and Ruby hosts together. It loads configuration once, registers eligible plugins before installing shared bindings, tracks document open/change/save/close events, polls native and plugin results, validates versioned diagnostics/navigation/completion, and retires failed plugin ownership. Each tab with a comparison has a cancellable repository worker; inactive tabs keep their workers and receive tagged results without changing visible focus. Closed or superseded comparisons reject stale results. Diff focus suppresses asynchronous editing/navigation effects without retiring healthy plugins.
|
||||
- `tui`: translates terminal events to session input, renders read-only active-buffer state, keeps frontend-owned viewports and syntax caches by stable buffer ID or immutable diff snapshot/file identity, and advertises `text`, `styled_text`, and `diagnostics` capabilities. It maps semantic tokens and diagnostics to terminal styling and has no server/protocol logic or paste acceptance policy.
|
||||
- `ruby`: evaluates configuration and runs isolated plugin hosts. Protocol, deadlines, and process cleanup stay inside this module.
|
||||
- The executable selects paths and frontend capabilities, opens an editor session, and launches the TUI.
|
||||
@@ -37,6 +37,8 @@ Frontends inspect `g_pending()` for the built-in Normal/Visual `gt`/`gb` prefix
|
||||
Frontends inspect `line_operator_pending()` for the optional Normal `d`/`c` prefix. Dedicated `dd`/`cc` actions project counted caret rows through existing linewise selection normalization, clipboard publication, and atomic range editing, without exposing a temporary Visual mode. Clipboard failures restore the original targets before returning. Repeat recipes retain the requested row count rather than the originally clamped range; change recipes share the existing Insert undo group. There is no general operator–motion parser.
|
||||
Editor-owned jump history is a bounded 100-location `VecDeque` of buffer IDs and grapheme cursors plus a current index, with no copied paths/text. Successful high-level navigation records departure/destination once; traversal uses unrecorded buffer activation and position placement, skips closed/clamped-duplicate locations, and truncates forward entries only on a distinct new jump. Session synchronization permanently invalidates pending LSP position requests after their source buffer/revision/caret/mode is left, so returning through history cannot revive an old definition reply.
|
||||
Frontends inspect `buffer_id()`, `buffer_count()`, `has_buffer(id)`, and the ascending-ID `buffers()` iterator of borrowed path/dirty/active metadata. Existing text, cursor, selection, search, and mode methods describe only the active buffer.
|
||||
Frontends inspect `tab_id()` for stable identity, `tab_index()` for display order, `tab_count()`, and `tab_buffer(index)` for borrowed title metadata. `pane_layout()` describes only the active tab; globally unique pane IDs keep inactive tabs' viewports alive. Documents remain unique across all tabs, while independent pane/buffer views are parked and restored without copying text.
|
||||
|
||||
Frontends inspect `file_picker()` for query, presentation, view, explorer-local INSERT/filter subfocus, selection, and errors, and iterate borrowed `FileRow` metadata (actual path, display label, directory flag, visible depth, expansion). Dock side is a logical preference. They allocate rectangles, add folder suffixes/colors, and render; hierarchy, filtering, parent/child navigation, focus transitions, and selected-file opening remain backend-owned.
|
||||
Frontends inspect `diagnostics()` and `diagnostic_at_cursor()` for validated active-buffer annotations. Diagnostics are owned by core LSP or a plugin and buffer revision; text changes clear them. Native server retirement removes only that server's contribution, preserving sibling diagnostics. The TUI underlines annotated text, colors affected gutter numbers by severity, and displays the primary caret's diagnostic message without replacing search/selection backgrounds.
|
||||
Frontends borrow `semantic_tokens()` for sorted, single-line grapheme spans from the active buffer. Immutable `Arc` slices are shared by native LSP and buffer storage; painting does not copy tokens. Text/path changes discard overlays, and native updates validate the whole applicable annotation batch before mutation. The TUI merges semantic styling into its existing glyph pass above syntax foregrounds but below diagnostic/search/selection/caret styling.
|
||||
@@ -54,7 +56,7 @@ Redraws stage Ratatui's buffer diff, cursor placement, and DEC synchronized-upda
|
||||
|
||||
`vendor/crossterm` retains Crossterm 0.29.0's upstream source and MIT license, with a small Unix decoder patch exposing `Event::ColorSchemeChanged` for `CSI ? 997 ; 1/2 n`. The report signals a palette refresh, not a choice of hard-coded light/dark colors. Valid fragmented reports and malformed color reports leave following keys intact. Cargo patches both direct and Ratatui dependency paths to this one copy; remove the patch/workspace member once upstream supports these notifications.
|
||||
|
||||
The TUI uses Ratatui constraint-based layout for a flexible buffer viewport, a one-row status bar, and a one-row prompt/message area. Scrolling, clipping, highlights, and cursor placement use the allocated regions, not terminal-wide offsets. Below three rows the status bar is hidden; at one row the buffer remains visible unless an active command/search prompt takes priority. Zero-sized terminals render nothing. Layout and terminal-cell text mapping remain frontend-owned.
|
||||
The TUI uses Ratatui constraint-based layout for a flexible buffer viewport, a one-row status bar, and a one-row prompt/message area. Multiple tabs reserve a top row for numbered filenames, dirty markers, and active-tab styling; overflow keeps the active title visible. Scrolling, clipping, highlights, and cursor placement use the allocated regions, not terminal-wide offsets. Below three workspace rows the status bar is hidden; at one workspace row the buffer remains visible unless an active command/search prompt takes priority. Zero-sized terminals render nothing. Layout and terminal-cell text mapping remain frontend-owned.
|
||||
|
||||
Mouse wheel input uses the last rendered document/panel rectangles for hit-testing. Document offsets and explorer scroll state remain frontend-owned; three-row wheel steps never become document cursor motions. Manual document views persist until logical caret-reveal, caret/selection, mode, revision, or buffer changes; offscreen primary and browsing-selection cursors are not placed. Hover wheel input reuses the existing editor-owned popup scrolling.
|
||||
|
||||
|
||||
@@ -57,15 +57,17 @@ For command-capable actions, canonical Ex names derive automatically from the ac
|
||||
Key notation follows [Herdr's readable style](https://herdr.dev/docs/configuration/#keybindings). Literal non-control Unicode characters are case-sensitive (`n`, `J`, `+`); named keys and modifiers are case-insensitive:
|
||||
|
||||
- Named keys: `esc`, `enter`, `backspace`, `delete`, `left`, `right`, `up`, `down`, `home`, `end`, `tab`, `space`.
|
||||
- Modifiers: `ctrl+a` through `ctrl+z`, `alt+a` through `alt+z` (`option+` is an alias for `alt+`), `shift+a` through `shift+z`, and `shift+tab`. `shift+j` and `J` address the same key.
|
||||
- Modifiers: `ctrl+a` through `ctrl+z`, `alt+a` through `alt+z` (`option+` is an alias for `alt+`), `ctrl+alt+a` through `ctrl+alt+z` (either modifier order), `shift+a` through `shift+z`, and `shift+tab`. `shift+j` and `J` address the same key.
|
||||
- Named punctuation: `minus`, `comma`, `ampersand`, `plus`, `backtick`. Each addresses the same binding as its literal character.
|
||||
|
||||
`ctrl+i` aliases `tab`, and `ctrl+m` aliases `enter`, including inside sequences. Ctrl+h and Ctrl+j are distinct control keys, not Backspace/Enter aliases. A single modifier on an ASCII letter is supported, plus Shift+Tab. Cmd/Super, combined modifiers, other modified special keys, and Vim-style angle-bracket notation are rejected rather than silently treated as another key.
|
||||
`ctrl+i` aliases `tab`, and `ctrl+m` aliases `enter`, including inside sequences. Ctrl+h and Ctrl+j are distinct control keys, not Backspace/Enter aliases. ASCII letters support a single modifier or combined Ctrl+Alt/Option; Shift+Tab is also supported. Cmd/Super, other combined modifiers, other modified special keys, and Vim-style angle-bracket notation are rejected rather than silently treated as another key.
|
||||
|
||||
Normal mode binds `ctrl+o` to `config.core.jump_back` and `ctrl+i`/`tab` to `config.core.jump_forward`. Insert-mode Tab inserts literal text outside snippets; inside one it moves to the next argument. Override or disable these keys with the ordinary per-mode binding interface; jump actions also appear in the command palette.
|
||||
|
||||
Normal mode uses literal `_` and `|` for above/below and left/right splits, and `alt+h/j/k/l` for directional pane focus. Bind the produced punctuation rather than a physical Shift combination. Close actions have no default binding; for example, `config.bind "leader x", config.core.pane.close`. See [panes and splits](editing.md#panes-and-splits) for shared-buffer and unsaved-change policies.
|
||||
|
||||
Normal mode binds `ctrl+option+h/l` (`ctrl+alt+h/l`) to previous/next tab. Tab actions have no default open/close binding; for example, `config.bind "leader t", config.core.tab.new` and `config.bind "leader x", config.core.tab.close`. See [tabs](editing.md#tabs) for layout preservation and close guards.
|
||||
|
||||
The built-in `gt`/`gb` sequences move to the first/last line in Normal and Visual; `gf` follows Markdown file links in Normal only. Bind `config.core.document_start`, `config.core.document_end`, or `config.core.follow_link` to another key or sequence to add another route. A configured/disabled `g`, a configured sequence starting with `g`, or choosing `g` as the leader overrides the built-in prefix in that binding mode.
|
||||
|
||||
Normal `dd` and `cc` delete/change complete rows, with leading counts. Bind `config.core.delete_line` or `config.core.change_line` to another key or sequence for another route. A configured/disabled `d` or `c`, a configured sequence starting with that key, or choosing it as the leader overrides its built-in doubled sequence. These are dedicated line actions, not general operator–motion grammar.
|
||||
@@ -87,6 +89,7 @@ Normal `dd` and `cc` delete/change complete rows, with leading counts. Bind `con
|
||||
| Normal, Insert, Visual, Command, Search | `diff.close` |
|
||||
| Normal, Insert, Visual, Command, Search | `word_wrap.toggle` |
|
||||
| Normal | `pane.split_horizontal`, `pane.split_vertical`, `pane.focus_left`, `pane.focus_down`, `pane.focus_up`, `pane.focus_right`, `pane.close`, `pane.force_close` |
|
||||
| Normal | `tab.new`, `tab.previous`, `tab.next`, `tab.close`, `tab.force_close` |
|
||||
| Normal | `jump_back`, `jump_forward` |
|
||||
| Normal | `lsp.definition`, `lsp.diagnostics`, `lsp.hover` |
|
||||
| Insert | `lsp.completion`, `snippet.next`, `snippet.previous` |
|
||||
@@ -111,7 +114,7 @@ Normal `dd` and `cc` delete/change complete rows, with leading counts. Bind `con
|
||||
|
||||
`core.select` (`v`) and `core.select_linewise` (`V`) select characterwise and whole-row Visual shapes. Switching selectors retains endpoints; pressing the active shape's selector again cancels. Both use `config.modes.visual`; there is no separate linewise binding mode. `core.select_lines` (Visual Ctrl+l) still splits touched rows into Normal carets.
|
||||
|
||||
`core.save` saves the current path; `core.save_and_quit` saves the active buffer and quits only if no buffer remains dirty. `core.quit` refuses unsaved changes in any buffer. `core.edit` and `core.buffer` open prefilled Ex prompts for their required path or buffer ID; deletion actions target the current buffer. Configured `core.force_quit`, `core.buffer_force_delete`, and `core.pane.force_close` keys act immediately, without confirmation.
|
||||
`core.save` saves the current path; `core.save_and_quit` saves the active buffer and quits only if no buffer remains dirty. `core.quit` refuses unsaved changes in any buffer. `core.edit` and `core.buffer` open prefilled Ex prompts for their required path or buffer ID; deletion actions target the current buffer. Configured `core.force_quit`, `core.buffer_force_delete`, `core.pane.force_close`, and `core.tab.force_close` keys act immediately, without confirmation.
|
||||
|
||||
## Binding overrides and failures
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ Rust tests live alongside their modules; native LSP integration checks are in [`
|
||||
- Editor checks cover Unicode editing, multi-target state, undo/redo, saving failures, and modal paste acceptance and rejection atomicity.
|
||||
- Markdown navigation checks cover inline/reference destinations, escaped/percent-encoded paths, heading markup and duplicates, same-document fragments, dirty target reuse, failed navigation atomicity, primary-only following, and `gf` prefix cancellation/configuration overrides.
|
||||
- Jump-history checks cover cross-buffer/same-document navigation, search and picker recording, counts and repeated visits, bounded history, branch truncation, closed buffers, edited-coordinate clamping, dirty text/undo preservation, Ctrl+i/Tab aliases, and custom bindings. Real LSP fixtures cover definition round-trips and delayed replies after leaving and returning to the same source location.
|
||||
- Tab checks cover shared edits/history with independent pane layouts, focused views, explorers and viewports; cross-tab close guards, immutable snapshots, hidden comparison refresh/result routing, and separate repository workers. Rendered checks cover sanitized/dirty titles, active-tab visibility in narrow terminals, overlay row reservation, and exclusion of hidden diff geometry from palette polling and viewport reporting.
|
||||
- Session and Ruby checks exercise real Ruby execution, command/capability ownership, effect validation, configuration fallback, plugin retirement, crash/timeout recovery, and inherited-pipe cleanup.
|
||||
- Unix terminal-hook checks use isolated child processes and real PTYs to keep process-global hooks out of the parallel runner. They cover raw/alternate-screen/mouse-capture cleanup on entry failure, normal return, I/O errors, and panics during frame construction, including original panic-payload propagation.
|
||||
- Redraw checks inspect actual Crossterm output during repeated scrolling: supporting terminals receive synchronized frames, terminals ignoring synchronization never paint with a visible hardware cursor, and the final caret remains visible. A protocol-state regression verifies that the terminal is not left in synchronized-output mode while the next frame is computed.
|
||||
|
||||
+19
-2
@@ -17,6 +17,7 @@ Default controls and editing behavior. For custom bindings, see [configuration](
|
||||
| Normal | `h`/`j`/`k`/`l` or arrows move every caret; `0`/`$` or Home/End |
|
||||
| Normal | `_` splits above/below; `\|` splits left/right; the new pane inherits the current buffer and view |
|
||||
| Normal | Option/Alt+h/j/k/l focuses the neighboring pane left/down/up/right |
|
||||
| Normal | Ctrl+Option/Alt+h/l switches to the previous/next tab, wrapping at either end |
|
||||
| Normal | `w`/`b`/`e` move every caret to next word start, previous/current word start, word end; `W`/`B`/`E` for whitespace-delimited WORDs |
|
||||
| Normal, Visual | `f<char>` seeks forward on the current line; `F<char>` seeks backward; `2fx` finds the second `x` after each caret |
|
||||
| Normal | `gf` follows a Markdown file link or heading fragment at the primary caret |
|
||||
@@ -194,8 +195,9 @@ Closing a pane gives its space to its sibling:
|
||||
| --- | --- | --- |
|
||||
| Clean buffer displayed only here | Close pane and buffer | Same |
|
||||
| Dirty buffer displayed only here | Refuse; save or force-close explicitly | Discard edits and close pane and buffer |
|
||||
| Buffer also displayed in another pane | Close only this pane; retain the buffer and edits | Same |
|
||||
| Last workspace pane | Quit only if every open buffer is clean, including hidden buffers | Quit without saving |
|
||||
| Buffer also displayed in another pane or tab | Close only this pane; retain the buffer and edits | Same |
|
||||
| Last pane in a tab, with other tabs open | Close the tab using the same shared-buffer and unsaved-change rules | Same, discarding only exclusively displayed edits |
|
||||
| Last pane in the last tab | Quit only if every open buffer is clean, including hidden buffers | Quit without saving |
|
||||
|
||||
There is no default close-pane binding. Option must send Alt/Meta in the terminal; on macOS, configure the terminal's Option-key behavior if it instead produces special characters. These bindings do not replace existing Ctrl+j or uppercase motion/edit bindings.
|
||||
|
||||
@@ -205,6 +207,21 @@ Floating windows overlay the entire editor, above all workspace panes. File pick
|
||||
|
||||
Diff comparisons reuse the split geometry and directional-focus engine. Option/Alt navigation moves between Before, After, and Files first, then to a neighboring workspace pane when there is no internal target in that direction. Splitting a diff text view creates an ordinary sibling showing the same buffer; historical snapshots remain read-only, including after the comparison closes.
|
||||
|
||||
## Tabs
|
||||
|
||||
Use `:tab.new` to open the current buffer in a new, focused tab with one pane. Each tab preserves its split layout, focused pane, explorer, and comparison. Buffers and undo/redo history remain shared; carets, selections, searches, wrapping, and scroll positions are independent views. Opening a historical diff buffer in a tab keeps it read-only, even after the source comparison closes.
|
||||
|
||||
| Canonical command | Typed action | Behavior |
|
||||
| --- | --- | --- |
|
||||
| `:tab.new` | `config.core.tab.new` | Open the current buffer in a new tab |
|
||||
| `:tab.previous` / `:tab.next` | `config.core.tab.previous` / `config.core.tab.next` | Switch tabs, wrapping at either end; Ctrl+Option/Alt+h/l in Normal |
|
||||
| `:tab.close` | `config.core.tab.close` | Close the current tab and all its panes, protecting unsaved changes |
|
||||
| `:tab.force_close` | `config.core.tab.force_close` | Explicitly discard exclusively displayed edits and close the tab |
|
||||
|
||||
Multiple tabs show a top row with numbered filenames, dirty markers, and an active-tab highlight. Narrow terminals keep the active tab visible. New/close actions have no default keybinding; for example, `config.bind "leader t", config.core.tab.new`.
|
||||
|
||||
Closing a tab refuses dirty buffers whose final displayed owner would disappear, including a comparison's working and return buffers. Buffers displayed in another tab remain open with their edits, even on force-close. Other hidden buffers remain loaded. Closing the last tab quits only if every open buffer is clean; force-close quits without saving.
|
||||
|
||||
## Buffers
|
||||
|
||||
Each buffer retains its shared text, path, saved state, and undo/redo history. View state is retained separately for each pane and buffer. Caret-following adjusts that view's offsets as needed; mouse-wheel scrolling can temporarily detach it from the caret. Clipboard fragments, bindings, plugin hosts, and plugin retirement remain session-wide. Plugin context and effects refer to the focused pane's active buffer.
|
||||
|
||||
+733
-40
@@ -26,6 +26,7 @@ pub enum Input {
|
||||
Char(char),
|
||||
Ctrl(char),
|
||||
Alt(char),
|
||||
CtrlAlt(char),
|
||||
Paste(String),
|
||||
Escape,
|
||||
Enter,
|
||||
@@ -387,6 +388,23 @@ core_actions![
|
||||
false,
|
||||
true
|
||||
),
|
||||
("core.tab.new", Action::TabNew, &[Mode::Normal], true),
|
||||
(
|
||||
"core.tab.previous",
|
||||
Action::TabPrevious,
|
||||
&[Mode::Normal],
|
||||
true
|
||||
),
|
||||
("core.tab.next", Action::TabNext, &[Mode::Normal], true),
|
||||
("core.tab.close", Action::TabClose, &[Mode::Normal], true),
|
||||
(
|
||||
"core.tab.force_close",
|
||||
Action::TabForceClose,
|
||||
&[Mode::Normal],
|
||||
true,
|
||||
false,
|
||||
true
|
||||
),
|
||||
("core.insert", Action::Insert, &[Mode::Normal]),
|
||||
(
|
||||
"core.insert_first_nonblank",
|
||||
@@ -800,6 +818,20 @@ fn parse_key(key: &str) -> Result<Input, String> {
|
||||
return Ok(Input::Char(ch));
|
||||
}
|
||||
let invalid = || format!("Invalid binding key: {key:?}");
|
||||
if let Some((first, rest)) = key.split_once('+')
|
||||
&& let Some((second, name)) = rest.split_once('+')
|
||||
{
|
||||
let alt = |modifier: &str| {
|
||||
modifier.eq_ignore_ascii_case("alt") || modifier.eq_ignore_ascii_case("option")
|
||||
};
|
||||
if ((first.eq_ignore_ascii_case("ctrl") && alt(second))
|
||||
|| (alt(first) && second.eq_ignore_ascii_case("ctrl")))
|
||||
&& let Some(ch) = character(name).filter(char::is_ascii_alphabetic)
|
||||
{
|
||||
return Ok(Input::CtrlAlt(ch.to_ascii_lowercase()));
|
||||
}
|
||||
return Err(invalid());
|
||||
}
|
||||
let (modifier, name) = key
|
||||
.split_once('+')
|
||||
.map_or((None, key), |(modifier, name)| (Some(modifier), name));
|
||||
@@ -876,6 +908,7 @@ fn append_key(display: &mut String, input: &Input) {
|
||||
Input::Char(ch) => display.push(*ch),
|
||||
Input::Ctrl(ch) => write!(display, "Ctrl+{ch}").expect("writing to a String"),
|
||||
Input::Alt(ch) => write!(display, "Alt+{ch}").expect("writing to a String"),
|
||||
Input::CtrlAlt(ch) => write!(display, "Ctrl+Alt+{ch}").expect("writing to a String"),
|
||||
Input::Escape => display.push_str("Esc"),
|
||||
Input::Enter => display.push_str("Enter"),
|
||||
Input::Backspace => display.push_str("Backspace"),
|
||||
@@ -923,6 +956,8 @@ fn default_bindings() -> HashMap<Mode, HashMap<Input, Binding>> {
|
||||
(Input::Alt('j'), PaneFocusDown),
|
||||
(Input::Alt('k'), PaneFocusUp),
|
||||
(Input::Alt('l'), PaneFocusRight),
|
||||
(Input::CtrlAlt('h'), TabPrevious),
|
||||
(Input::CtrlAlt('l'), TabNext),
|
||||
] {
|
||||
bindings
|
||||
.entry(Mode::Normal)
|
||||
@@ -1170,6 +1205,26 @@ struct Pane {
|
||||
views: BTreeMap<usize, ViewState>,
|
||||
}
|
||||
|
||||
struct Tab {
|
||||
pane_id: usize,
|
||||
pane_layout: PaneTree,
|
||||
file_picker: Option<FilePicker>,
|
||||
diff: Option<DiffState>,
|
||||
parked_diff: Option<DiffState>,
|
||||
diff_owner: Option<usize>,
|
||||
diff_prompt_mode: Option<Mode>,
|
||||
diff_pane_bounds: [(Focus, PaneRect); 3],
|
||||
diff_bound: Option<(u64, usize, Focus, usize)>,
|
||||
diff_return_buffer: Option<usize>,
|
||||
outstanding_diff_request: Option<u64>,
|
||||
}
|
||||
|
||||
impl Tab {
|
||||
fn diff(&self) -> Option<&DiffState> {
|
||||
self.diff.as_ref().or(self.parked_diff.as_ref())
|
||||
}
|
||||
}
|
||||
|
||||
struct Buffer {
|
||||
id: usize,
|
||||
diff_snapshot: Option<(u64, usize, Focus)>,
|
||||
@@ -1275,6 +1330,10 @@ pub struct Editor {
|
||||
next_pane_id: usize,
|
||||
panes: BTreeMap<usize, Pane>,
|
||||
pane_layout: PaneTree,
|
||||
tab_id: usize,
|
||||
next_tab_id: usize,
|
||||
tab_order: Vec<usize>,
|
||||
tabs: BTreeMap<usize, Tab>,
|
||||
pane_bounds: PaneRect,
|
||||
render_pane_focused: bool,
|
||||
diff_owner: Option<usize>,
|
||||
@@ -1344,6 +1403,10 @@ impl Editor {
|
||||
},
|
||||
)]),
|
||||
pane_layout: PaneTree::new(1),
|
||||
tab_id: 1,
|
||||
next_tab_id: 2,
|
||||
tab_order: vec![1],
|
||||
tabs: BTreeMap::new(),
|
||||
pane_bounds: PaneRect {
|
||||
x: 0,
|
||||
y: 0,
|
||||
@@ -1464,6 +1527,131 @@ impl Editor {
|
||||
Ok(editor)
|
||||
}
|
||||
|
||||
pub fn tab_id(&self) -> usize {
|
||||
self.tab_id
|
||||
}
|
||||
|
||||
pub fn tab_index(&self) -> usize {
|
||||
self.tab_order
|
||||
.iter()
|
||||
.position(|id| *id == self.tab_id)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
pub fn tab_count(&self) -> usize {
|
||||
self.tab_order.len()
|
||||
}
|
||||
|
||||
pub fn tab_buffer(&self, index: usize) -> Option<BufferInfo<'_>> {
|
||||
let id = *self.tab_order.get(index)?;
|
||||
let pane = if id == self.tab_id {
|
||||
self.pane_id
|
||||
} else {
|
||||
self.tabs.get(&id)?.pane_id
|
||||
};
|
||||
self.buffer(self.pane_owned_buffer(pane))
|
||||
.map(|buffer| buffer.info(id == self.tab_id))
|
||||
}
|
||||
|
||||
pub(crate) fn tab_diff(&self, id: usize) -> Option<&DiffState> {
|
||||
if id == self.tab_id {
|
||||
self.workspace_diff()
|
||||
} else {
|
||||
self.tabs.get(&id)?.diff()
|
||||
}
|
||||
}
|
||||
|
||||
fn select_tab(&mut self, id: usize) {
|
||||
if id == self.tab_id {
|
||||
return;
|
||||
}
|
||||
let next = self.tabs.remove(&id).expect("selected tab exists");
|
||||
self.stash_view();
|
||||
let previous = Tab {
|
||||
pane_id: std::mem::replace(&mut self.pane_id, next.pane_id),
|
||||
pane_layout: std::mem::replace(&mut self.pane_layout, next.pane_layout),
|
||||
file_picker: std::mem::replace(&mut self.file_picker, next.file_picker),
|
||||
diff: std::mem::replace(&mut self.diff, next.diff),
|
||||
parked_diff: std::mem::replace(&mut self.parked_diff, next.parked_diff),
|
||||
diff_owner: std::mem::replace(&mut self.diff_owner, next.diff_owner),
|
||||
diff_pane_bounds: std::mem::replace(&mut self.diff_pane_bounds, next.diff_pane_bounds),
|
||||
diff_prompt_mode: std::mem::replace(&mut self.diff_prompt_mode, next.diff_prompt_mode),
|
||||
diff_bound: std::mem::replace(&mut self.diff_bound, next.diff_bound),
|
||||
diff_return_buffer: std::mem::replace(
|
||||
&mut self.diff_return_buffer,
|
||||
next.diff_return_buffer,
|
||||
),
|
||||
outstanding_diff_request: std::mem::replace(
|
||||
&mut self.outstanding_diff_request,
|
||||
next.outstanding_diff_request,
|
||||
),
|
||||
};
|
||||
self.tabs.insert(self.tab_id, previous);
|
||||
self.tab_id = id;
|
||||
let buffer_id = self.panes[&self.pane_id].buffer_id;
|
||||
if buffer_id != self.active.id {
|
||||
let buffer = self.parked.remove(&buffer_id).expect("tab buffer exists");
|
||||
let previous = std::mem::replace(&mut self.active, buffer);
|
||||
self.parked.insert(previous.id, previous);
|
||||
}
|
||||
self.load_view();
|
||||
}
|
||||
|
||||
fn new_tab(&mut self) {
|
||||
self.finish_pane_interaction();
|
||||
let buffer_id = self.active.id;
|
||||
let view = self.active.view.inherit();
|
||||
let pane_id = self.next_pane_id;
|
||||
self.next_pane_id += 1;
|
||||
self.panes.insert(
|
||||
pane_id,
|
||||
Pane {
|
||||
buffer_id,
|
||||
origin: None,
|
||||
views: BTreeMap::from([(buffer_id, view)]),
|
||||
},
|
||||
);
|
||||
let id = self.next_tab_id;
|
||||
self.next_tab_id += 1;
|
||||
self.tabs.insert(
|
||||
id,
|
||||
Tab {
|
||||
pane_id,
|
||||
pane_layout: PaneTree::new(pane_id),
|
||||
file_picker: None,
|
||||
diff: None,
|
||||
parked_diff: None,
|
||||
diff_owner: None,
|
||||
diff_pane_bounds: self.diff_pane_bounds,
|
||||
diff_prompt_mode: None,
|
||||
diff_bound: None,
|
||||
diff_return_buffer: None,
|
||||
outstanding_diff_request: None,
|
||||
},
|
||||
);
|
||||
self.tab_order.push(id);
|
||||
self.select_tab(id);
|
||||
self.normalize_targets();
|
||||
if self.active.view.mode == Mode::Insert {
|
||||
self.begin_insert();
|
||||
}
|
||||
}
|
||||
|
||||
fn adjacent_tab(&mut self, forward: bool) {
|
||||
self.finish_pane_interaction();
|
||||
let index = self.tab_index();
|
||||
let count = self.tab_count();
|
||||
let next = if forward {
|
||||
(index + 1) % count
|
||||
} else {
|
||||
(index + count - 1) % count
|
||||
};
|
||||
self.select_tab(self.tab_order[next]);
|
||||
if self.active.view.mode == Mode::Insert {
|
||||
self.begin_insert();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn pane_id(&self) -> usize {
|
||||
self.pane_id
|
||||
}
|
||||
@@ -1543,6 +1731,22 @@ impl Editor {
|
||||
fn with_pane<R>(&mut self, id: usize, callback: impl FnOnce(&mut Self) -> R) -> R {
|
||||
assert!(self.panes.contains_key(&id), "rendered pane exists");
|
||||
let focused = self.pane_id;
|
||||
let tab = self.tab_id;
|
||||
let target_tab = if self.pane_layout.contains(id) {
|
||||
tab
|
||||
} else {
|
||||
*self
|
||||
.tabs
|
||||
.iter()
|
||||
.find(|(_, tab)| tab.pane_layout.contains(id))
|
||||
.map(|(id, _)| id)
|
||||
.expect("pane belongs to a tab")
|
||||
};
|
||||
let target_focused = if target_tab == tab {
|
||||
focused
|
||||
} else {
|
||||
self.tabs[&target_tab].pane_id
|
||||
};
|
||||
let interaction = (id != focused).then(|| {
|
||||
(
|
||||
self.palette.take(),
|
||||
@@ -1556,11 +1760,14 @@ impl Editor {
|
||||
std::mem::take(&mut self.g_pending),
|
||||
self.line_pending.take(),
|
||||
self.seek_pending.take(),
|
||||
std::mem::take(&mut self.message),
|
||||
)
|
||||
});
|
||||
self.select_tab(target_tab);
|
||||
self.select_pane(id);
|
||||
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| callback(self)));
|
||||
self.select_pane(focused);
|
||||
self.select_pane(target_focused);
|
||||
self.select_tab(tab);
|
||||
if let Some(interaction) = interaction {
|
||||
(
|
||||
self.palette,
|
||||
@@ -1574,6 +1781,7 @@ impl Editor {
|
||||
self.g_pending,
|
||||
self.line_pending,
|
||||
self.seek_pending,
|
||||
self.message,
|
||||
) = interaction;
|
||||
}
|
||||
match result {
|
||||
@@ -1679,22 +1887,104 @@ impl Editor {
|
||||
}
|
||||
}
|
||||
|
||||
fn pane_owned_buffer(&self, id: usize) -> usize {
|
||||
if self.diff_owner == Some(id) {
|
||||
self.diff
|
||||
.as_ref()
|
||||
.or(self.parked_diff.as_ref())
|
||||
.and_then(|diff| diff.live_buffer)
|
||||
.or(self.diff_return_buffer)
|
||||
.unwrap_or(self.panes[&id].buffer_id)
|
||||
fn pane_buffers(&self, id: usize) -> [Option<usize>; 3] {
|
||||
let (diff, return_buffer) = if self.diff_owner == Some(id) {
|
||||
(self.workspace_diff(), self.diff_return_buffer)
|
||||
} else if let Some(tab) = self.tabs.values().find(|tab| tab.diff_owner == Some(id)) {
|
||||
(tab.diff(), tab.diff_return_buffer)
|
||||
} else {
|
||||
self.panes[&id].buffer_id
|
||||
(None, None)
|
||||
};
|
||||
[
|
||||
Some(self.panes[&id].buffer_id),
|
||||
diff.and_then(|diff| diff.live_buffer),
|
||||
return_buffer,
|
||||
]
|
||||
}
|
||||
|
||||
fn pane_owned_buffer(&self, id: usize) -> usize {
|
||||
let buffers = self.pane_buffers(id);
|
||||
buffers[1].or(buffers[2]).or(buffers[0]).unwrap()
|
||||
}
|
||||
|
||||
fn closing_buffers(&self, panes: &[usize]) -> Vec<usize> {
|
||||
let mut buffers = Vec::new();
|
||||
for pane in panes {
|
||||
for buffer in self.pane_buffers(*pane).into_iter().flatten() {
|
||||
if !buffers.contains(&buffer)
|
||||
&& !self.panes.keys().any(|other| {
|
||||
!panes.contains(other) && self.pane_buffers(*other).contains(&Some(buffer))
|
||||
})
|
||||
{
|
||||
buffers.push(buffer);
|
||||
}
|
||||
}
|
||||
}
|
||||
buffers
|
||||
}
|
||||
|
||||
fn forget_buffer(&mut self, id: usize) {
|
||||
self.parked.remove(&id);
|
||||
for pane in self.panes.values_mut() {
|
||||
pane.views.remove(&id);
|
||||
}
|
||||
self.jumps.retain(|jump| jump.buffer_id != id);
|
||||
self.jump_index = self.jump_index.min(self.jumps.len().saturating_sub(1));
|
||||
}
|
||||
|
||||
fn close_tab(&mut self, force: bool) {
|
||||
if self.tab_count() == 1 {
|
||||
if !force && self.any_dirty() {
|
||||
self.set_message("Unsaved changes; use :w or :tab.force_close");
|
||||
} else {
|
||||
self.quit = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
let panes: Vec<_> = self
|
||||
.panes
|
||||
.keys()
|
||||
.copied()
|
||||
.filter(|id| self.pane_layout.contains(*id))
|
||||
.collect();
|
||||
let buffers = self.closing_buffers(&panes);
|
||||
if !force
|
||||
&& buffers
|
||||
.iter()
|
||||
.any(|id| self.buffer(*id).is_some_and(Buffer::dirty))
|
||||
{
|
||||
self.set_message("Unsaved changes; use :w or :tab.force_close");
|
||||
return;
|
||||
}
|
||||
self.finish_pane_interaction();
|
||||
if let Some(owner) = self.diff_owner {
|
||||
self.with_pane(owner, |editor| {
|
||||
editor.release_diff_buffers();
|
||||
editor.diff = None;
|
||||
});
|
||||
self.parked_diff = None;
|
||||
self.diff_owner = None;
|
||||
self.outstanding_diff_request = None;
|
||||
}
|
||||
let id = self.tab_id;
|
||||
let index = self.tab_index();
|
||||
let next = self.tab_order[(index + 1) % self.tab_count()];
|
||||
self.select_tab(next);
|
||||
self.tabs.remove(&id);
|
||||
self.tab_order.remove(index);
|
||||
for pane in panes {
|
||||
self.panes.remove(&pane);
|
||||
}
|
||||
for buffer in buffers {
|
||||
self.forget_buffer(buffer);
|
||||
}
|
||||
}
|
||||
|
||||
fn close_pane(&mut self, force: bool) {
|
||||
if self.pane_layout.len() == 1 {
|
||||
if !force && self.any_dirty() {
|
||||
if self.tab_count() > 1 {
|
||||
self.close_tab(force);
|
||||
} else if !force && self.any_dirty() {
|
||||
self.set_message("Unsaved changes; use :w or :pane.force_close");
|
||||
} else {
|
||||
self.quit = true;
|
||||
@@ -1702,12 +1992,12 @@ impl Editor {
|
||||
return;
|
||||
}
|
||||
let id = self.pane_id;
|
||||
let buffer_id = self.pane_owned_buffer(id);
|
||||
let shared = self.panes.iter().any(|(other, pane)| {
|
||||
*other != id
|
||||
&& (pane.buffer_id == buffer_id || self.pane_owned_buffer(*other) == buffer_id)
|
||||
});
|
||||
if !shared && !force && self.buffer(buffer_id).is_some_and(Buffer::dirty) {
|
||||
let buffers = self.closing_buffers(&[id]);
|
||||
if !force
|
||||
&& let Some(buffer_id) = buffers
|
||||
.iter()
|
||||
.find(|buffer| self.buffer(**buffer).is_some_and(Buffer::dirty))
|
||||
{
|
||||
self.set_message(format!(
|
||||
"Buffer {buffer_id} has unsaved changes; use :w or :pane.force_close"
|
||||
));
|
||||
@@ -1724,13 +2014,8 @@ impl Editor {
|
||||
let next = self.pane_layout.close(id).expect("another pane exists");
|
||||
self.select_pane(next);
|
||||
self.panes.remove(&id);
|
||||
if !shared {
|
||||
self.parked.remove(&buffer_id);
|
||||
for pane in self.panes.values_mut() {
|
||||
pane.views.remove(&buffer_id);
|
||||
}
|
||||
self.jumps.retain(|jump| jump.buffer_id != buffer_id);
|
||||
self.jump_index = self.jump_index.min(self.jumps.len().saturating_sub(1));
|
||||
for buffer in buffers {
|
||||
self.forget_buffer(buffer);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1874,12 +2159,19 @@ impl Editor {
|
||||
}
|
||||
|
||||
pub(crate) fn apply_diff_result(&mut self, id: u64, result: Result<Outcome, String>) -> bool {
|
||||
if self.outstanding_diff_request != Some(id) {
|
||||
let owner = self
|
||||
.tabs
|
||||
.values()
|
||||
.find(|tab| tab.outstanding_diff_request == Some(id))
|
||||
.and_then(|tab| tab.diff_owner);
|
||||
return owner.is_some_and(|owner| {
|
||||
self.with_pane(owner, |editor| editor.apply_diff_result(id, result))
|
||||
});
|
||||
}
|
||||
if let Some(owner) = self.diff_owner.filter(|owner| *owner != self.pane_id) {
|
||||
return self.with_pane(owner, |editor| editor.apply_diff_result(id, result));
|
||||
}
|
||||
if self.outstanding_diff_request != Some(id) {
|
||||
return false;
|
||||
}
|
||||
self.outstanding_diff_request = None;
|
||||
let Some(diff) = &mut self.diff else {
|
||||
return false;
|
||||
@@ -2031,7 +2323,14 @@ impl Editor {
|
||||
self.activate(buffer);
|
||||
}
|
||||
self.parked.retain(|id, buffer| {
|
||||
buffer.diff_snapshot.is_none() || self.panes.values().any(|pane| pane.buffer_id == *id)
|
||||
buffer.diff_snapshot.is_none()
|
||||
|| self.panes.values().any(|pane| pane.buffer_id == *id)
|
||||
|| buffer.diff_snapshot.is_some_and(|role| {
|
||||
self.tabs.values().any(|tab| {
|
||||
tab.diff()
|
||||
.is_some_and(|diff| diff.comparison_id == Some(role.0))
|
||||
})
|
||||
})
|
||||
});
|
||||
for pane in self.panes.values_mut() {
|
||||
pane.views
|
||||
@@ -2199,7 +2498,16 @@ impl Editor {
|
||||
}
|
||||
|
||||
pub(crate) fn refresh_live_diff(&mut self) {
|
||||
for diff in self.diff.iter_mut().chain(self.parked_diff.iter_mut()) {
|
||||
for diff in self
|
||||
.diff
|
||||
.iter_mut()
|
||||
.chain(self.parked_diff.iter_mut())
|
||||
.chain(
|
||||
self.tabs
|
||||
.values_mut()
|
||||
.flat_map(|tab| tab.diff.iter_mut().chain(tab.parked_diff.iter_mut())),
|
||||
)
|
||||
{
|
||||
let buffer = diff.live_buffer.and_then(|id| {
|
||||
if id == self.active.id {
|
||||
Some(&self.active)
|
||||
@@ -2316,6 +2624,11 @@ impl Editor {
|
||||
| Action::PaneFocusRight
|
||||
| Action::PaneClose
|
||||
| Action::PaneForceClose
|
||||
| Action::TabNew
|
||||
| Action::TabPrevious
|
||||
| Action::TabNext
|
||||
| Action::TabClose
|
||||
| Action::TabForceClose
|
||||
)
|
||||
)
|
||||
})
|
||||
@@ -4331,6 +4644,11 @@ impl Editor {
|
||||
| Action::PaneFocusDown
|
||||
| Action::PaneFocusUp
|
||||
| Action::PaneFocusRight
|
||||
| Action::TabNew
|
||||
| Action::TabPrevious
|
||||
| Action::TabNext
|
||||
| Action::TabClose
|
||||
| Action::TabForceClose
|
||||
)
|
||||
{
|
||||
let action = *action;
|
||||
@@ -4588,6 +4906,11 @@ impl Editor {
|
||||
| Action::PaneFocusRight
|
||||
| Action::PaneClose
|
||||
| Action::PaneForceClose
|
||||
| Action::TabNew
|
||||
| Action::TabPrevious
|
||||
| Action::TabNext
|
||||
| Action::TabClose
|
||||
| Action::TabForceClose
|
||||
| Action::Save
|
||||
| Action::Quit
|
||||
| Action::ForceQuit
|
||||
@@ -5035,21 +5358,33 @@ impl Editor {
|
||||
));
|
||||
return;
|
||||
}
|
||||
if let Some(owner) = self.diff_owner
|
||||
&& (self.diff_return_buffer == Some(id)
|
||||
|| self
|
||||
.diff
|
||||
.as_ref()
|
||||
.or(self.parked_diff.as_ref())
|
||||
.is_some_and(|diff| diff.live_buffer == Some(id)))
|
||||
{
|
||||
let owners: Vec<_> = self
|
||||
.diff_owner
|
||||
.filter(|_| {
|
||||
self.diff_return_buffer == Some(id)
|
||||
|| self
|
||||
.workspace_diff()
|
||||
.is_some_and(|diff| diff.live_buffer == Some(id))
|
||||
})
|
||||
.into_iter()
|
||||
.chain(
|
||||
self.tabs
|
||||
.values()
|
||||
.filter(|tab| {
|
||||
tab.diff_return_buffer == Some(id)
|
||||
|| tab.diff().is_some_and(|diff| diff.live_buffer == Some(id))
|
||||
})
|
||||
.filter_map(|tab| tab.diff_owner),
|
||||
)
|
||||
.collect();
|
||||
for owner in owners {
|
||||
self.with_pane(owner, |editor| {
|
||||
editor.release_diff_buffers();
|
||||
editor.diff = None;
|
||||
editor.parked_diff = None;
|
||||
editor.diff_owner = None;
|
||||
editor.outstanding_diff_request = None;
|
||||
});
|
||||
self.parked_diff = None;
|
||||
self.diff_owner = None;
|
||||
self.outstanding_diff_request = None;
|
||||
}
|
||||
let replacement_id = if id == self.active.id {
|
||||
let next = self.adjacent_buffer(true);
|
||||
@@ -5229,6 +5564,11 @@ impl Editor {
|
||||
| Action::PaneFocusRight
|
||||
| Action::PaneClose
|
||||
| Action::PaneForceClose
|
||||
| Action::TabNew
|
||||
| Action::TabPrevious
|
||||
| Action::TabNext
|
||||
| Action::TabClose
|
||||
| Action::TabForceClose
|
||||
) {
|
||||
if !args.is_empty() {
|
||||
self.set_message(format!(
|
||||
@@ -5246,6 +5586,11 @@ impl Editor {
|
||||
Action::PaneFocusRight => self.focus_pane(PaneDirection::Right),
|
||||
Action::PaneClose => self.close_pane(false),
|
||||
Action::PaneForceClose => self.close_pane(true),
|
||||
Action::TabNew => self.new_tab(),
|
||||
Action::TabPrevious => self.adjacent_tab(false),
|
||||
Action::TabNext => self.adjacent_tab(true),
|
||||
Action::TabClose => self.close_tab(false),
|
||||
Action::TabForceClose => self.close_tab(true),
|
||||
_ => unreachable!("pane actions handled above"),
|
||||
}
|
||||
return None;
|
||||
@@ -8308,6 +8653,354 @@ mod tests {
|
||||
assert_eq!(editor.buffer_id(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tabs_preserve_panes_explorers_and_independent_views_of_shared_history() {
|
||||
let directory = Directory::new();
|
||||
let mut editor = editor();
|
||||
editor.project_root = directory.0.clone();
|
||||
editor
|
||||
.apply(vec![Effect::Replace("abcdef".into())])
|
||||
.unwrap();
|
||||
editor.execute("pane.split_vertical");
|
||||
let source_pane = editor.pane_id();
|
||||
set_carets(&mut editor, &[(0, 2)], 0);
|
||||
editor.open_file_picker(FilePresentation::Docked);
|
||||
let layout = format!("{:?}", editor.pane_layout());
|
||||
editor.execute("tab.new");
|
||||
let new_pane = editor.pane_id();
|
||||
assert_ne!(source_pane, new_pane);
|
||||
assert_eq!(editor.tab_index(), 1);
|
||||
assert_eq!(editor.tab_count(), 2);
|
||||
assert_eq!(editor.buffer_id(), 1);
|
||||
assert_eq!(editor.buffers().count(), 1);
|
||||
assert_eq!(editor.pane_layout().len(), 1);
|
||||
assert!(editor.file_picker().is_none());
|
||||
set_carets(&mut editor, &[(0, 4)], 0);
|
||||
editor.handle(Input::CtrlAlt('h'));
|
||||
assert_eq!(editor.pane_id(), source_pane);
|
||||
assert_eq!(format!("{:?}", editor.pane_layout()), layout);
|
||||
assert_eq!(editor.cursor().column, 2);
|
||||
assert!(editor.file_picker().unwrap().focused);
|
||||
editor.handle(Input::CtrlAlt('l'));
|
||||
assert_eq!(editor.pane_id(), new_pane);
|
||||
assert_eq!(editor.cursor().column, 4);
|
||||
type_text(&mut editor, "iX");
|
||||
editor.handle(Input::Escape);
|
||||
editor.handle(Input::CtrlAlt('l'));
|
||||
assert_eq!(editor.tab_index(), 0);
|
||||
assert_eq!(editor.lines(), ["abcdXef"]);
|
||||
assert_eq!(editor.cursor().column, 2);
|
||||
editor.blur_file_picker();
|
||||
editor.handle(Input::Char('u'));
|
||||
assert_eq!(editor.lines(), ["abcdef"]);
|
||||
editor.handle(Input::CtrlAlt('h'));
|
||||
assert_eq!(editor.tab_index(), 1);
|
||||
assert_eq!(editor.lines(), ["abcdef"]);
|
||||
assert_eq!(editor.pane_buffer_id(source_pane), Some(1));
|
||||
assert_eq!(editor.tab_buffer(0).unwrap().id, 1);
|
||||
assert_eq!(editor.tab_buffer(1).unwrap().id, 1);
|
||||
assert!(editor.tab_buffer(2).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tab_close_guards_final_dirty_owners_and_force_keeps_shared_documents() {
|
||||
let directory = Directory::new();
|
||||
let mut editor = editor();
|
||||
editor.apply(vec![Effect::Insert("shared".into())]).unwrap();
|
||||
editor.execute("tab.new");
|
||||
editor.execute("tab.force_close");
|
||||
assert_eq!(editor.tab_count(), 1);
|
||||
assert!(editor.has_buffer(1));
|
||||
assert!(editor.dirty());
|
||||
editor.execute("tab.new");
|
||||
editor.execute(&format!(
|
||||
"edit {}",
|
||||
directory.path("exclusive.txt").display()
|
||||
));
|
||||
let exclusive = editor.buffer_id();
|
||||
editor
|
||||
.apply(vec![Effect::Insert("exclusive".into())])
|
||||
.unwrap();
|
||||
editor.execute("pane.split_horizontal");
|
||||
editor.execute("tab.close");
|
||||
assert_eq!(editor.tab_count(), 2);
|
||||
assert!(editor.message().contains("tab.force_close"));
|
||||
editor.execute("pane.close");
|
||||
assert_eq!(editor.pane_layout().len(), 1);
|
||||
editor.execute("pane.close");
|
||||
assert_eq!(editor.tab_count(), 2);
|
||||
editor.execute("pane.force_close");
|
||||
assert_eq!(editor.tab_count(), 1);
|
||||
assert!(editor.has_buffer(1));
|
||||
assert!(!editor.has_buffer(exclusive));
|
||||
assert_eq!(editor.lines(), ["shared"]);
|
||||
editor.execute("tab.close");
|
||||
assert!(!editor.should_quit());
|
||||
editor.execute("tab.force_close");
|
||||
assert!(editor.should_quit());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closing_tab_checks_dirty_diff_return_buffer_even_when_live_buffer_is_shared() {
|
||||
let directory = Directory::new();
|
||||
let path = directory.path("file.txt");
|
||||
fs::write(&path, "base").unwrap();
|
||||
let mut editor = Editor::open(Some(path), vec![]).unwrap();
|
||||
editor.execute("tab.new");
|
||||
editor.execute(&format!("edit {}", directory.path("return.txt").display()));
|
||||
let return_buffer = editor.buffer_id();
|
||||
editor
|
||||
.apply(vec![Effect::Insert("dirty return".into())])
|
||||
.unwrap();
|
||||
working_diff(&mut editor, &directory.0, &["file.txt"]);
|
||||
editor.diff.as_mut().unwrap().focus = Focus::Before;
|
||||
editor.bind_diff_buffer();
|
||||
assert!(!editor.dirty());
|
||||
editor.execute("tab.close");
|
||||
assert_eq!(editor.tab_count(), 2);
|
||||
assert!(editor.message().contains("unsaved") || editor.message().contains("Unsaved"));
|
||||
editor.execute("tab.force_close");
|
||||
assert_eq!(editor.tab_count(), 1);
|
||||
assert!(editor.has_buffer(1));
|
||||
assert!(!editor.has_buffer(return_buffer));
|
||||
assert_eq!(editor.lines(), ["base"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tab_modifiers_aliases_catalog_and_palette_follow_pane_conventions() {
|
||||
for key in ["ctrl+alt+h", "CTRL+Option+H", "alt+ctrl+h", "option+ctrl+h"] {
|
||||
assert_eq!(parse_key(key).unwrap(), Input::CtrlAlt('h'));
|
||||
}
|
||||
for key in [
|
||||
"ctrl+shift+h",
|
||||
"ctrl+alt+shift+h",
|
||||
"alt+alt+h",
|
||||
"ctrl+alt+left",
|
||||
] {
|
||||
assert!(parse_key(key).is_err(), "{key}");
|
||||
}
|
||||
assert_eq!(display_key(&Input::CtrlAlt('h')), "Ctrl+Alt+h");
|
||||
let mut editor = editor();
|
||||
for command in [
|
||||
"tab.new",
|
||||
"tab.previous",
|
||||
"tab.next",
|
||||
"tab.close",
|
||||
"tab.force_close",
|
||||
] {
|
||||
assert!(core_command(command).is_some());
|
||||
assert!(Action::named(Mode::Normal, &format!("core.{command}")).is_some());
|
||||
assert!(Action::named(Mode::Insert, &format!("core.{command}")).is_none());
|
||||
}
|
||||
editor.execute("tab.new extra");
|
||||
assert_eq!(editor.tab_count(), 1);
|
||||
editor.handle(Input::Ctrl('p'));
|
||||
type_text(&mut editor, "core.tab.new");
|
||||
editor.handle(Input::Enter);
|
||||
assert_eq!(editor.tab_count(), 2);
|
||||
editor.handle(Input::Char('i'));
|
||||
editor.handle(Input::CtrlAlt('h'));
|
||||
assert_eq!(editor.tab_index(), 1);
|
||||
editor.handle(Input::Escape);
|
||||
editor.bind("normal", "option+ctrl+h", None).unwrap();
|
||||
editor.handle(Input::CtrlAlt('h'));
|
||||
assert_eq!(editor.tab_index(), 1);
|
||||
editor
|
||||
.bind("normal", "ctrl+alt+h", Some("core.tab.previous".into()))
|
||||
.unwrap();
|
||||
editor.handle(Input::CtrlAlt('h'));
|
||||
assert_eq!(editor.tab_index(), 0);
|
||||
editor.handle(Input::Ctrl('p'));
|
||||
type_text(&mut editor, "core.tab.force_close");
|
||||
editor.handle(Input::Enter);
|
||||
assert_eq!(editor.command_line(), "tab.force_close ");
|
||||
assert_eq!(editor.tab_count(), 2);
|
||||
editor.handle(Input::Enter);
|
||||
assert_eq!(editor.tab_count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hidden_tab_diff_results_restore_visible_insert_interaction_and_route_request_ids() {
|
||||
use crate::diff::{
|
||||
Catalog, Category, ChangedFile, Comparison, FileStatus, RepoKind, Revision,
|
||||
};
|
||||
let mut editor = editor();
|
||||
editor.execute("diff");
|
||||
let first_tab = editor.tab_id();
|
||||
let (first_request, _) = editor.take_diff_request().unwrap();
|
||||
editor.execute("pane.split_vertical");
|
||||
let hidden_focused_pane = editor.pane_id();
|
||||
editor.execute("tab.new");
|
||||
editor.execute("diff");
|
||||
let second_tab = editor.tab_id();
|
||||
let (second_request, _) = editor.take_diff_request().unwrap();
|
||||
assert!(second_request > first_request);
|
||||
editor.execute("tab.new");
|
||||
let visible_tab = editor.tab_id();
|
||||
let visible_pane = editor.pane_id();
|
||||
type_text(&mut editor, "iX");
|
||||
let cursor = editor.cursor();
|
||||
editor.set_message("visible");
|
||||
let catalog = || {
|
||||
Outcome::Catalog(Catalog {
|
||||
kind: RepoKind::Git,
|
||||
entries: [
|
||||
RevisionTarget::Commit("base".into()),
|
||||
RevisionTarget::Working,
|
||||
]
|
||||
.into_iter()
|
||||
.map(|target| Revision {
|
||||
target,
|
||||
label: "Revision".into(),
|
||||
description: String::new(),
|
||||
category: Category::Commits,
|
||||
})
|
||||
.collect(),
|
||||
notice: None,
|
||||
})
|
||||
};
|
||||
assert!(editor.apply_diff_result(second_request, Ok(catalog())));
|
||||
assert!(editor.apply_diff_result(first_request, Ok(catalog())));
|
||||
assert!(!editor.apply_diff_result(first_request, Ok(catalog())));
|
||||
assert_eq!(editor.tab_id(), visible_tab);
|
||||
assert_eq!(editor.pane_id(), visible_pane);
|
||||
assert_eq!(editor.cursor(), cursor);
|
||||
assert_eq!(editor.mode(), Mode::Insert);
|
||||
assert_eq!(editor.message(), "visible");
|
||||
assert_eq!(editor.lines(), ["X"]);
|
||||
assert!(editor.diff().is_none());
|
||||
assert!(!editor.tab_diff(first_tab).unwrap().loading);
|
||||
assert!(!editor.tab_diff(second_tab).unwrap().loading);
|
||||
let owner = editor.tabs[&first_tab].diff_owner.unwrap();
|
||||
let (comparison_request, _) = editor.with_pane(owner, |editor| {
|
||||
editor.handle(Input::Enter);
|
||||
editor.handle(Input::Down);
|
||||
editor.handle(Input::Enter);
|
||||
editor.take_diff_request().unwrap()
|
||||
});
|
||||
let before = vec!["base".to_owned()];
|
||||
let after = vec!["changed".to_owned()];
|
||||
let hunks = crate::diff::line_hunks(&before, false, &after, false);
|
||||
assert!(editor.apply_diff_result(
|
||||
comparison_request,
|
||||
Ok(Outcome::Comparison(Comparison {
|
||||
root: editor.project_root().to_owned(),
|
||||
before: "base".into(),
|
||||
after: "working".into(),
|
||||
editable_paths: Default::default(),
|
||||
files: vec![ChangedFile {
|
||||
loaded: true,
|
||||
path: "file.txt".into(),
|
||||
old_path: None,
|
||||
status: FileStatus::Modified,
|
||||
before,
|
||||
after,
|
||||
before_eof: false,
|
||||
after_eof: false,
|
||||
hunks,
|
||||
notice: None,
|
||||
}],
|
||||
}))
|
||||
));
|
||||
assert_eq!(editor.tab_id(), visible_tab);
|
||||
assert_eq!(editor.pane_id(), visible_pane);
|
||||
assert_eq!(editor.buffer_id(), 1);
|
||||
assert_eq!(editor.cursor(), cursor);
|
||||
assert_eq!(editor.mode(), Mode::Insert);
|
||||
assert_eq!(editor.message(), "visible");
|
||||
assert_ne!(editor.pane_buffer_id(owner), Some(1));
|
||||
assert_eq!(editor.tabs[&first_tab].pane_id, hidden_focused_pane);
|
||||
editor.handle(Input::Escape);
|
||||
editor.handle(Input::Char('u'));
|
||||
assert_eq!(editor.lines(), [""]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tab_snapshots_remain_readonly_and_other_comparisons_survive_release() {
|
||||
let directory = Directory::new();
|
||||
let mut editor = editor();
|
||||
historical_diff(
|
||||
&mut editor,
|
||||
&directory.0,
|
||||
vec!["base".into()],
|
||||
vec!["first".into()],
|
||||
);
|
||||
let first_tab = editor.tab_id();
|
||||
let first_snapshot = editor.buffer_id();
|
||||
let first_comparison = editor.diff().unwrap().comparison_id;
|
||||
editor.execute("tab.new");
|
||||
assert_eq!(editor.buffer_id(), first_snapshot);
|
||||
assert!(editor.diff().is_none());
|
||||
assert!(
|
||||
editor
|
||||
.apply(vec![Effect::Insert("rejected".into())])
|
||||
.is_err()
|
||||
);
|
||||
editor.execute("buffer 1");
|
||||
historical_diff(
|
||||
&mut editor,
|
||||
&directory.0,
|
||||
vec!["base".into()],
|
||||
vec!["second".into()],
|
||||
);
|
||||
let second_snapshot = editor.buffer_id();
|
||||
assert_ne!(editor.diff().unwrap().comparison_id, first_comparison);
|
||||
editor.execute("tab.new");
|
||||
assert_eq!(editor.buffer_id(), second_snapshot);
|
||||
editor.execute("tab.previous");
|
||||
editor.execute("diff.close");
|
||||
assert!(editor.tab_diff(first_tab).is_some());
|
||||
assert!(editor.has_buffer(first_snapshot));
|
||||
assert!(editor.has_buffer(second_snapshot));
|
||||
editor.execute("tab.next");
|
||||
assert_eq!(editor.buffer_id(), second_snapshot);
|
||||
assert!(editor.mutation_blocked());
|
||||
editor.execute("tab.next");
|
||||
assert_eq!(editor.buffer_id(), first_snapshot);
|
||||
assert_eq!(editor.diff().unwrap().comparison_id, first_comparison);
|
||||
assert_eq!(editor.diff().unwrap().focus, Focus::Before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hidden_tab_comparisons_refresh_shared_edits_and_deletion_closes_all_owners() {
|
||||
let directory = Directory::new();
|
||||
let path = directory.path("file.txt");
|
||||
fs::write(&path, "base").unwrap();
|
||||
let mut editor = Editor::open(Some(path), vec![]).unwrap();
|
||||
working_diff(&mut editor, &directory.0, &["file.txt"]);
|
||||
editor.diff.as_mut().unwrap().focus = Focus::After;
|
||||
editor.bind_diff_buffer();
|
||||
let first_tab = editor.tab_id();
|
||||
editor.execute("tab.new");
|
||||
working_diff(&mut editor, &directory.0, &["file.txt"]);
|
||||
editor.diff.as_mut().unwrap().focus = Focus::After;
|
||||
editor.bind_diff_buffer();
|
||||
let second_tab = editor.tab_id();
|
||||
editor.execute("tab.new");
|
||||
editor
|
||||
.apply(vec![Effect::Insert("changed".into())])
|
||||
.unwrap();
|
||||
let revision = editor.text_revision();
|
||||
assert_eq!(
|
||||
editor.tab_diff(first_tab).unwrap().live_revision,
|
||||
Some(revision)
|
||||
);
|
||||
assert_eq!(
|
||||
editor.tab_diff(second_tab).unwrap().live_revision,
|
||||
Some(revision)
|
||||
);
|
||||
editor.execute("tab.close");
|
||||
assert_eq!(editor.tab_count(), 2);
|
||||
assert!(editor.has_buffer(1));
|
||||
editor.execute("bdelete! 1");
|
||||
assert!(!editor.has_buffer(1));
|
||||
assert!(editor.tab_diff(first_tab).is_none());
|
||||
assert!(editor.tab_diff(second_tab).is_none());
|
||||
editor.execute("tab.next");
|
||||
assert!(editor.diff().is_none());
|
||||
assert!(editor.has_buffer(editor.buffer_id()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pane_views_survive_buffer_switches_and_deletion_replaces_every_reference() {
|
||||
let directory = Directory::new();
|
||||
|
||||
@@ -21,6 +21,8 @@ Normal: i/a insert, I/A insert at first nonblank/end, o/O new line
|
||||
gf follows Markdown file links/reference links and heading fragments (no LSP)
|
||||
Normal/Visual: gt first line, gb last line, at first nonblank; Esc cancels g
|
||||
Ctrl+o older jump location; Ctrl+i/Tab newer (counts supported)
|
||||
_ splits above/below, | left/right; Option/Alt+h/j/k/l focuses neighboring panes
|
||||
Ctrl+Option/Alt+h/l previous/next tab; :tab.new opens current buffer in another tab
|
||||
Normal/Visual: prefix motions with counts (5j, 2w, 3n); Esc cancels count
|
||||
u undo, U redo, : command, / search, n/N next/previous match
|
||||
dd deletes lines; cc changes lines and enters Insert; counts: 3dd, 2cc; Esc cancels prefix
|
||||
@@ -42,6 +44,8 @@ Aliases: w=save, q=quit, q!=force_quit, wq=save_and_quit
|
||||
Buffers: :edit PATH, :buffers, :buffer N, :buffer_next, :buffer_previous, :buffer_delete [N], :buffer_force_delete [N]
|
||||
Aliases: bnext, bprev, bdelete, bdelete!; arguments and safety rules are unchanged
|
||||
:quit refuses any dirty buffer; :save_and_quit saves active and quits only if all are clean
|
||||
Tabs: :tab.new, :tab.previous, :tab.next, :tab.close, :tab.force_close
|
||||
Tabs retain pane layouts and independent views; close protects exclusively displayed unsaved edits
|
||||
Markdown links: :follow_link (primary caret; local files only)
|
||||
Diff: :diff (Normal); choose Before then After; Working checkout After edits live unsaved buffers
|
||||
Before and historical/remote After stay read-only; Tab/Shift+Tab categories/Normal focus
|
||||
|
||||
+46
-46
@@ -169,7 +169,7 @@ pub struct EditorSession {
|
||||
pending_hover: bool,
|
||||
pending_completion: bool,
|
||||
hover_owner: Option<String>,
|
||||
diff_worker: Option<DiffWorker>,
|
||||
diff_workers: BTreeMap<usize, DiffWorker>,
|
||||
}
|
||||
|
||||
impl EditorSession {
|
||||
@@ -261,7 +261,7 @@ impl EditorSession {
|
||||
pending_hover: false,
|
||||
pending_completion: false,
|
||||
hover_owner: None,
|
||||
diff_worker: None,
|
||||
diff_workers: BTreeMap::new(),
|
||||
};
|
||||
session.synchronize_documents();
|
||||
Ok(session)
|
||||
@@ -319,27 +319,27 @@ impl EditorSession {
|
||||
}
|
||||
|
||||
fn start_diff_request(&mut self) {
|
||||
if self.editor.workspace_diff().is_none() {
|
||||
self.diff_worker = None;
|
||||
return;
|
||||
}
|
||||
if self
|
||||
.editor
|
||||
.workspace_diff()
|
||||
.is_some_and(|diff| !diff.loading)
|
||||
&& let Some(worker) = &mut self.diff_worker
|
||||
&& let Some(id) = worker.pending.take()
|
||||
{
|
||||
worker.cancelled_request.store(id, Ordering::Release);
|
||||
worker.cancelled.store(true, Ordering::Release);
|
||||
}
|
||||
self.diff_workers.retain(|tab, worker| {
|
||||
let Some(diff) = self.editor.tab_diff(*tab) else {
|
||||
return false;
|
||||
};
|
||||
if !diff.loading
|
||||
&& let Some(id) = worker.pending.take()
|
||||
{
|
||||
worker.cancelled_request.store(id, Ordering::Release);
|
||||
worker.cancelled.store(true, Ordering::Release);
|
||||
}
|
||||
true
|
||||
});
|
||||
let Some((id, operation)) = self.editor.take_diff_request() else {
|
||||
return;
|
||||
};
|
||||
if self.diff_worker.is_none() {
|
||||
let root = self.editor.diff_root();
|
||||
match DiffWorker::start(root) {
|
||||
Ok(worker) => self.diff_worker = Some(worker),
|
||||
let tab = self.editor.tab_id();
|
||||
if let std::collections::btree_map::Entry::Vacant(entry) = self.diff_workers.entry(tab) {
|
||||
match DiffWorker::start(self.editor.diff_root()) {
|
||||
Ok(worker) => {
|
||||
entry.insert(worker);
|
||||
}
|
||||
Err(error) => {
|
||||
self.editor.apply_diff_result(id, Err(error));
|
||||
return;
|
||||
@@ -347,46 +347,46 @@ impl EditorSession {
|
||||
}
|
||||
}
|
||||
if let Err(error) = self
|
||||
.diff_worker
|
||||
.as_mut()
|
||||
.diff_workers
|
||||
.get_mut(&tab)
|
||||
.expect("diff worker started")
|
||||
.request(id, operation)
|
||||
{
|
||||
self.editor.apply_diff_result(id, Err(error));
|
||||
self.diff_worker = None;
|
||||
self.diff_workers.remove(&tab);
|
||||
}
|
||||
}
|
||||
|
||||
fn poll_diff(&mut self) -> bool {
|
||||
self.start_diff_request();
|
||||
let mut changed = false;
|
||||
while let Some(worker) = self.diff_worker.as_ref() {
|
||||
let result = worker
|
||||
.results
|
||||
.as_ref()
|
||||
.expect("live diff worker has results")
|
||||
.try_recv();
|
||||
match result {
|
||||
Ok((id, result)) => {
|
||||
if let Some(worker) = &mut self.diff_worker
|
||||
&& worker.pending == Some(id)
|
||||
{
|
||||
worker.pending = None;
|
||||
self.diff_workers.retain(|_, worker| {
|
||||
loop {
|
||||
let result = worker
|
||||
.results
|
||||
.as_ref()
|
||||
.expect("live diff worker has results")
|
||||
.try_recv();
|
||||
match result {
|
||||
Ok((id, result)) => {
|
||||
if worker.pending == Some(id) {
|
||||
worker.pending = None;
|
||||
}
|
||||
changed |= self.editor.apply_diff_result(id, result);
|
||||
}
|
||||
changed |= self.editor.apply_diff_result(id, result);
|
||||
}
|
||||
Err(mpsc::TryRecvError::Empty) => break,
|
||||
Err(mpsc::TryRecvError::Disconnected) => {
|
||||
if let Some(id) = self.diff_worker.as_ref().and_then(|worker| worker.pending) {
|
||||
changed |= self
|
||||
.editor
|
||||
.apply_diff_result(id, Err("Diff worker stopped unexpectedly".into()));
|
||||
Err(mpsc::TryRecvError::Empty) => return true,
|
||||
Err(mpsc::TryRecvError::Disconnected) => {
|
||||
if let Some(id) = worker.pending.take() {
|
||||
changed |= self.editor.apply_diff_result(
|
||||
id,
|
||||
Err("Diff worker stopped unexpectedly".into()),
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
self.diff_worker = None;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
changed
|
||||
}
|
||||
|
||||
|
||||
+321
-9
@@ -403,9 +403,10 @@ impl BufferViewports {
|
||||
let focused = editor.pane_id();
|
||||
self.activate(focused, editor);
|
||||
self.parked
|
||||
.retain(|id, _| editor.pane_layout().contains(*id));
|
||||
.retain(|id, _| editor.pane_buffer_id(*id).is_some());
|
||||
self.syntax.retain(|id, _| editor.has_buffer(*id));
|
||||
let area = frame.area();
|
||||
let area = draw_tab_bar(frame, editor, area);
|
||||
let multiple = editor.pane_layout().len() > 1;
|
||||
let chrome = if !multiple && editor.diff().is_some() {
|
||||
None
|
||||
@@ -557,8 +558,9 @@ impl BufferViewports {
|
||||
.iter()
|
||||
.chain(
|
||||
self.parked
|
||||
.values()
|
||||
.filter_map(|pane| pane.panels.diff.as_ref()),
|
||||
.iter()
|
||||
.filter(|(id, _)| editor.pane_layout().contains(**id))
|
||||
.filter_map(|(_, pane)| pane.panels.diff.as_ref()),
|
||||
)
|
||||
.any(|geometry| geometry.viewport.is_some());
|
||||
}
|
||||
@@ -573,7 +575,10 @@ impl BufferViewports {
|
||||
{
|
||||
session.set_diff_viewport(comparison_id, file, top, left_before, left_after);
|
||||
}
|
||||
for pane in self.parked.values() {
|
||||
for (pane_id, _) in &self.rects {
|
||||
let Some(pane) = self.parked.get(pane_id) else {
|
||||
continue;
|
||||
};
|
||||
if let Some((id, file, top, before, after)) = pane
|
||||
.panels
|
||||
.diff
|
||||
@@ -909,9 +914,11 @@ fn translate(key: KeyEvent) -> Option<Input> {
|
||||
}
|
||||
if key.modifiers.contains(KeyModifiers::ALT) {
|
||||
return match key.code {
|
||||
KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||
Some(Input::Alt(c.to_ascii_lowercase()))
|
||||
}
|
||||
KeyCode::Char(c) => Some(if key.modifiers.contains(KeyModifiers::CONTROL) {
|
||||
Input::CtrlAlt(c.to_ascii_lowercase())
|
||||
} else {
|
||||
Input::Alt(c.to_ascii_lowercase())
|
||||
}),
|
||||
_ => None,
|
||||
};
|
||||
}
|
||||
@@ -994,6 +1001,75 @@ fn diagnostic_on_row(diagnostic: &Diagnostic, row: usize) -> bool {
|
||||
|| diagnostic.start == diagnostic.end)
|
||||
}
|
||||
|
||||
fn draw_tab_bar(frame: &mut Frame, editor: &Editor, area: Rect) -> Rect {
|
||||
if editor.tab_count() <= 1 || area.height == 0 {
|
||||
return area;
|
||||
}
|
||||
let bar = Rect::new(area.x, area.y, area.width, 1);
|
||||
let workspace = Rect::new(
|
||||
area.x,
|
||||
area.y.saturating_add(1),
|
||||
area.width,
|
||||
area.height - 1,
|
||||
);
|
||||
if bar.width == 0 {
|
||||
return workspace;
|
||||
}
|
||||
let labels: Vec<_> = (0..editor.tab_count())
|
||||
.map(|index| {
|
||||
let buffer = editor.tab_buffer(index);
|
||||
let name = buffer
|
||||
.as_ref()
|
||||
.and_then(|buffer| buffer.path)
|
||||
.map(|path| {
|
||||
path.file_name()
|
||||
.unwrap_or(path.as_os_str())
|
||||
.to_string_lossy()
|
||||
})
|
||||
.unwrap_or(Cow::Borrowed("[No Name]"));
|
||||
display_text(&format!(
|
||||
"{} {name}{} ",
|
||||
index + 1,
|
||||
if buffer.is_some_and(|buffer| buffer.dirty) {
|
||||
" [+]"
|
||||
} else {
|
||||
""
|
||||
},
|
||||
))
|
||||
})
|
||||
.collect();
|
||||
let active = editor.tab_index();
|
||||
let mut first = active;
|
||||
let mut width = labels[active].width();
|
||||
while first > 0 && width + labels[first - 1].width() <= usize::from(bar.width) {
|
||||
first -= 1;
|
||||
width += labels[first].width();
|
||||
}
|
||||
let inactive = Style::default().fg(Color::Gray).bg(Color::DarkGray);
|
||||
frame.render_widget(Paragraph::new("").style(inactive), bar);
|
||||
let mut x = bar.x;
|
||||
for (index, label) in labels.iter().enumerate().skip(first) {
|
||||
let width = label.width().min(usize::from(bar.right() - x)) as u16;
|
||||
let style = if index == active {
|
||||
Style::default()
|
||||
.fg(Color::Black)
|
||||
.bg(Color::Cyan)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
inactive
|
||||
};
|
||||
frame.render_widget(
|
||||
Paragraph::new(label.as_str()).style(style),
|
||||
Rect::new(x, bar.y, width, 1),
|
||||
);
|
||||
x += width;
|
||||
if x == bar.right() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
workspace
|
||||
}
|
||||
|
||||
fn document_layout(area: Rect, editor: &Editor) -> [Rect; 3] {
|
||||
let prompting = matches!(editor.mode(), Mode::Command | Mode::Search);
|
||||
let dock_focused = editor
|
||||
@@ -1018,6 +1094,7 @@ fn draw(
|
||||
panels: &mut WheelPanels,
|
||||
) {
|
||||
let area = frame.area();
|
||||
let area = draw_tab_bar(frame, editor, area);
|
||||
let areas = if editor.diff().is_some() {
|
||||
[area, Rect::default(), Rect::default()]
|
||||
} else {
|
||||
@@ -1497,6 +1574,7 @@ fn draw_overlays(
|
||||
draw_palette(
|
||||
frame,
|
||||
palette,
|
||||
area,
|
||||
editor.mode(),
|
||||
editor.selection_navigation_hint(),
|
||||
);
|
||||
@@ -3720,8 +3798,13 @@ fn draw_file_list(
|
||||
);
|
||||
}
|
||||
|
||||
fn draw_palette(frame: &mut Frame, palette: &Palette, mode: Mode, navigation_hint: &str) {
|
||||
let area = frame.area();
|
||||
fn draw_palette(
|
||||
frame: &mut Frame,
|
||||
palette: &Palette,
|
||||
area: Rect,
|
||||
mode: Mode,
|
||||
navigation_hint: &str,
|
||||
) {
|
||||
if area.width == 0 || area.height == 0 {
|
||||
return;
|
||||
}
|
||||
@@ -5083,6 +5166,212 @@ mod tests {
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tab_bar_shows_shared_dirty_titles_and_reserves_document_and_overlay_rows() {
|
||||
let directory = Directory::new();
|
||||
let mut editor =
|
||||
Editor::open(Some(directory.file("a\tname.txt", "DOCUMENT")), vec![]).unwrap();
|
||||
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(80, 12)).unwrap();
|
||||
let mut views = BufferViewports::default();
|
||||
terminal
|
||||
.draw(|frame| views.draw(frame, &mut editor))
|
||||
.unwrap();
|
||||
assert_eq!(views.document.geometry.unwrap().1.y, 0);
|
||||
assert!(pane_screen(&terminal, Rect::new(0, 0, 80, 1)).contains("DOCUMENT"));
|
||||
|
||||
editor.execute("tab.new");
|
||||
editor.handle(Input::Char('i'));
|
||||
editor.handle(Input::Paste("changed ".into()));
|
||||
editor.handle(Input::Escape);
|
||||
terminal
|
||||
.draw(|frame| views.draw(frame, &mut editor))
|
||||
.unwrap();
|
||||
let titles = pane_screen(&terminal, Rect::new(0, 0, 80, 1));
|
||||
assert!(titles.contains("1 a�name.txt [+]"), "{titles}");
|
||||
assert!(titles.contains("2 a�name.txt [+]"), "{titles}");
|
||||
let active_x = display_column(&titles[..titles.find("2 a�name.txt").unwrap()], usize::MAX);
|
||||
assert_eq!(terminal.backend().buffer()[(0, 0)].bg, Color::DarkGray);
|
||||
assert_eq!(
|
||||
terminal.backend().buffer()[(active_x as u16, 0)].bg,
|
||||
Color::Cyan
|
||||
);
|
||||
assert_eq!(views.document.geometry.unwrap().1.y, 1);
|
||||
assert!(terminal.get_cursor_position().unwrap().y >= 1);
|
||||
assert!(pane_screen(&terminal, views.rects[0].1).contains("changed DOCUMENT"));
|
||||
|
||||
editor.handle(Input::Ctrl('p'));
|
||||
terminal
|
||||
.draw(|frame| views.draw(frame, &mut editor))
|
||||
.unwrap();
|
||||
assert_eq!(pane_screen(&terminal, Rect::new(0, 0, 80, 1)), titles);
|
||||
assert!(terminal.get_cursor_position().unwrap().y >= 1);
|
||||
assert!(screen_text(&terminal).contains("Commands"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn narrow_tab_bar_keeps_the_active_title_visible_and_handles_empty_areas() {
|
||||
let directory = Directory::new();
|
||||
let mut editor = Editor::open(
|
||||
Some(directory.file("a-very-long-inactive-title.txt", "first")),
|
||||
vec![],
|
||||
)
|
||||
.unwrap();
|
||||
editor.execute("tab.new");
|
||||
editor.execute("tab.new");
|
||||
editor.execute(&format!(
|
||||
"edit {}",
|
||||
directory.file("active.txt", "ACTIVE").display()
|
||||
));
|
||||
let mut views = BufferViewports::default();
|
||||
for (width, height) in [(12, 9), (7, 9), (1, 9), (0, 9), (12, 1), (12, 0)] {
|
||||
let mut terminal =
|
||||
Terminal::new(ratatui::backend::TestBackend::new(width, height)).unwrap();
|
||||
terminal
|
||||
.draw(|frame| views.draw(frame, &mut editor))
|
||||
.unwrap();
|
||||
if width > 0 && height > 0 {
|
||||
let title = pane_screen(&terminal, Rect::new(0, 0, width, 1));
|
||||
let expected: String = "3 active.txt".chars().take(usize::from(width)).collect();
|
||||
assert_eq!(title, expected);
|
||||
assert_eq!(terminal.backend().buffer()[(0, 0)].bg, Color::Cyan);
|
||||
}
|
||||
}
|
||||
|
||||
let mut editor = Editor::open(None, vec![]).unwrap();
|
||||
editor.execute("tab.new");
|
||||
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(40, 6)).unwrap();
|
||||
terminal
|
||||
.draw(|frame| views.draw(frame, &mut editor))
|
||||
.unwrap();
|
||||
let titles = pane_screen(&terminal, Rect::new(0, 0, 40, 1));
|
||||
assert!(titles.contains("1 [No Name]"), "{titles}");
|
||||
assert!(titles.contains("2 [No Name]"), "{titles}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tab_switches_restore_independent_pane_viewports_and_retire_only_closed_panes() {
|
||||
let directory = Directory::new();
|
||||
let text = (0..80)
|
||||
.map(|row| format!("row-{row:02} {}", "long ".repeat(30)))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
let mut session =
|
||||
EditorSession::open(Some(directory.file("rows.txt", &text)), vec![], None).unwrap();
|
||||
let buffer = session.editor().buffer_id();
|
||||
session.execute("tab.new");
|
||||
let other = session.editor().pane_id();
|
||||
session.execute("tab.previous");
|
||||
session.execute("pane.split_vertical");
|
||||
let split = session.editor().pane_id();
|
||||
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(80, 24)).unwrap();
|
||||
let mut views = BufferViewports::default();
|
||||
terminal
|
||||
.draw(|frame| views.draw_session(frame, &mut session))
|
||||
.unwrap();
|
||||
let area = views.rects.iter().find(|(id, _)| *id == split).unwrap().1;
|
||||
assert_eq!(area.y, 1);
|
||||
assert!(!views.scroll(
|
||||
&mut session,
|
||||
document_wheel(MouseEventKind::ScrollDown, 2, 0)
|
||||
));
|
||||
for _ in 0..2 {
|
||||
for kind in [MouseEventKind::ScrollDown, MouseEventKind::ScrollRight] {
|
||||
assert!(views.scroll(&mut session, document_wheel(kind, area.x + 2, area.y + 2)));
|
||||
}
|
||||
}
|
||||
terminal
|
||||
.draw(|frame| views.draw_session(frame, &mut session))
|
||||
.unwrap();
|
||||
let split_offset = views.offsets[&buffer];
|
||||
assert!(split_offset.0 > 0 && split_offset.1 > 0);
|
||||
|
||||
session.execute("tab.next");
|
||||
terminal
|
||||
.draw(|frame| views.draw_session(frame, &mut session))
|
||||
.unwrap();
|
||||
assert_eq!(views.rects.len(), 1);
|
||||
assert_eq!(views.parked[&split].offsets[&buffer], split_offset);
|
||||
for kind in [MouseEventKind::ScrollDown, MouseEventKind::ScrollRight] {
|
||||
assert!(views.scroll(&mut session, document_wheel(kind, 2, 2)));
|
||||
}
|
||||
terminal
|
||||
.draw(|frame| views.draw_session(frame, &mut session))
|
||||
.unwrap();
|
||||
let other_offset = views.offsets[&buffer];
|
||||
assert_ne!(other_offset, split_offset);
|
||||
|
||||
session.execute("tab.previous");
|
||||
terminal
|
||||
.draw(|frame| views.draw_session(frame, &mut session))
|
||||
.unwrap();
|
||||
assert_eq!(session.editor().pane_id(), split);
|
||||
assert_eq!(views.rects.len(), 2);
|
||||
assert_eq!(views.offsets[&buffer], split_offset);
|
||||
assert_eq!(views.parked[&other].offsets[&buffer], other_offset);
|
||||
session.execute("tab.next");
|
||||
terminal
|
||||
.draw(|frame| views.draw_session(frame, &mut session))
|
||||
.unwrap();
|
||||
assert_eq!(views.offsets[&buffer], other_offset);
|
||||
session.execute("tab.close");
|
||||
terminal
|
||||
.draw(|frame| views.draw_session(frame, &mut session))
|
||||
.unwrap();
|
||||
assert!(!views.parked.contains_key(&other));
|
||||
assert_eq!(views.offsets[&buffer], split_offset);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hidden_tab_diff_caches_do_not_poll_colors_or_report_stale_viewports() {
|
||||
let directory = Directory::new();
|
||||
let mut session = EditorSession::open(
|
||||
Some(directory.file("source.txt", "WORKSPACE")),
|
||||
vec![],
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let source = (0..80)
|
||||
.map(|row| format!("old-{row:02} {}", "x ".repeat(30)))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
install_readonly_diff(
|
||||
session.editor_mut(),
|
||||
syntax_diff_file("history.txt", &source, &source.replace("old", "new")),
|
||||
);
|
||||
let owner = session.editor().pane_id();
|
||||
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 30)).unwrap();
|
||||
let mut views = BufferViewports::default();
|
||||
terminal
|
||||
.draw(|frame| views.draw_session(frame, &mut session))
|
||||
.unwrap();
|
||||
assert!(views.diff_visible);
|
||||
let (id, file, _, _, _) = views.panels.diff.as_ref().unwrap().viewport.unwrap();
|
||||
session.set_diff_viewport(id, file, 24, 7, 8);
|
||||
session.execute("tab.new");
|
||||
terminal
|
||||
.draw(|frame| views.draw_session(frame, &mut session))
|
||||
.unwrap();
|
||||
assert!(!views.diff_visible);
|
||||
assert!(views.parked[&owner].panels.diff.is_some());
|
||||
assert!(views.rects.iter().all(|(id, _)| *id != owner));
|
||||
|
||||
session.execute("tab.previous");
|
||||
let diff = session.editor().diff().unwrap();
|
||||
assert_eq!((diff.top, diff.left_before, diff.left_after), (24, 7, 8));
|
||||
terminal
|
||||
.draw(|frame| views.draw_session(frame, &mut session))
|
||||
.unwrap();
|
||||
assert!(views.diff_visible);
|
||||
assert_eq!(terminal.backend().buffer()[(0, 1)].bg, Color::Cyan);
|
||||
assert_eq!(views.panels.files.unwrap().0.y, 2);
|
||||
|
||||
session.execute("diff");
|
||||
terminal
|
||||
.draw(|frame| views.draw_session(frame, &mut session))
|
||||
.unwrap();
|
||||
assert!(views.panels.diff.as_ref().unwrap().regions[0].0.y >= 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explorer_dock_reserves_space_outside_all_workspace_panes() {
|
||||
let directory = Directory::new();
|
||||
@@ -7566,6 +7855,26 @@ end
|
||||
(
|
||||
KeyCode::Char('j'),
|
||||
KeyModifiers::ALT | KeyModifiers::CONTROL,
|
||||
Some(Input::CtrlAlt('j')),
|
||||
),
|
||||
(
|
||||
KeyCode::Char('H'),
|
||||
KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SHIFT,
|
||||
Some(Input::CtrlAlt('h')),
|
||||
),
|
||||
(
|
||||
KeyCode::Char('L'),
|
||||
KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SHIFT,
|
||||
Some(Input::CtrlAlt('l')),
|
||||
),
|
||||
(
|
||||
KeyCode::Char('j'),
|
||||
KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SUPER,
|
||||
None,
|
||||
),
|
||||
(
|
||||
KeyCode::Left,
|
||||
KeyModifiers::CONTROL | KeyModifiers::ALT,
|
||||
None,
|
||||
),
|
||||
(KeyCode::Char('j'), KeyModifiers::SUPER, None),
|
||||
@@ -10567,6 +10876,7 @@ end
|
||||
draw_palette(
|
||||
frame,
|
||||
&palette,
|
||||
area,
|
||||
Mode::Visual,
|
||||
editor.selection_navigation_hint(),
|
||||
)
|
||||
@@ -10605,9 +10915,11 @@ end
|
||||
Terminal::new(ratatui::backend::TestBackend::new(width, height)).unwrap();
|
||||
terminal
|
||||
.draw(|frame| {
|
||||
let area = frame.area();
|
||||
draw_palette(
|
||||
frame,
|
||||
&palette,
|
||||
area,
|
||||
Mode::Normal,
|
||||
editor.selection_navigation_hint(),
|
||||
)
|
||||
|
||||
+101
@@ -184,6 +184,107 @@ fn typed_diff_edits_unsaved_working_after_and_preserves_undo_on_explicit_close()
|
||||
assert_eq!(fixture.git(&["status", "--porcelain"]), "M buffer.rs");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tabs_keep_repository_workers_and_hidden_comparisons_independent() {
|
||||
let first = Fixture::new();
|
||||
let second = Fixture::new();
|
||||
for (fixture, before, after) in [
|
||||
(&first, "first before\n", "first after\n"),
|
||||
(&second, "second before\n", "second after\n"),
|
||||
] {
|
||||
fixture.git(&["init", "-q"]);
|
||||
fs::write(fixture.0.join("repo/source.txt"), before).unwrap();
|
||||
fixture.git(&["add", "source.txt"]);
|
||||
fixture.git(&["commit", "-qm", "base"]);
|
||||
fs::write(fixture.0.join("repo/source.txt"), after).unwrap();
|
||||
}
|
||||
let first_path = first.0.join("repo/source.txt");
|
||||
let second_path = second.0.join("repo/source.txt");
|
||||
let config = first.0.join("config.rb");
|
||||
fs::write(
|
||||
&config,
|
||||
"Vamp.configure do |config|\n config.bind 'ctrl+option+n', config.core.tab.new\n config.bind 'ctrl+option+w', config.core.tab.close\nend\n",
|
||||
)
|
||||
.unwrap();
|
||||
let mut session =
|
||||
EditorSession::open(Some(first_path.clone()), vec!["text".into()], Some(&config)).unwrap();
|
||||
assert!(session.editor().startup_diagnostic().is_none());
|
||||
session.execute("diff");
|
||||
session.handle(Input::CtrlAlt('n'));
|
||||
session.execute(&format!("edit {}", second_path.display()));
|
||||
session.execute("diff");
|
||||
wait_until(&mut session, |session| {
|
||||
session
|
||||
.editor()
|
||||
.diff()
|
||||
.is_some_and(|diff| diff.catalog.is_some() && !diff.loading)
|
||||
});
|
||||
session.handle(Input::CtrlAlt('h'));
|
||||
assert_eq!(session.editor().tab_index(), 0);
|
||||
wait_until(&mut session, |session| {
|
||||
session
|
||||
.editor()
|
||||
.diff()
|
||||
.is_some_and(|diff| diff.catalog.is_some() && !diff.loading)
|
||||
});
|
||||
type_query(&mut session, &first.git(&["rev-parse", "HEAD"]));
|
||||
session.handle(Input::Down);
|
||||
session.handle(Input::Enter);
|
||||
session.handle(Input::Enter);
|
||||
session.handle(Input::CtrlAlt('l'));
|
||||
assert_eq!(session.editor().tab_index(), 1);
|
||||
type_query(&mut session, &second.git(&["rev-parse", "HEAD"]));
|
||||
session.handle(Input::Down);
|
||||
session.handle(Input::Enter);
|
||||
session.handle(Input::Enter);
|
||||
wait_until(&mut session, |session| {
|
||||
session
|
||||
.editor()
|
||||
.diff()
|
||||
.is_some_and(|diff| diff.phase == Phase::View && diff.selected_file_loaded())
|
||||
});
|
||||
let comparison = session
|
||||
.editor()
|
||||
.diff()
|
||||
.unwrap()
|
||||
.comparison
|
||||
.as_ref()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
comparison.root,
|
||||
second.0.join("repo").canonicalize().unwrap()
|
||||
);
|
||||
assert_eq!(comparison.files[0].before, ["second before"]);
|
||||
assert_eq!(comparison.files[0].after, ["second after", ""]);
|
||||
session.handle(Input::CtrlAlt('h'));
|
||||
wait_until(&mut session, |session| {
|
||||
session
|
||||
.editor()
|
||||
.diff()
|
||||
.is_some_and(|diff| diff.phase == Phase::View && diff.selected_file_loaded())
|
||||
});
|
||||
let comparison = session
|
||||
.editor()
|
||||
.diff()
|
||||
.unwrap()
|
||||
.comparison
|
||||
.as_ref()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
comparison.root,
|
||||
first.0.join("repo").canonicalize().unwrap()
|
||||
);
|
||||
assert_eq!(comparison.files[0].before, ["first before"]);
|
||||
assert_eq!(comparison.files[0].after, ["first after", ""]);
|
||||
session.handle(Input::CtrlAlt('l'));
|
||||
session.handle(Input::CtrlAlt('w'));
|
||||
assert_eq!(session.editor().tab_count(), 1);
|
||||
assert!(session.editor().diff().is_some());
|
||||
session.execute("diff.close");
|
||||
assert_eq!(session.editor().path(), Some(first_path.as_path()));
|
||||
assert_eq!(session.editor().lines(), ["first after", ""]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editable_diff_navigation_skips_omitted_lines_without_expanding_excerpts() {
|
||||
use vamp::diff::DisplayRow;
|
||||
|
||||
Reference in New Issue
Block a user