diff --git a/.claude/commands/plan-next.md b/.claude/commands/plan-next.md index bf949923..bbe1bbc6 100644 --- a/.claude/commands/plan-next.md +++ b/.claude/commands/plan-next.md @@ -1,5 +1,5 @@ -Read CLAUDE.md, PROJECT_STATE.md, and PLAN.md only. +Read CLAUDE.md, PROJECT_STATE.md, PLAN.md, and BUGS.md only. Do not scan or explore the src/ directory yet. -Summarize the next incomplete task from PLAN.md. +Summarize the next incomplete task from PLAN.md and bugs from BUGS.md. List what files you expect to touch and why. Then wait for my confirmation before doing anything. diff --git a/BUGS.md b/BUGS.md index e033b3e8..05f316fd 100644 --- a/BUGS.md +++ b/BUGS.md @@ -1,10 +1,18 @@ # Known Bugs -No known bugs. +- **(Low) GTK Explorer: Enter requires two presses after arrow-key navigation** — After using arrow keys to navigate to a folder in the GTK TreeView, the first Enter press doesn't expand/collapse; the second does. Likely a GTK4 TreeView cursor/selection sync issue or interaction between GTK's built-in key bindings and the `row_activated` signal. Works correctly after the first activation. TUI explorer is unaffected. ## Resolved -All bugs below were fixed in Session 220 or earlier. See SESSION_HISTORY.md for details. +All bugs below were fixed in Session 225 or earlier. See SESSION_HISTORY.md for details. + +- **Search `n` doesn't scroll far enough — match off-screen** — Both TUI and GTK approximate `viewport_lines` missed the tab bar row entirely (GTK) or didn't account for breadcrumbs/hide_single_tab (TUI). The approximate value was set every loop iteration, overwriting the accurate per-window value from the renderer. Fixed by computing correct chrome row count (status + cmd + tab bar + breadcrumbs, minus hidden tab bar) in both backends. +- **Explorer tree doesn't reveal active buffer on folder open** — TUI sidebar had no `reveal_path()` call at startup or after `open_folder()`. Added initial reveal before the event loop and after folder picker confirmation. +- **Visual yank doesn't move cursor to selection start** — `yank_visual_selection()` left cursor at end of selection. Vim moves cursor to start (line-wise: col 0; char-wise: start col). Fixed in `visual.rs` + updated integration test. +- **YAML syntax breaks after editing** — tree-sitter-yaml has an external scanner (like Markdown) that corrupts state during incremental reparsing without `InputEdit`. Fixed by skipping old-tree reuse for YAML in `syntax.rs::reparse()`, same as Markdown. +- **Crash in `completion_prefix_at_cursor` (index out of bounds)** — cursor col could exceed `chars.len()` after edits; clamped col to valid range in `motions.rs`. +- **Swap files don't preserve most recent edits on crash** — swap files were only written every 4 seconds (updatetime); edits in the last 0-4s were lost on panic. Added `emergency_swap_flush()` that writes all dirty buffers immediately, invoked from panic hooks (both GTK and TUI) and from the TUI `catch_unwind` error handler. Global engine pointer registered at startup via `swap::register_emergency_engine()`. +- **Crash in `active_window_mut` (stale WindowId after tab/group close)** — `active_tab().active_window` could point to a `WindowId` no longer in `self.windows` after certain tab/group close sequences. Added `repair_active_window()` self-healing method that finds a valid window from the current tab's layout or creates a scratch window as last resort. Called from `active_window_mut()` and after all close operations (`close_tab`, `close_editor_group`, `close_window`, `close_other_tabs`, `close_tabs_to_right/left`, `close_saved_tabs`). - **`cargo run -- file.rs` restores entire previous session** — Skip `restore_session_files()` when CLI file/directory argument is provided. Use `open_file_with_mode(Permanent)` to load the file into the initial scratch window's tab (no leftover "[No Name]" tab). - **TUI: cannot drag tab to create new editor group when only one group exists** — `compute_tui_tab_drop_zone()` single-group branch only handled tab bar reorder. Added content area edge zone detection using terminal size, and visual feedback rendering in `render_tab_drag_overlay()` for `Center`/`Split` zones. diff --git a/Cargo.toml b/Cargo.toml index ab8705ba..d07a5866 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "vimcode" -version = "0.5.1" +version = "0.6.0" edition = "2021" description = "Vim-like code editor with GTK4 and tree-sitter" license = "MIT" diff --git a/PLAN.md b/PLAN.md index 557de587..7565c725 100644 --- a/PLAN.md +++ b/PLAN.md @@ -5,9 +5,12 @@ --- ## Recently Completed -- **Session 224**: Release prep v0.5.1 — bump version, update docs, tick off macOS CI/Homebrew roadmap item (implemented in Session 218, first release with macOS binaries). -- **Session 223**: Consolidate sidebar focus state into engine. -> Sessions 222 and earlier in **SESSION_HISTORY.md**. +- **Session 233**: Explorer focus UX polish — stronger `sidebar_sel_bg` and `explorer_active_bg` colors across all 6 themes; suppress current-file highlight when explorer has focus (TUI); TUI click on explorer sets `explorer_has_focus`; Ctrl-W h focuses explorer (GTK `window_nav_overflow` handling + TUI Explorer case in overflow match); `OpenFileFromSidebar` clears focus; GTK `row_activated` handles directory expand/collapse; fixed GTK j/k/arrow key passthrough for TreeView navigation. Known bug: GTK Enter on folder after arrow-key nav requires two presses (filed in BUGS.md). +- **Session 232**: Inline new file/folder in explorer tree — `ExplorerNewEntryState` struct with inline editing; replaced status-line prompt (TUI) and modal dialog (GTK) with inline editable row in tree; GTK bordered text field via CSS `treeview entry` styling; TUI inverted-cursor rendering with virtual row interleaving; generic file/folder icons during input; `start_explorer_new_file/folder()`, `handle_explorer_new_entry_key()`; removed `PromptKind::NewFile/NewFolder` and `show_name_prompt_dialog()`; `find_tree_iter_for_path()` + `remove_new_entry_rows()` tree helpers; 10 new tests. +- **Session 231**: Git branch switcher in status bar — clickable branch name in status bar opens `PickerSource::GitBranches` picker; ahead/behind counts (`↑N ↓N`) displayed; `status_branch_range` on `ScreenLayout`; GTK + TUI click handlers; `:Gbranches` command; fixed `Gcheckout` → `Gswitch` in picker confirm; 6 new tests. +- **Session 230**: Command Center enhancements + `sw` — `%` grep prefix, `debug` keyword (launch configs), `task` keyword (tasks.json), placeholder hints dropdown (9 mode items on empty query). `sw` greps word under cursor; `:GrepWord` command; palette entry. 30 new tests. +- **Session 229**: Command Center — clickable search box in menu bar opens unified picker with prefix routing: _(none)_ fuzzy files, `>` command palette, `@` document symbols, `#` workspace symbols, `:` go to line, `?` help. LSP `documentSymbol`/`workspaceSymbol` integration. `:CommandCenter` ex command. GTK + TUI click-to-open. 11 new tests. +> Sessions 228 and earlier in **SESSION_HISTORY.md**. ### Bug Fixes - [x] GTK core dump from panic in extern "C" draw callback — `catch_unwind` + `.ok()` on Cairo operations @@ -42,6 +45,13 @@ - [x] Double-click word-wise drag — word boundary snapping with `mouse_drag_word_mode`/`mouse_drag_word_origin` - [x] Ctrl+V paste in fuzzy finder — added handler in `handle_picker_key()` - [x] Search panel input broken — TUI click handler sets `sidebar.has_focus = true` +- [x] Search `n` doesn't scroll far enough — viewport_lines missed tab bar/breadcrumbs/hide_single_tab chrome rows in both GTK and TUI +- [x] Explorer tree doesn't reveal active buffer on folder open — added `reveal_path()` at TUI startup and after `open_folder()` +- [x] Visual yank doesn't move cursor to selection start — `yank_visual_selection()` moves cursor to start (Vim behavior) +- [x] YAML syntax breaks after editing — added YAML to tree-sitter reparse exclusion (external scanner corruption) +- [x] Crash in `completion_prefix_at_cursor` (index out of bounds) — clamped cursor col to `chars.len()` +- [x] Swap files don't preserve most recent edits on crash — `emergency_swap_flush()` + global engine pointer + panic hooks +- [x] Crash in `active_window_mut` (stale WindowId after tab/group close) — `repair_active_window()` self-healing + called after all close operations - [x] Git insights hover on non-cursor lines — clear `editor_hover_content` in `clear_annotations()` - [x] Semantic tokens disappear after hover — only accept responses with actual `data` array - [x] Terminal backspace key-hold batching — poll immediately after `terminal_write()` @@ -49,6 +59,9 @@ - [x] CLI file arg restores entire previous session — skip `restore_session_files` when CLI arg given; use `open_file_with_mode(Permanent)` to reuse scratch tab - [x] TUI: cannot drag tab to create new editor group with one group — added edge zone detection + visual feedback in `compute_tui_tab_drop_zone` / `render_tab_drag_overlay` - [x] GTK "Don't know color ''" warnings — empty TreeStore color columns (3, 5) replaced with valid hex defaults +- [x] Swap recovery dialog shown for unmodified buffers after crash — compare swap content with disk file, silently delete if identical +- [x] GTK explorer focus not returning to editor after file open — clear `explorer_has_focus`/`tree_has_focus` in `OpenFileFromSidebar` +- [x] GTK 100% CPU after opening file from explorer — caused by stuck `explorer_has_focus` state (same fix as above) ## Roadmap - [x] **Spell checker** — Vim-compatible `]s`/`[s`/`z=`/`zg`/`zw`; spellbook Hunspell parser; bundled en_US dictionary; tree-sitter-aware; `spell`/`spelllang` settings; user dictionary at `~/.config/vimcode/user.dic` @@ -103,6 +116,7 @@ ### Extensions (Planned) - [x] **Unified Picker (Telescope-style)** — core Rust-native unified picker replacing separate fuzzy/grep/palette modals; `PickerSource`/`PickerItem`/`PickerAction` types; `.gitignore`-aware file walking; fuzzy match highlighting; `sf`/`sg`/`sp` bindings; remappable via `panel_keys`; Phases 1-2 complete (files, grep, commands). Remaining: Phase 3 (buffers, marks, registers, branches), Phase 4 (Lua `vimcode.picker.show()` API) +- [x] **Fuzzy grep word under cursor (`sw`)** — `sw` opens the unified picker in live grep mode pre-filled with the word under the cursor (like Telescope's `grep_string`). Useful for quickly finding all usages of an identifier without manually typing it. Remappable via `panel_keys`. Also available as `:GrepWord` ex command and "Search: Word Under Cursor" palette entry. ### UI & Menus - [x] **VSCode-style menus** — application menu bar (File / Edit / View / Go / Run / Terminal / Help) in GTK; command palette (`Ctrl-Shift-P`) lists all commands + key bindings; fuzzy-searchable; both GTK native menus and TUI pop-up menu overlay (sessions 81–82, 100–101) @@ -156,6 +170,8 @@ ### Explorer - [x] **Explorer tree indicators** — Right-aligned git status (`M`/`A`/`?`/`D`/`R`) and deduplicated LSP diagnostic counts (errors/warnings) on explorer tree rows (like VSCode); per-extension `ignore_error_sources` config; `9+` cap. Both GTK and TUI backends. +- [x] **Inline new file/folder in explorer tree** — New File and New Folder should create an empty inline editable entry in the explorer tree (inserted under the selected/target directory) rather than prompting for the name in the status line (TUI) or a modal dialog (GTK). The entry uses the same inline editing pattern as rename (`ExplorerRenameState`-style). In GTK mode, both the new entry input and rename input should display with a visible bordered box around the text field. In TUI mode, the existing inverted-cursor inline style is sufficient. On Enter, create the file/folder; on Escape, cancel. File icon should show a generic new-file/new-folder icon during input. +- [ ] **Replace status-line confirmations with modal dialogs** — Audit all places where a y/n confirmation is collected via the status/command line (e.g. TUI `PromptKind::DeleteConfirm`, file move confirmations) and migrate them to use the engine's modal dialog system (`show_dialog()`/`show_error_dialog()`) instead. Dialogs are more visible, support clickable buttons, and match GTK's native confirmation dialogs. The status line should only be used for transient messages, not interactive prompts. ### Refactoring - [x] **Split main.rs into gtk/ directory** — `src/main.rs` (16,826 lines) → `src/gtk/` directory with 6 submodules: `mod.rs` (9,267 — App, Msg, SimpleComponent impl), `draw.rs` (5,519 — all 32 draw_* functions), `click.rs` (575 — mouse click/drag), `css.rs` (525 — theme CSS), `util.rs` (468 — GTK utilities), `tree.rs` (432 — file tree). Thin `main.rs` (55 lines) dispatches to `gtk::run()` or `tui_main::run()`. Zero API changes, all 4,721 tests pass. @@ -168,6 +184,39 @@ ### Robustness (Low Priority) - [x] **Consolidate sidebar focus state into engine** — `explorer_has_focus`/`search_has_focus` on Engine struct; `sidebar_has_focus()` aggregator + `clear_sidebar_focus()` helper; `handle_key()` guards; TUI `sync_sidebar_focus()` keeps state consistent; GTK sync on focus toggle/editor focus; 8 tests verify key routing correctness +### Tab Navigation & Command Center +- [x] **Tab scroll-into-view** — When a tab is opened, switched to via explorer click, or navigated to via history arrows, scroll the tab bar so the active tab appears in the center of the visible tab strip (or as close to center as possible given the tab count). Currently new tabs appear at the end and may be off-screen in the tab bar when many tabs are open. Applies to both GTK and TUI backends, per editor group. +- [x] **Back/Forward navigation arrows** — Add `←` `→` arrow buttons in the menu bar area (between the menu items and the Command Center, matching VSCode's layout). Maintain a per-editor-group **tab access history** stack (`Vec<(GroupId, TabId)>` on Engine) that records every tab focus change. `←` navigates to the previously accessed tab; `→` moves forward through the history after going back. Keyboard shortcuts: `Ctrl-Alt-Left` / `Ctrl-Alt-Right` (remappable via `panel_keys`). The arrows should be clickable in both GTK (drawn in the menu bar row) and TUI (rendered as `◀ ▶` buttons in the menu/tab bar row). History should be bounded (e.g. 100 entries) and deduplicated (consecutive duplicates collapsed). This is distinct from the existing Vim jump list (`Ctrl-O`/`Ctrl-I`), which tracks cursor positions within files rather than tab switches. +- [x] **Menu bar MRU history arrows** — Add `◀ ▶` arrow buttons in the **menu bar** row (to the left of the Command Center, matching VSCode's layout). These are distinct from the existing per-group tab bar arrows (which cycle L/R within the group). The menu bar arrows navigate a **global MRU tab history** across all editor groups — clicking `◀` jumps back to the previously visited tab (which may be in a different editor group), and `▶` moves forward. This enables quickly jumping between tabs you were working on minutes ago, even across splits. Keyboard shortcuts: configurable via `panel_keys` (e.g. `Ctrl-Alt-Left`/`Ctrl-Alt-Right` or similar, distinct from the per-group tab bar arrow bindings). History: bounded (100 entries), deduplicated (consecutive duplicates collapsed), forward entries truncated on new navigation. Rendered in both GTK (drawn in the menu bar row) and TUI (rendered as `◀ ▶` in the menu bar row). Distinct from the Vim jump list (`Ctrl-O`/`Ctrl-I`), which tracks cursor positions within files. +- [x] **Command Center** — Clickable search box in the menu bar opens the unified picker with prefix-based mode switching: _(none)_ fuzzy files, `>` command palette, `@` document symbols (LSP), `#` workspace symbols (LSP), `:` go to line, `?` help. Both GTK and TUI backends. `:CommandCenter` ex command. 11 tests. + +### Command Center Enhancements +- [x] **Search for Text prefix (`%`)** — Add `%` prefix to Command Center that opens live grep mode (same as `Ctrl+G` / `PickerSource::Grep`). When the user types `%` as the first character, the picker switches to live project search — matching VSCode's "Search for Text" Command Center entry. The `?` help menu should list this prefix alongside the others. +- [x] **Start Debugging prefix (`debug`)** — Add `debug` keyword prefix to Command Center. When the user types `debug`, show available launch configurations from `.vimcode/launch.json` (or offer to generate one). Selecting a configuration starts the DAP session (same as F5). If no launch.json exists, show "Create launch.json..." option. +- [x] **Run Task prefix (`task`)** — Add `task` keyword prefix to Command Center. When the user types `task`, list available tasks from `.vimcode/tasks.json` (build, test, lint, etc.). Selecting a task runs it in the integrated terminal. If no tasks.json exists, show "Configure Tasks..." option. +- [ ] **Open Quick Chat prefix** — Add a prefix (e.g. `chat` or `ai`) to Command Center that opens the AI chat panel and optionally pre-fills a prompt. Typing `chat ` sends the question directly to the AI provider. Requires AI panel to be configured (`ai_provider` setting). +- [x] **Command Center placeholder hints** — When the Command Center search box is empty and first opened, show a list of available modes as selectable items (matching VSCode's initial dropdown): "Go to File", "Show and Run Commands >", "Search for Text %", "Go to Symbol in Editor @", "Start Debugging debug", "Run Task task", "More ?". Each item should have its keyboard shortcut shown on the right. Selecting an item sets the corresponding prefix. + +### Breadcrumbs & Navigation +- [ ] **Breadcrumb symbol navigation** — Extend the existing breadcrumb bar to show the current symbol at the end (e.g. `src > engine > picker.rs > open_command_center`), populated from LSP `documentSymbol`. Clicking a path segment opens a dropdown of sibling files/folders to navigate; clicking the symbol segment opens a dropdown of sibling symbols in the file to jump between. Both GTK and TUI backends. + +### Status Bar Enhancements +- [x] **Git branch switcher in status bar** — Make the git branch name in the status bar clickable. Clicking opens the unified picker in `PickerSource::GitBranches` mode to switch branches. Show ahead/behind counts next to the branch name. Both GTK (click handler on status bar DA) and TUI (mouse click detection on status bar row). +- [ ] **Per-window status lines (Vim-style)** — Replace the single global status line with per-window status lines, matching Neovim/Vim behavior. Each window in a split gets its own status line drawn at its bottom edge. The **active window** shows a rich, colorful status line with segments: mode indicator (colored `NORMAL`/`INSERT`/`VISUAL`), git branch, filename (relative path or short name), modified flag, encoding (`utf-8`), file format (`unix`/`dos`), filetype/language (`rust`, `json`, etc.), and cursor position (`Ln:Col`). **Inactive windows** show a dimmed/minimal status line (just filename + cursor position). The status line content should be **user-configurable** via a format string in `settings.json` (similar to Vim's `statusline` option or lualine segments) — e.g. `"statusline": "%m %f %{branch} %l:%c %{filetype}"`. Render in both GTK (per-window Cairo strip below each editor pane) and TUI (per-window row at bottom of each window rect). The current global status bar at the very bottom becomes a "global status bar" showing workspace-level info (like VSCode's bottom bar) or can be hidden. This is a significant layout change: `RenderedWindow` gains a `status_line: Vec` field, `WindowRect` heights shrink by one row to accommodate per-window bars, and `build_screen_layout` computes per-window status content. +- [ ] **Clickable status bar segments** — Make status bar sections interactive: click line/col to open "Go to Line" (Command Center with `:` prefix), click language name to change syntax highlighting mode, click indentation to toggle tabs/spaces and set width, click encoding to change file encoding. Each click opens either a picker or a small settings popup. Both GTK and TUI backends. +- [ ] **LSP status indicator** — Show LSP server status in the status bar: spinning/pulsing indicator during initialization, server name when ready, error icon on crash. Clicking opens `:LspInfo`. Replaces the transient "LSP server initializing..." message with a persistent, unobtrusive indicator. + +### Tab Bar Enhancements +- [ ] **Editor action menu (`...`) button** — Add a `...` (more actions) button at the right edge of each tab bar group. Clicking opens a dropdown with common editor actions: Close All, Close Others, Close Saved, Close Tabs to the Right/Left, Toggle Word Wrap, Change Language Mode, Reveal in Explorer. Reuses existing engine commands. Both GTK and TUI backends. +- [ ] **Pinned tabs** — Allow pinning tabs via right-click context menu or `:tabpin` command. Pinned tabs shrink to just an icon (file type icon) and stay at the left of the tab bar. They cannot be closed by `:q` (require `:q!` or explicit unpin). Pinned state persists in session. Both GTK and TUI backends. + +### Layout & Chrome +- [ ] **Layout toggle buttons** — Add small clickable icons to toggle sidebar visibility, bottom panel (terminal) visibility, and editor layout from the menu bar or activity bar. VSCode puts these in the top-right corner of the title bar. Could also be exposed as status bar segments. Reuses existing toggle commands. +- [ ] **Notification / progress indicator** — Show a subtle indicator in the status bar or menu bar during background operations: LSP indexing, extension install, git operations, project search. Bell icon for completed notifications. Clicking opens an output log or dismisses. Prevents "is it working?" uncertainty during long operations. + +### Editor Features +- [ ] **Minimap** — Code overview minimap on the right edge of each editor pane, showing a scaled-down rendering of the entire file with the viewport highlighted. Click/drag to scroll. Syntax-highlighted. Toggleable via `:set minimap` / settings. Both GTK (Cairo scaled rendering) and TUI (braille/block character approximation). + ### CI & Distribution - [x] **macOS builds via GitHub Actions + Homebrew tap** — Add a macOS build target to the GitHub Actions CI/release workflow (build on `macos-latest` with `cargo build --release`). Produce a universal or arch-specific binary artifact. Create a Homebrew tap repository (e.g. `homebrew-vimcode`) with a formula that installs the release binary. Ensure the release workflow updates the tap formula (SHA256 + version) on each release. Test the full `brew install` → launch cycle in CI. - [ ] **Windows portable builds + code signing** — Add a Windows build target to the GitHub Actions CI/release workflow (build on `windows-latest` with `cargo build --release`). Package as a portable app (self-contained `.zip` with `vimcode.exe` + any required DLLs, no installer needed — just extract and run). Attach the `.zip` as a release artifact. Investigate code signing (Authenticode) so the binary doesn't trigger SmartScreen warnings and can be installed/run on corporate machines with restricted execution policies; document the signing process and certificate options (self-signed for testing, trusted CA for production). diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index 4f88a55f..25f99985 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -1,9 +1,9 @@ # VimCode Project State -**Last updated:** Mar 26, 2026 (Session 224 — Release prep v0.5.1) | **Tests:** 4769 +**Last updated:** Mar 29, 2026 (Session 233 — Explorer focus UX + GTK fixes) | **Tests:** 4986 > Feature documentation lives in **README.md**. -> Per-session implementation notes through Session 224 are in **SESSION_HISTORY.md**. +> Per-session implementation notes through Session 232 are in **SESSION_HISTORY.md**. --- @@ -26,5 +26,4 @@ When implementing a new key/command, add tests covering: ## Recent Work -> All sessions through 224 archived in **SESSION_HISTORY.md**. No recent unarchived work. - +> All sessions through 233 archived in **SESSION_HISTORY.md**. diff --git a/README.md b/README.md index 4cc3ef46..b7f83132 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ For detailed how-to guides and configuration references, see the **[VimCode Wiki - **First-class Vim mode** — deeply integrated, not a plugin - **Cross-platform** — GTK4 desktop UI + full terminal (TUI) backend - **CPU rendering** — Cairo/Pango (works in VMs, remote desktops, SSH) -- **Clean architecture** — platform-agnostic core, 4,769 tests, zero async runtime dependency +- **Clean architecture** — platform-agnostic core, 4,814 tests, zero async runtime dependency > **Note:** VimCode does not implement VimScript. Extension and scripting is handled via > the built-in Lua 5.4 plugin system. The goal is full Vim *keybinding* and *editing* @@ -350,6 +350,22 @@ The tab context menu offers both: "Split Right/Down" creates a Vim window split VimCode uses a unified picker system for file finding, live grep, and command palette. All pickers share the same UI and keybindings. Fuzzy match characters are highlighted in the results. +#### Command Center + +Click the search box in the menu bar (or run `:CommandCenter`) to open the unified picker. Type a prefix to switch modes: + +| Prefix | Mode | +|--------|------| +| _(none)_ | Fuzzy file search (same as `Ctrl-P`) | +| `>` | Command palette (same as `Ctrl-Shift-P`) | +| `@` | Go to symbol in current file (LSP `documentSymbol`) | +| `#` | Workspace symbol search (LSP `workspace/symbol`) | +| `:` | Go to line number | +| `%` | Search for text in project (live grep) | +| `debug` | Start debugging (show launch configurations) | +| `task` | Run a task (from tasks.json) | +| `?` | Show available prefix modes | + #### Fuzzy File Finder - `Ctrl-P` or `sf` (Normal mode) — open the fuzzy file picker @@ -361,6 +377,7 @@ VimCode uses a unified picker system for file finding, live grep, and command pa #### Live Grep - `Ctrl-Shift-F` or `sg` (Normal mode) — open the live grep picker +- `sw` — open live grep pre-filled with the word under the cursor - A centered floating two-column modal appears over the editor - Type to instantly search file *contents* across the entire project (live-as-you-type, query ≥ 2 chars) - Left pane shows results in `filename.rs:N: snippet` format; right pane shows ±5 context lines around the match @@ -483,6 +500,7 @@ For full details on adapters, launch.json, conditional breakpoints, and the debu | `:Gblame` | `:Gb` | Open `git blame` in scroll-synced vertical split | | `:Gswitch ` | `:Gsw` | Switch to an existing branch | | `:Gbranch ` | | Create a new branch and switch to it | +| `:Gbranches` | | Open branch picker (fuzzy-filter, click status bar branch) | | `:Ghs` | `:Ghunk` | Stage hunk under cursor (in a `:Gdiff` buffer) | | `:Gshow ` | | Show commit in Git Log panel (navigates and expands) | @@ -770,7 +788,7 @@ Full editor in the terminal via ratatui + crossterm — feature-parity with GTK. - **Layout:** activity bar (3 cols) | sidebar | editor area; status line + command line full-width at bottom - **Sidebar:** same file explorer as GTK with Nerd Font icons -- **Mouse support:** click-to-position, double-click word select, click-and-drag visual selection, window switching, scroll wheel (targets pane under cursor), scrollbar click-to-jump and drag; drag event coalescing for smooth scrollbar tracking; bracketed paste support +- **Mouse support:** click-to-position, double-click word select, click-and-drag visual selection, window switching, scroll wheel (targets pane under cursor), scrollbar click-to-jump and drag; drag event coalescing for smooth scrollbar tracking; bracketed paste support; click branch name in status bar to open branch picker - **Sidebar resize:** drag separator column; `Alt+Left` / `Alt+Right` keyboard resize (min 15, max 60 cols) - **Scrollbars:** `█` / `░` thumb/track in uniform grey; vsplit separator doubles as left-pane vertical scrollbar; horizontal scrollbar row when content wider than viewport; `┘` corner when both axes present - **Scroll sync:** `:Gblame` pairs stay in sync across keyboard nav and mouse events @@ -859,6 +877,7 @@ Full editor in the terminal via ratatui + crossterm — feature-parity with GTK. | `gt` / `gT` | Next / previous tab | | `Ctrl+Tab` / `Ctrl+Shift+Tab` | MRU tab switcher (forward / backward) | | `Alt+t` | MRU tab switcher (TUI + GTK compatible) | +| `Ctrl+Alt+Left` / `Ctrl+Alt+Right` | Navigate back / forward through tab history | | `gd` | Go to definition (LSP) | | `gr` | Find references (LSP) — multiple results open quickfix | | `gi` | Insert at last insert position | @@ -888,6 +907,7 @@ Full editor in the terminal via ratatui + crossterm — feature-parity with GTK. | `ca` | Show LSP code actions for current line | | `sf` | Open fuzzy file finder (same as Ctrl-P) | | `sg` | Open live grep picker (same as Ctrl-Shift-F) | +| `sw` | Grep word under cursor | | `sp` | Open command palette (same as Ctrl-Shift-P) | | `za` / `zo` / `zc` / `zR` | Fold toggle / open / close / open all | | `zA` / `zO` / `zC` | Fold toggle / open / close recursively | @@ -994,7 +1014,9 @@ All ex commands support Vim-style abbreviations (e.g., `:j` for `:join`, `:y` fo | `:b {name}` | Switch to buffer matching partial file name | | `:!{cmd}` | Execute shell command and show output | | `:r {file}` | Read file contents into buffer after cursor line | -| `:tabmove [N]` | Move current tab to position N (0-based, default = end) | +| `:tabmove [N]` | Move current tab to position N (1-based, 0 = end) | +| `:navback` | Navigate to previous tab in history | +| `:navforward` | Navigate to next tab in history | | `:Gdiff` / `:Gdiffsplit` | Git diff (unified / side-by-side) | | `:Gstatus` | Git status | | `:Gadd` / `:Gadd!` | Stage file / stage all | @@ -1005,6 +1027,7 @@ All ex commands support Vim-style abbreviations (e.g., `:j` for `:join`, `:y` fo | `:Gblame` | Blame (scroll-synced split) | | `:Gswitch ` / `:Gsw` | Switch to existing branch | | `:Gbranch ` | Create new branch and switch to it | +| `:Gbranches` | Open branch picker (status bar click also works) | | `:Ghs` / `:Ghunk` | Stage hunk under cursor | | `:Gshow ` | Show commit in Git Log panel (navigates and expands) | | `:DiffPeek` | Open diff hunk peek popup at cursor (revert/stage) | @@ -1022,6 +1045,7 @@ All ex commands support Vim-style abbreviations (e.g., `:j` for `:join`, `:y` fo | `:DiffToggleContext` | Toggle hiding unchanged sections in diff view | | `:diffoff` | Clear diff highlighting | | `:grep ` / `:vimgrep ` | Search project, populate quickfix list | +| `:GrepWord` | Grep the word under cursor (same as `sw`) | | `:copen` / `:ccl` | Open / close quickfix panel | | `:cn` / `:cp` | Next / previous quickfix item | | `:cc N` | Jump to Nth quickfix item (1-based) | @@ -1040,6 +1064,7 @@ All ex commands support Vim-style abbreviations (e.g., `:j` for `:join`, `:y` fo | `:nextdiag` / `:prevdiag` | Jump to next / previous LSP diagnostic | | `:nexthunk` / `:prevhunk` | Jump to next / previous git hunk | | `:fuzzy` | Open fuzzy file finder | +| `:CommandCenter` | Open Command Center (unified picker with prefix modes) | | `:sidebar` | Toggle sidebar | | `:palette` | Open command palette | | `:Comment [N]` | Toggle comment on N lines (46+ languages; `:Commentary` alias) | diff --git a/SESSION_HISTORY.md b/SESSION_HISTORY.md index 5f8bea8e..23af53ab 100644 --- a/SESSION_HISTORY.md +++ b/SESSION_HISTORY.md @@ -1,10 +1,46 @@ # VimCode Session History Detailed per-session implementation notes archived from PROJECT_STATE.md. -All sessions through 224 archived here. Recent work summary in PROJECT_STATE.md. +All sessions through 233 archived here. Recent work summary in PROJECT_STATE.md. --- +**Session 233 — Explorer focus UX polish + GTK fixes (4986 tests):** +Explorer focus visibility improvements: stronger `sidebar_sel_bg` colors across all 6 themes (OneDark `#373d4a`, Gruvbox `#504945`, Tokyo Night `#33395a`, Solarized `#0a4a5a`, Dark+ `#04395e`, Light+ `#b4d9ff`); brighter `explorer_active_bg` for current-file highlight when explorer unfocused. TUI: suppress current-editor-file highlight (`is_active`) when `explorer_has_focus` is true; clicking explorer tree sets `explorer_has_focus`. Ctrl-W h now focuses explorer: GTK handles `window_nav_overflow` (left overflow → `Msg::FocusExplorer`); TUI adds `Explorer` case to overflow match. GTK: `OpenFileFromSidebar` clears `explorer_has_focus`/`tree_has_focus` (fixes 100% CPU + stuck focus); `row_activated` handles directory expand/collapse; j/k/arrow keys pass through to TreeView; `ExplorerActivateSelected` message for programmatic activation. Swap recovery: skip dialog when swap content matches disk file. Known bug filed: GTK Enter on folder after arrow-key nav requires two presses. +Files: `render.rs`, `tui_main/panels.rs`, `tui_main/mouse.rs`, `tui_main/mod.rs`, `gtk/mod.rs`, `gtk/css.rs`, `core/engine/ext_panel.rs`, `BUGS.md`. + +**Session 232 — Inline new file/folder in explorer tree (4985 tests):** +`ExplorerNewEntryState` struct with inline editing in explorer tree. Replaced status-line prompt (TUI) and modal dialog (GTK) with inline editable row inserted under target directory. GTK: bordered text field via CSS `treeview entry` styling; `CellRendererText` editable mode; `Msg::StartInlineNewFile/Folder`; `ExplorerAction` dispatch via `idle_add_local_once` to avoid RefCell panics. TUI: inverted-cursor rendering with virtual row interleaving in `render_new_entry_row()`; key routing guard for `explorer_new_entry`. Engine: `start_explorer_new_file/folder()`, `handle_explorer_new_entry_key()` (Escape/Return/BackSpace/Delete/Left/Right/Home/End/printable). Removed `PromptKind::NewFile/NewFolder` and `show_name_prompt_dialog()`. Tree helpers: `find_tree_iter_for_path()`, `remove_new_entry_rows()`. Swap recovery: compare swap content with disk, silently delete if identical. 10 new tests + 1 swap recovery test. +Files: `core/engine/mod.rs`, `core/engine/buffers.rs`, `core/engine/ext_panel.rs`, `core/engine/tests.rs`, `gtk/mod.rs`, `gtk/tree.rs`, `gtk/css.rs`, `tui_main/mod.rs`, `tui_main/panels.rs`, `tui_main/mouse.rs`, `tui_main/render_impl.rs`, `tests/swap_recovery.rs`. + +**Session 231 — Git branch switcher in status bar (4955 tests):** +Clickable branch name in status bar opens `PickerSource::GitBranches` unified picker. Status bar shows ahead/behind counts (`↑N ↓N`). `status_branch_range: Option<(usize, usize)>` on `ScreenLayout` for click detection. TUI: status bar row click handler in `mouse.rs`. GTK: click handler in `handle_mouse_click_msg()` reconstructs column range from engine state. `:Gbranches` command opens branch picker. Fixed picker confirm using `Gcheckout` (nonexistent) → `Gswitch`. Updated "Git: Switch Branch" palette entry to use `Gbranches`. 6 new tests. +Files: `render.rs`, `engine/picker.rs`, `engine/execute.rs`, `engine/mod.rs`, `engine/tests.rs`, `tui_main/mouse.rs`, `gtk/mod.rs`. + +**Session 230 — Command Center enhancements + `sw` (4937 tests):** +Added Command Center prefix routing: `%` for live grep (extracted `picker_cc_grep_search()`), `debug` keyword prefix (reads `.vimcode/launch.json` / `.vscode/launch.json` via `picker_populate_debug_configs()`), `task` keyword prefix (reads tasks.json via `picker_populate_tasks()`, `EngineAction::RunInTerminal`). Placeholder hints dropdown: 9 mode items shown when CC opens with empty query (Go to File, Commands, Symbols, Line, Grep, Debug, Task, Help). `hint_item()` helper. `sw` greps word under cursor via `word_under_cursor()` + opens Grep picker pre-filled. `:GrepWord` command + "Search: Word Under Cursor" palette entry. 30 new tests. +Files: `engine/picker.rs`, `engine/keys.rs`, `engine/execute.rs`, `engine/mod.rs`, `engine/tests.rs`. + +**Session 229 — Command Center (4847 tests):** +Clickable search box in menu bar opens unified picker with prefix-based mode switching: no prefix = fuzzy files, `>` = command palette, `@` = document symbols (LSP `textDocument/documentSymbol`), `#` = workspace symbols (LSP `workspace/symbol`), `:` = go to line, `?` = help. Added `PickerSource::CommandCenter`, `PickerAction::GotoLine`/`GotoSymbol`. LSP types: `SymbolInfo`, `SymbolKind` (with icons/labels), hierarchical + flat response parsing. `picker_filter_command_center()` prefix routing, `fuzzy_filter_items()` shared helper. `:CommandCenter` ex command + palette entry. GTK `Msg::OpenCommandCenter` + search box click. TUI search box click. 11 new tests. Also added 14 new roadmap items (Command Center enhancements, breadcrumbs, status bar, tab bar, layout, minimap). +Files: `engine/mod.rs`, `engine/picker.rs`, `engine/execute.rs`, `engine/panels.rs`, `engine/tests.rs`, `lsp.rs`, `lsp_manager.rs`, `gtk/mod.rs`, `tui_main/mouse.rs`. + +**Session 228 — Menu bar MRU history arrows (4814 tests):** +Moved `◀ ▶` nav arrows from per-group tab bars to the menu bar row, centered alongside a VSCode Command Center-style search box showing the workspace directory name. Nav arrows navigate a global MRU tab history across all editor groups (like VSCode). Removed per-group tab bar nav arrows and overflow indicators. History starts empty on startup (arrows greyed out); seeded with initial tab so first switch records origin. Forward history truncated when navigating to a new tab (undo/redo style). `MenuBarData.title` changed from active filename to workspace dir name. GTK: `nav_arrow_rects: Rc>` caches exact draw positions for click hit-testing; `EventSequenceState::Claimed` prevents WindowHandle double-click-to-maximize in nav+search area; `menu_bar_da.queue_draw()` for proper redraws. TUI: arrows + search box centered as one unit in menu bar row. `tab_nav_push()` called from explicit navigation methods only; `tab_mru_touch()` no longer pushes to nav history. 7 tab nav tests rewritten. +Files: `engine/mod.rs`, `engine/windows.rs`, `engine/tests.rs`, `render.rs`, `gtk/draw.rs`, `gtk/mod.rs`, `tui_main/render_impl.rs`, `tui_main/mouse.rs`. + +**Session 227 — Back/Forward navigation arrows + bug fixes (4811 tests):** +Tab access history with clickable nav arrows; `:tabmove` 1-based; `Ngt` to tab N; TUI split button dedup; Explorer preview scroll; multi-swap recovery; `:q` diff tab fix; SC panel single/double-click; `C` to commit; `p`/`P` swap. Engine fields: `tab_nav_history`, `tab_nav_index`, `tab_nav_navigating`. Methods: `tab_nav_push/back/forward/switch_to/can_go_back/can_go_forward`. PanelKeys: `nav_back` (``), `nav_forward` (``). `:navback`, `:navforward` commands + palette entries. GTK: `NavBtnMap`, nav arrows in `draw_tab_bar`, click targets `NavBack`/`NavForward`. TUI: `◀ ▶` at left of tab bar. 5 new tab nav tests. +Files: `engine/mod.rs`, `engine/windows.rs`, `engine/keys.rs`, `engine/execute.rs`, `engine/source_control.rs`, `engine/panels.rs`, `engine/ext_panel.rs`, `engine/tests.rs`, `settings.rs`, `render.rs`, `gtk/mod.rs`, `gtk/draw.rs`, `gtk/click.rs`, `tui_main/mod.rs`, `tui_main/render_impl.rs`, `tui_main/mouse.rs`. + +**Session 226 — Tab scroll-into-view (4784 tests):** +Added per-group tab bar scrolling so the active tab is always visible. `EditorGroup.tab_scroll_offset: usize` — index of first visible tab. `ensure_active_tab_visible()` called from `goto_tab`, `next_tab`, `prev_tab`, `new_tab`, `close_tab`, `open_file_in_tab`, `tab_switcher_confirm`, and `:tabmove`. `◀`/`▶` overflow indicators when tabs hidden on either side. Both GTK and TUI: rendering, click handling, tooltips, drag-drop adjusted for scroll offset. 6 new tests. +Files: `engine/mod.rs`, `engine/windows.rs`, `engine/execute.rs`, `render.rs`, `gtk/draw.rs`, `tui_main/render_impl.rs`, `tui_main/mouse.rs`, `engine/tests.rs`. + +**Session 225 — Bug fixes & crash hardening (4778 tests):** +Fixed 7 bugs: explorer reveal on folder open, visual yank cursor position, YAML syntax corruption, completion_prefix crash, swap flush on panic (emergency_swap_flush + global engine pointer), search viewport_lines accounting, stale WindowId crash (repair_active_window self-healing). Added "Tab Navigation & Command Center" plan items. +Files: `accessors.rs`, `motions.rs`, `panels.rs`, `visual.rs`, `windows.rs`, `tests.rs`, `swap.rs`, `syntax.rs`, `gtk/mod.rs`, `tui_main/mod.rs`, `tests/visual_mode.rs`. + **Session 224 — Release prep v0.5.1 (4769 tests):** Bump version to 0.5.1 for patch release. Update test counts across docs (4736→4769). Tick off macOS CI/Homebrew roadmap item in PLAN.md (implemented in Session 218 CI commit, but release.yml changes hadn't reached `main` yet — this release will be the first to produce macOS binaries and update the Homebrew tap). Investigated why macOS build job wasn't running in release workflow: the `build-macos` job definition only existed on `develop`, not `main`. diff --git a/SUMMARIES/core_modules.md b/SUMMARIES/core_modules.md index e3499a6c..8d92d0e7 100644 --- a/SUMMARIES/core_modules.md +++ b/SUMMARIES/core_modules.md @@ -1,14 +1,16 @@ # Core Modules (src/core/) -## lsp.rs — 2,486 lines +## lsp.rs — 2,784 lines LSP protocol transport and single-server client. ### Types - `LspServer` — manages a single LSP server process (stdin/stdout/stderr, reader thread) -- `LspEvent` — enum of all LSP response types (Completion, Definition, Hover, Diagnostics, SemanticTokens, etc.) +- `LspEvent` — enum of all LSP response types (Completion, Definition, Hover, Diagnostics, SemanticTokens, DocumentSymbolResponse, WorkspaceSymbolResponse, etc.) - `Diagnostic` / `DiagnosticSeverity` — diagnostic data - `CodeAction` / `CompletionItem` / `Location` / `LspRange` / `LspPosition` — LSP data types - `WorkspaceEdit` / `FileEdit` / `FormattingEdit` — edit application types - `SemanticToken` / `SemanticTokensLegend` — semantic token data +- `SymbolInfo` — document/workspace symbol data (name, kind, path, line, col) +- `SymbolKind` — enum with `from_number()`, `icon()`, `label()` methods - `SignatureHelpData` — function signature info - `LspServerConfig` — server command + args + language mappings - `MasonPackageInfo` — Mason package metadata @@ -16,11 +18,14 @@ LSP protocol transport and single-server client. - `LspServer::start(config)` — spawn LSP process, send initialize, start reader thread - `did_open/did_change/did_save/did_close` — document sync notifications - `request_completion/definition/hover/references/implementation/rename/code_action/formatting/semantic_tokens_full` — LSP requests +- `request_document_symbols(uri)` / `request_workspace_symbols(query)` — symbol requests +- `parse_document_symbols(value)` / `parse_workspace_symbols(value)` — parse symbol responses +- `flatten_document_symbol(sym, path, out)` / `parse_symbol_information(item, out)` — internal symbol parsers - `decode_semantic_tokens(raw, legend)` — delta-decode semantic token array - `path_to_uri/uri_to_path` — file path ↔ URI conversion - `language_id_from_path(path)` — file extension to language ID -## lsp_manager.rs — 982 lines +## lsp_manager.rs — 994 lines Multi-server LSP coordinator. Manages server lifecycle per language. ### Types - `LspManager` — holds active servers, registry, extension manifests @@ -30,6 +35,7 @@ Multi-server LSP coordinator. Manages server lifecycle per language. - `poll_events()` — collect events from all active servers - `notify_did_open/change/save/close(path)` — route notifications to correct server - `request_completion/definition/hover/references/...` — route requests by file path +- `request_document_symbols(path)` / `request_workspace_symbols(path, query)` — route symbol requests to correct server - `restart_server_for_language(lang_id)` / `stop_server_for_language(lang_id)` - `server_info(current_lang)` — `:LspInfo` output - `default_server_registry()` — built-in server configs @@ -100,7 +106,7 @@ Buffer storage and management. - `BufferManager::create(path)` / `get(id)` / `get_mut(id)` / `remove(id)` - `BufferState::from_text(text)` / `from_file(path)` — buffer creation -## syntax.rs — 1,522 lines +## syntax.rs — 1,525 lines Tree-sitter syntax highlighting for 20 languages. ### Types - `SyntaxHighlighter` — tree-sitter parser + tree per buffer diff --git a/SUMMARIES/engine_buffers.md b/SUMMARIES/engine_buffers.md index e21ad0f3..3aa27580 100644 --- a/SUMMARIES/engine_buffers.md +++ b/SUMMARIES/engine_buffers.md @@ -1,4 +1,4 @@ -# src/core/engine/buffers.rs — 3,089 lines +# src/core/engine/buffers.rs — 3,244 lines File I/O, buffer management, syntax updates, undo/redo, git diff, markdown preview, netrw directory browser, and workspace operations. @@ -30,6 +30,11 @@ File I/O, buffer management, syntax updates, undo/redo, git diff, markdown previ - `open_diff_view(path)` — git diff view - `sc_open_selected_async()` — async diff for source control panel +## Inline New File/Folder +- `start_explorer_new_file(parent_dir)` — begin inline new-file entry in explorer +- `start_explorer_new_folder(parent_dir)` — begin inline new-folder entry in explorer +- `handle_explorer_new_entry_key(key, unicode, ctrl)` — key dispatch for inline creation (Enter creates, Escape cancels) + ## Workspace - `open_folder(path)` — change working directory - `add_workspace_folder(path)` — multi-root workspace diff --git a/SUMMARIES/engine_execute.md b/SUMMARIES/engine_execute.md index d46a81fc..085bade4 100644 --- a/SUMMARIES/engine_execute.md +++ b/SUMMARIES/engine_execute.md @@ -1,9 +1,9 @@ -# src/core/engine/execute.rs — 2,962 lines +# src/core/engine/execute.rs — 2,998 lines Ex-command dispatcher. Parses and executes all `:` commands entered in command mode. ## Key Methods - `execute_command(cmd)` — main dispatcher; giant match over ~100+ command names -- Handles: `:w`, `:q`, `:e`, `:sp`, `:vs`, `:bn`, `:bp`, `:bd`, `:tabnew`, `:tabclose`, `:set`, `:colorscheme`, `:norm`, `:grep`, `:vimgrep`, `:copen`, `:cn`, `:cp`, `:Gdiff`, `:Gblame`, `:Gstatus`, `:Gpush`, `:Gpull`, `:Gfetch`, `:term`, `:LspInfo`, `:LspRestart`, `:LspInstall`, `:DapInstall`, `:Plugin`, `:Settings`, `:Keymaps`, `:AI`, `:ExtRemove`, `:ExtRefresh`, `:map`/`:nmap`/`:imap`/`:vmap`, `:retab`, `:saveas`, `:windo`/`:bufdo`/`:tabdo`, `:fold`, `:Rename`, `:Lformat`, `:CodeAction`, `:hover`, `:DiffPeek`, `:Explore`, etc. +- Handles: `:w`, `:q`, `:e`, `:sp`, `:vs`, `:bn`, `:bp`, `:bd`, `:tabnew`, `:tabclose`, `:set`, `:colorscheme`, `:norm`, `:grep`, `:vimgrep`, `:copen`, `:cn`, `:cp`, `:Gdiff`, `:Gblame`, `:Gstatus`, `:Gpush`, `:Gpull`, `:Gfetch`, `:Gbranches`, `:term`, `:LspInfo`, `:LspRestart`, `:LspInstall`, `:DapInstall`, `:Plugin`, `:Settings`, `:Keymaps`, `:AI`, `:ExtRemove`, `:ExtRefresh`, `:map`/`:nmap`/`:imap`/`:vmap`, `:retab`, `:saveas`, `:windo`/`:bufdo`/`:tabdo`, `:fold`, `:Rename`, `:Lformat`, `:CodeAction`, `:hover`, `:DiffPeek`, `:Explore`, etc. - Range parsing: `%`, `'<,'>`, `N,M`, `.`, `$`, relative `+N/-N` - Falls through to plugin command dispatch if no built-in match diff --git a/SUMMARIES/engine_mod.md b/SUMMARIES/engine_mod.md index 0c0c4764..1c52ec63 100644 --- a/SUMMARIES/engine_mod.md +++ b/SUMMARIES/engine_mod.md @@ -1,4 +1,4 @@ -# src/core/engine/mod.rs — 3,342 lines +# src/core/engine/mod.rs — 3,415 lines Core engine definition. Contains the `Engine` struct (all editor state), enums, types, `new()` constructor, free functions, and `mod` declarations for all submodules. @@ -6,18 +6,19 @@ Core engine definition. Contains the `Engine` struct (all editor state), enums, - `Engine` — main editor state struct (~830 fields covering buffers, windows, groups, mode, LSP, DAP, search, terminal, plugins, etc.) - `EngineAction` — enum returned by key handlers (None, Quit, OpenFile, Redraw, etc.) - `Mode` — editor mode (Normal, Insert, Visual, VisualLine, VisualBlock, Command, Search, Replace) -- `PickerSource` / `PickerItem` / `PickerAction` — unified picker types +- `PickerSource` / `PickerItem` / `PickerAction` — unified picker types (includes `CommandCenter` source, `GotoLine(usize)` and `GotoSymbol(PathBuf, usize, usize)` actions) - `Dialog` / `DialogButton` / `DialogInput` — modal dialog system -- `PaletteCommand` — command palette entry +- `PaletteCommand` — command palette entry (includes "Go: Command Center") - `DiffLine` / `AlignedDiffEntry` — diff display types - `TabDragState` — tab drag-and-drop state - `ContextMenuState` / `ContextMenuItem` — right-click context menus - `PanelHoverPopup` / `EditorHoverPopup` — hover popup state -- `EditorGroup` — tab group with own tab list +- `EditorGroup` — tab group with own tab list + `tab_scroll_offset` for overflow scrolling - `UserKeymap` — user-defined key remapping - `DiffPeekState` — inline diff peek popup state - `SwapRecovery` — crash recovery swap file state - `SettingsRow` — settings panel row identifier +- `ExplorerNewEntryState` — inline new-file/folder creation state (parent_dir, input, cursor, is_folder) ## Key Functions - `Engine::new()` — constructor, initializes all state diff --git a/SUMMARIES/engine_small_submodules.md b/SUMMARIES/engine_small_submodules.md index dee80851..74696ba5 100644 --- a/SUMMARIES/engine_small_submodules.md +++ b/SUMMARIES/engine_small_submodules.md @@ -1,12 +1,13 @@ # Engine Small Submodules -## accessors.rs — 401 lines +## accessors.rs — 444 lines Convenience facade methods for accessing the active group/buffer/window. - `buffer()` / `buffer_mut()` — active window's buffer - `view()` / `view_mut()` — active window's viewport - `cursor()` — active cursor position - `active_group()` / `active_group_mut()` — active editor group - `active_tab()` / `active_tab_mut()` — active tab in active group +- `repair_active_window()` — self-heal stale active_window ID (find valid window or create scratch) - `is_tab_bar_hidden(group_id)` — true if tab bar should be hidden (single group, ≤1 tab, setting on) - `adjust_group_rects_for_hidden_tabs(rects, height)` — expand content area when tab bar hidden - `sidebar_has_focus()` — true if any sidebar panel has keyboard focus @@ -61,7 +62,7 @@ Extension panel system (Lua panels), hover popups (panel + editor), and source c - `sc_hover_markdown(flat_idx)` — generate hover content for SC panel items - `sc_hover_file()` / `sc_hover_log_entry()` / `sc_hover_branch_info()` — SC hover helpers -## panels.rs — 1,710 lines +## panels.rs — 1,726 lines AI chat panel, dialog system, and swap file crash recovery. - `ai_send_message(msg)` — send message to AI provider - `ai_poll()` — poll for AI streaming response @@ -70,6 +71,7 @@ AI chat panel, dialog system, and swap file crash recovery. - `process_dialog_result(id)` — handle dialog button press - `tick_swap_files()` — periodic swap file writes - `check_swap_recovery(path)` — detect and offer crash recovery +- `emergency_swap_flush()` — write swap files for ALL dirty buffers immediately (called from panic hooks) ## plugins.rs — 653 lines Lua plugin lifecycle and dispatch. @@ -97,13 +99,21 @@ VSCode edit mode and menu bar handling. - `menu_activate_item(menu, item)` — execute menu action - `handle_menu_key(key)` — menu navigation -## picker.rs — 630 lines -Unified fuzzy finder (Telescope-style) and quickfix. +## picker.rs — 1,275 lines +Unified fuzzy picker (Telescope-style), command center, quickfix, and branch picker. - `fuzzy_score(query, candidate)` — subsequence match scoring - `open_picker(source)` — open picker with file/grep/command/buffer source - `handle_picker_key(key, ctrl, unicode)` — picker input and navigation -- `picker_confirm()` — execute selected picker item +- `picker_confirm()` — execute selected picker item (includes branch switching via `Gswitch`) - `quickfix_jump(idx)` — jump to quickfix entry +- `open_command_center()` — opens picker in CommandCenter mode +- `picker_filter_command_center()` — prefix-aware routing (>, @, #, :, ?) +- `picker_populate_document_symbols()` — populate picker from LSP document symbol response +- `picker_populate_workspace_symbols()` — populate picker from LSP workspace symbol response +- `picker_populate_branches()` — populate picker from git branch list +- `picker_request_document_symbols()` — send LSP documentSymbol request +- `picker_request_workspace_symbols()` — send LSP workspace/symbol request +- `fuzzy_filter_items()` — shared fuzzy filter helper ## terminal_ops.rs — 478 lines Integrated terminal management. diff --git a/SUMMARIES/engine_tests.md b/SUMMARIES/engine_tests.md index 8a78851b..593949ec 100644 --- a/SUMMARIES/engine_tests.md +++ b/SUMMARIES/engine_tests.md @@ -1,6 +1,6 @@ -# src/core/engine/tests.rs — 14,629 lines +# src/core/engine/tests.rs — 15,889 lines -All engine unit and integration tests. ~4,736 test functions covering every Vim feature, command, motion, text object, and edge case. +All engine unit and integration tests. ~815 test functions covering every Vim feature, command, motion, text object, and edge case. ## Test Helpers - `engine_with(text)` — create engine with initial buffer content; resets settings and keymaps for hermeticity @@ -10,4 +10,4 @@ All engine unit and integration tests. ~4,736 test functions covering every Vim - `assert_cursor(engine, line, col)` — assert cursor position ## Test Coverage Areas -Normal mode keys, insert mode, visual mode (char/line/block), operators (d/c/y/>//` for click detection - `EditorGroupSplitData` — group layout with tab bars and windows -- `GroupTabBar` / `TabInfo` — tab strip data +- `GroupTabBar` / `TabInfo` — tab strip data (includes `tab_scroll_offset`) - `BreadcrumbBar` / `BreadcrumbSegment` — file path breadcrumbs - `CompletionMenu` — LSP/word completion dropdown - `HoverPopup` / `EditorHoverPopupData` — hover information popup diff --git a/SUMMARIES/tui_modules.md b/SUMMARIES/tui_modules.md index d1b7ecc0..65fc1fd7 100644 --- a/SUMMARIES/tui_modules.md +++ b/SUMMARIES/tui_modules.md @@ -1,6 +1,6 @@ # TUI Backend Modules -## src/tui_main/mod.rs — 4,175 lines +## src/tui_main/mod.rs — 4,230 lines TUI application shell using ratatui + crossterm. Contains setup, event loop, key translation, clipboard, and cell rendering helpers. - `run(file_path, debug_log)` — entry point; sets up terminal, runs event loop, restores terminal on exit - `event_loop(terminal, engine)` — main loop: poll events, dispatch keys, call draw_frame, poll async (LSP/DAP/terminal/search) @@ -10,7 +10,7 @@ TUI application shell using ratatui + crossterm. Contains setup, event loop, key - Clipboard via `copypasta_ext::x11_bin::ClipboardContext` - Keyboard enhancement flags for Kitty/WezTerm (disambiguate Ctrl+Shift combos) -## src/tui_main/render_impl.rs — 3,777 lines +## src/tui_main/render_impl.rs — 3,845 lines All TUI rendering. Converts `ScreenLayout` into ratatui `Frame` draws. - `draw_frame(frame, engine, theme)` — top-level render function - `build_screen_for_tui(engine, cols, rows)` — compute layout geometry @@ -18,13 +18,13 @@ All TUI rendering. Converts `ScreenLayout` into ratatui `Frame` draws. - Editor window rendering (syntax spans → ratatui Spans) - Popup rendering: completion, hover, picker, dialog, context menu, diff peek, signature help - Status line + command line + wildmenu rendering -- Menu bar + dropdown rendering +- Menu bar + dropdown rendering (centered nav arrows + Command Center search box) - Debug toolbar rendering -## src/tui_main/panels.rs — 3,931 lines +## src/tui_main/panels.rs — 4,048 lines Sidebar panel rendering for all TUI panels. - Activity bar (icon column, panel switching) -- Explorer file tree with git/diagnostic indicators +- Explorer file tree with git/diagnostic indicators + inline new-entry rows (`render_new_entry_row()`) - Source control panel (staged/unstaged files, commit input, worktrees) - Debug sidebar (call stack, variables, watch, breakpoints) - Extensions marketplace panel (installed/available, search, install/remove) @@ -34,7 +34,7 @@ Sidebar panel rendering for all TUI panels. - Extension dynamic panels (Lua-registered panels) - Panel hover popup rendering -## src/tui_main/mouse.rs — 2,385 lines +## src/tui_main/mouse.rs — 2,459 lines All TUI mouse interaction handling. - `handle_mouse(event, engine, layout)` — top-level mouse dispatcher - Activity bar clicks (panel switching) @@ -43,6 +43,7 @@ All TUI mouse interaction handling. - Tab bar clicks (tab switch, close button, drag between groups) - Sidebar resize drag (Alt+Left/Right or mouse drag on border) - Scrollbar drag (vertical + horizontal, editor + panel) +- Status bar clicks (branch name click opens branch picker) - Terminal panel clicks - Source control / extensions / debug sidebar clicks - Group divider drag to resize editor groups diff --git a/src/core/engine/accessors.rs b/src/core/engine/accessors.rs index 3a2f5431..8253ecbc 100644 --- a/src/core/engine/accessors.rs +++ b/src/core/engine/accessors.rs @@ -64,6 +64,37 @@ impl Engine { // Accessors for active window/buffer (facade for backward compatibility) // ======================================================================= + /// Repair inconsistent state where `active_tab().active_window` points + /// to a WindowId that no longer exists in `self.windows`. This can + /// happen after certain tab/group close sequences. The method finds + /// a valid window from the current tab's layout, or creates a fresh + /// scratch window as a last resort. + pub(crate) fn repair_active_window(&mut self) { + let wid = self.active_tab().active_window; + if self.windows.contains_key(&wid) { + return; // already valid + } + + // Try to find another valid window in the current tab's layout. + let layout_wids = self.active_tab().layout.window_ids(); + for candidate in &layout_wids { + if self.windows.contains_key(candidate) { + self.active_tab_mut().active_window = *candidate; + return; + } + } + + // No valid windows in this tab at all — create a scratch window. + let buf_id = self.buffer_manager.create(); + let new_wid = crate::core::window::WindowId(self.next_window_id); + self.next_window_id += 1; + let window = crate::core::window::Window::new(new_wid, buf_id); + self.windows.insert(new_wid, window); + let tab = self.active_tab_mut(); + tab.layout = crate::core::window::WindowLayout::leaf(new_wid); + tab.active_window = new_wid; + } + pub fn active_tab(&self) -> &Tab { self.active_group().active_tab() } @@ -77,12 +108,24 @@ impl Engine { } pub fn active_window(&self) -> &Window { - self.windows.get(&self.active_window_id()).unwrap() + let id = self.active_window_id(); + self.windows.get(&id).unwrap_or_else(|| { + panic!( + "BUG: active_window WindowId({}) not in windows map (map has {} entries). \ + Please report this at https://github.com/anthropics/claude-code/issues", + id.0, + self.windows.len() + ) + }) } pub fn active_window_mut(&mut self) -> &mut Window { + // Self-heal: if the active window ID is stale, repair before accessing. + self.repair_active_window(); let id = self.active_window_id(); - self.windows.get_mut(&id).unwrap() + self.windows + .get_mut(&id) + .expect("repair_active_window should have fixed this") } pub fn active_buffer_id(&self) -> BufferId { diff --git a/src/core/engine/buffers.rs b/src/core/engine/buffers.rs index 03ee8969..962609ab 100644 --- a/src/core/engine/buffers.rs +++ b/src/core/engine/buffers.rs @@ -2034,6 +2034,161 @@ impl Engine { true } + // ── Inline new file/folder ────────────────────────────────────────────── + + /// Start inline new-file creation in the explorer sidebar. + /// + /// Creates an empty editable entry under `parent_dir`. Backends should + /// render a temporary row in the tree for this entry. + pub fn start_explorer_new_file(&mut self, parent_dir: PathBuf) { + self.explorer_new_entry = Some(ExplorerNewEntryState { + parent_dir, + input: String::new(), + cursor: 0, + is_folder: false, + }); + } + + /// Start inline new-folder creation in the explorer sidebar. + pub fn start_explorer_new_folder(&mut self, parent_dir: PathBuf) { + self.explorer_new_entry = Some(ExplorerNewEntryState { + parent_dir, + input: String::new(), + cursor: 0, + is_folder: true, + }); + } + + /// Handle a key press while inline new-entry creation is active. + /// + /// Returns `true` if the key was consumed. On Enter the file/folder is + /// created; on Escape it is cancelled. Sets `explorer_needs_refresh` + /// on success so backends rebuild the tree. + pub fn handle_explorer_new_entry_key( + &mut self, + key_name: &str, + unicode: Option, + ctrl: bool, + ) -> bool { + let state = match self.explorer_new_entry.as_mut() { + Some(s) => s, + None => return false, + }; + + match key_name { + "Escape" => { + self.explorer_new_entry = None; + return true; + } + "Return" => { + let parent_dir = state.parent_dir.clone(); + let input = state.input.clone(); + let is_folder = state.is_folder; + self.explorer_new_entry = None; + let name = input.trim(); + if name.is_empty() { + // Silent cancel on empty name + return true; + } + let path = parent_dir.join(name); + if path.exists() { + self.message = format!("'{}' already exists", name); + return true; + } + if is_folder { + match std::fs::create_dir_all(&path) { + Ok(()) => { + self.explorer_needs_refresh = true; + self.message = format!("Created folder: {}", name); + } + Err(e) => { + self.message = format!("Error creating folder: {}", e); + } + } + } else { + match std::fs::write(&path, "") { + Ok(()) => { + self.explorer_needs_refresh = true; + self.message = format!("Created: {}", name); + if let Err(e) = self.open_file_with_mode( + &path, + crate::core::engine::OpenMode::Permanent, + ) { + self.message = e; + } + } + Err(e) => { + self.message = format!("Error creating file: {}", e); + } + } + } + return true; + } + "BackSpace" => { + if state.cursor > 0 { + let prev = state.input[..state.cursor] + .char_indices() + .next_back() + .map(|(i, _)| i) + .unwrap_or(0); + state.input.remove(prev); + state.cursor = prev; + } + return true; + } + "Delete" => { + if state.cursor < state.input.len() { + state.input.remove(state.cursor); + } + return true; + } + "Left" => { + if state.cursor > 0 { + state.cursor = state.input[..state.cursor] + .char_indices() + .next_back() + .map(|(i, _)| i) + .unwrap_or(0); + } + return true; + } + "Right" => { + if state.cursor < state.input.len() { + let rest = &state.input[state.cursor..]; + state.cursor = rest + .char_indices() + .nth(1) + .map(|(i, _)| state.cursor + i) + .unwrap_or(state.input.len()); + } + return true; + } + "Home" => { + state.cursor = 0; + return true; + } + "End" => { + state.cursor = state.input.len(); + return true; + } + _ => {} + } + + // Printable character insertion + if !ctrl { + if let Some(ch) = unicode { + if !ch.is_control() { + state.input.insert(state.cursor, ch); + state.cursor += ch.len_utf8(); + return true; + } + } + } + + // Consume all other keys while new-entry is active + true + } + /// Show a confirmation dialog before moving a file/folder. /// /// Stores the pending move and displays a Yes/No dialog. The actual @@ -2064,6 +2219,77 @@ impl Engine { ); } + /// Show a confirmation dialog before deleting a file/folder. + /// + /// Stores the pending delete path and displays a Yes/No dialog. The actual + /// deletion happens when the user confirms via the dialog. + pub fn confirm_delete_file(&mut self, path: &Path) { + let name = path + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_else(|| path.to_string_lossy().to_string()); + let item_type = if path.is_dir() { "folder" } else { "file" }; + self.pending_delete = Some(path.to_path_buf()); + self.show_dialog( + "confirm_delete", + "Confirm Delete", + vec![format!("Delete {} '{}'?", item_type, name)], + vec![ + DialogButton { + label: "Delete".into(), + hotkey: 'd', + action: "delete".into(), + }, + DialogButton { + label: "Cancel".into(), + hotkey: '\0', + action: "cancel".into(), + }, + ], + ); + } + + /// Show a dialog with text input for moving a file to a new location. + /// + /// The dialog pre-fills the input with the file's current relative path. + pub fn start_move_file_dialog(&mut self, src: &Path, project_root: &Path) { + let name = src + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_else(|| src.to_string_lossy().to_string()); + let prefill = src + .strip_prefix(project_root) + .unwrap_or(src) + .to_string_lossy() + .to_string(); + self.pending_move = Some((src.to_path_buf(), PathBuf::new())); // dest filled on confirm + self.show_dialog( + "move_file_input", + &format!("Move '{}'", name), + vec!["Enter destination path:".into()], + vec![ + DialogButton { + label: "Move".into(), + hotkey: '\0', + action: "move".into(), + }, + DialogButton { + label: "Cancel".into(), + hotkey: '\0', + action: "cancel".into(), + }, + ], + ); + // Set up the text input field with pre-filled path + if let Some(ref mut dlg) = self.dialog { + dlg.input = Some(DialogInput { + label: "Destination: ".into(), + value: prefill, + is_password: false, + }); + } + } + /// Move `src` into `dest_dir` (a directory). /// /// The filename is preserved. Any open buffer whose `file_path` matches diff --git a/src/core/engine/execute.rs b/src/core/engine/execute.rs index a649abe3..79e17885 100644 --- a/src/core/engine/execute.rs +++ b/src/core/engine/execute.rs @@ -549,6 +549,12 @@ impl Engine { return EngineAction::None; } + // Handle :Gbranches — open branch picker + if cmd == "Gbranches" || cmd == "GBranches" { + self.open_picker(PickerSource::GitBranches); + return EngineAction::None; + } + // Handle :Plugin list|reload|enable|disable if let Some(subcmd) = cmd.strip_prefix("Plugin").map(|s| s.trim()) { match subcmd { @@ -1209,6 +1215,18 @@ impl Engine { self.message = "Usage: :grep ".to_string(); return EngineAction::None; } + if cmd == "GrepWord" { + let word = self.word_under_cursor().unwrap_or_default(); + if word.is_empty() { + self.message = "No word under cursor".to_string(); + } else { + self.open_picker(PickerSource::Grep); + self.picker_query = word; + self.picker_filter(); + self.picker_load_preview(); + } + return EngineAction::None; + } // Handle :h[elp] [topic] if cmd == "help" { @@ -1341,7 +1359,7 @@ impl Engine { return EngineAction::None; } - // Handle :tabmove [N] — move current tab to position N (0-based) + // Handle :tabmove [N] — move current tab to position N (1-based, 0 = move to end) if cmd == "tabmove" || cmd.starts_with("tabmove ") { let arg = cmd.strip_prefix("tabmove").unwrap_or("").trim(); let num_tabs = self.active_group().tabs.len(); @@ -1349,7 +1367,11 @@ impl Engine { let dest = if arg.is_empty() { num_tabs.saturating_sub(1) // move to end } else if let Ok(n) = arg.parse::() { - n.min(num_tabs.saturating_sub(1)) + if n == 0 { + num_tabs.saturating_sub(1) // 0 also means end + } else { + (n - 1).min(num_tabs.saturating_sub(1)) // 1-based to 0-based + } } else { self.message = "Usage: :tabmove [N]".to_string(); return EngineAction::None; @@ -1358,11 +1380,21 @@ impl Engine { let tab = self.active_group_mut().tabs.remove(current); self.active_group_mut().tabs.insert(dest, tab); self.active_group_mut().active_tab = dest; - self.message = format!("Tab moved to position {}", dest); + self.ensure_active_tab_visible(); + self.message = format!("Tab moved to position {}", dest + 1); } return EngineAction::None; } + if cmd == "navback" { + self.tab_nav_back(); + return EngineAction::None; + } + if cmd == "navforward" { + self.tab_nav_forward(); + return EngineAction::None; + } + // Handle :sav[eas] {file} — save buffer to a new file if let Some(path_str) = cmd.strip_prefix("saveas ") { let path_str = path_str.trim(); @@ -1818,6 +1850,10 @@ impl Engine { self.open_picker(PickerSource::Commands); EngineAction::None } + "CommandCenter" => { + self.open_command_center(); + EngineAction::None + } "undo" => { self.undo(); self.refresh_md_previews(); diff --git a/src/core/engine/ext_panel.rs b/src/core/engine/ext_panel.rs index f6841846..687c1111 100644 --- a/src/core/engine/ext_panel.rs +++ b/src/core/engine/ext_panel.rs @@ -3010,9 +3010,10 @@ impl Engine { if !self.settings.swap_file { return false; } - // Don't overwrite an existing recovery dialog. + // Don't overwrite an existing recovery dialog. Don't create a + // fresh swap either — the stale swap must survive until the user + // dismisses the current dialog and we re-scan. if self.pending_swap_recovery.is_some() { - self.swap_create_for_buffer(buf_id); return false; } let (canonical, file_path) = { @@ -3064,7 +3065,17 @@ impl Engine { ); return false; } - // PID is dead → offer recovery via dialog. + // PID is dead — but does the swap actually differ from the file on disk? + // If the content is identical the buffer was never modified before the + // crash, so silently discard the stale swap instead of bothering the user. + let disk_content = std::fs::read_to_string(&file_path).unwrap_or_default(); + if content == disk_content { + crate::core::swap::delete_swap(&swap_path); + self.swap_create_for_buffer(buf_id); + return false; + } + + // Content differs → offer recovery via dialog. let fname = file_path.file_name().unwrap_or_default().to_string_lossy(); self.pending_swap_recovery = Some(SwapRecovery { swap_path, @@ -3133,6 +3144,13 @@ impl Engine { } _ => {} } + // Check remaining open buffers for more stale swaps. + // Skip when disk saves are suppressed (integration tests) because + // delete_swap/write_swap are no-ops, so the swap file persists on + // disk and would trigger an infinite recovery loop. + if !crate::core::session::saves_suppressed() { + self.swap_recheck_open_buffers(); + } EngineAction::None } } diff --git a/src/core/engine/keys.rs b/src/core/engine/keys.rs index 19221797..c8e99465 100644 --- a/src/core/engine/keys.rs +++ b/src/core/engine/keys.rs @@ -1735,7 +1735,20 @@ impl Engine { } } Some('t') => { - self.next_tab(); + if let Some(n) = self.count { + // Ngt → go to tab N (1-based, like Vim) + // Clamp to last tab if N exceeds tab count. + let total = self.active_group().tabs.len(); + let idx = if n >= total { + total.saturating_sub(1) + } else { + n.saturating_sub(1) + }; + self.goto_tab(idx); + self.count = None; + } else { + self.next_tab(); + } } Some('T') => { self.prev_tab(); @@ -3878,7 +3891,7 @@ impl Engine { partial.push(ch); // All known built-in leader sequences - const SEQUENCES: &[&str] = &["rn", "gf", "gF", "gi", "gb", "ca", "sf", "sg", "sp"]; + const SEQUENCES: &[&str] = &["rn", "gf", "gF", "gi", "gb", "ca", "sf", "sg", "sp", "sw"]; match partial.as_str() { "rn" => { @@ -3914,6 +3927,17 @@ impl Engine { "sp" => { self.open_picker(PickerSource::Commands); } + "sw" => { + // Grep word under cursor + if let Some(word) = self.word_under_cursor() { + self.open_picker(PickerSource::Grep); + self.picker_query = word; + self.picker_filter(); + self.picker_load_preview(); + } else { + self.open_picker(PickerSource::Grep); + } + } s => { // Check if this is a complete plugin keymap match let leader_key = format!("{s}"); diff --git a/src/core/engine/mod.rs b/src/core/engine/mod.rs index 9f0efadb..0a2dd506 100644 --- a/src/core/engine/mod.rs +++ b/src/core/engine/mod.rs @@ -381,12 +381,24 @@ pub static PALETTE_COMMANDS: &[PaletteCommand] = &[ vscode_shortcut: "", action: "fuzzy", }, + PaletteCommand { + label: "Go: Command Center", + shortcut: "", + vscode_shortcut: "", + action: "CommandCenter", + }, PaletteCommand { label: "Go: Live Grep", shortcut: "Ctrl+G", vscode_shortcut: "Ctrl+Shift+F", action: "grep", }, + PaletteCommand { + label: "Search: Word Under Cursor", + shortcut: "sw", + vscode_shortcut: "sw", + action: "GrepWord", + }, PaletteCommand { label: "Go: Go to Line", shortcut: "", @@ -417,6 +429,18 @@ pub static PALETTE_COMMANDS: &[PaletteCommand] = &[ vscode_shortcut: "Alt+Left", action: "jump_back", }, + PaletteCommand { + label: "Go: Navigate Back", + shortcut: "Ctrl+Alt+Left", + vscode_shortcut: "", + action: "navback", + }, + PaletteCommand { + label: "Go: Navigate Forward", + shortcut: "Ctrl+Alt+Right", + vscode_shortcut: "", + action: "navforward", + }, // Run / Debug PaletteCommand { label: "Debug: Start / Continue", @@ -502,7 +526,7 @@ pub static PALETTE_COMMANDS: &[PaletteCommand] = &[ label: "Git: Switch Branch", shortcut: "", vscode_shortcut: "", - action: "Gswitch", + action: "Gbranches", }, PaletteCommand { label: "Git: Create Branch", @@ -710,6 +734,8 @@ pub enum PickerSource { Marks, Registers, GitBranches, + /// Command Center: dynamic prefix routing (>, @, #, :, ?). + CommandCenter, Custom(String), } @@ -743,6 +769,10 @@ pub enum PickerAction { JumpToMark(char), PasteRegister(char), CheckoutBranch(String), + /// Go to a specific line number in the active buffer. + GotoLine(usize), + /// Jump to a symbol location (file, line, col). + GotoSymbol(PathBuf, usize, usize), Custom(String), } @@ -1073,6 +1103,19 @@ pub struct ExplorerRenameState { pub cursor: usize, } +/// State for inline new-file/folder creation in the explorer sidebar. +#[derive(Debug, Clone)] +pub struct ExplorerNewEntryState { + /// The directory under which the new entry will be created. + pub parent_dir: PathBuf, + /// Current text input (the new name). + pub input: String, + /// Byte-offset cursor position within `input`. + pub cursor: usize, + /// Whether creating a folder (true) or a file (false). + pub is_folder: bool, +} + /// One panel in a VSCode-style editor-group split. /// Each group owns its own tab bar and independent tab navigation. /// Single-group mode (the default) is identical to the previous behaviour. @@ -1080,6 +1123,12 @@ pub struct ExplorerRenameState { pub struct EditorGroup { pub tabs: Vec, pub active_tab: usize, + /// Index of the first visible tab in the tab bar (for scroll-into-view). + /// Updated by `ensure_active_tab_visible()` whenever the active tab changes. + pub tab_scroll_offset: usize, + /// Number of tabs that fit in the rendered tab bar. Set by the renderer + /// each frame via `Engine::set_tab_visible_count()`. Default 6. + pub tab_visible_count: usize, } impl EditorGroup { @@ -1087,6 +1136,8 @@ impl EditorGroup { Self { tabs: vec![initial_tab], active_tab: 0, + tab_scroll_offset: 0, + tab_visible_count: 6, } } @@ -1889,6 +1940,10 @@ pub struct Engine { lsp_dirty_buffers: HashMap, /// Request ID of the pending code action request. pub lsp_pending_code_action: Option, + /// Pending document symbol request ID. + pub lsp_pending_document_symbols: Option, + /// Pending workspace symbol request ID. + pub lsp_pending_workspace_symbols: Option, /// The (path, line) for which the pending code action request was made. lsp_code_action_request_ctx: Option<(PathBuf, usize)>, /// Cached code actions per file path and line number. @@ -1997,6 +2052,14 @@ pub struct Engine { /// Most recently used is at index 0. pub tab_mru: Vec<(GroupId, usize)>, + /// Back/forward tab navigation history. + /// Each entry is (GroupId, TabId) at the time of the switch. + pub tab_nav_history: Vec<(GroupId, TabId)>, + /// Current position in `tab_nav_history` (index of the entry we're viewing). + pub tab_nav_index: usize, + /// True when navigating via back/forward (suppresses pushing to history). + tab_nav_navigating: bool, + // --- Quickfix state --- /// Quickfix list populated by :grep / :vimgrep. pub quickfix_items: Vec, @@ -2464,6 +2527,8 @@ pub struct Engine { pub diff_selected_file: Option, /// Pending move awaiting user confirmation: (source_path, dest_dir). pub pending_move: Option<(PathBuf, PathBuf)>, + /// Pending delete awaiting user confirmation. + pub pending_delete: Option, /// Set to true when a file move completes; backends should refresh the explorer tree /// and clear this flag. pub explorer_needs_refresh: bool, @@ -2472,6 +2537,10 @@ pub struct Engine { /// sidebar row matching `path` should render an editable text input /// instead of the plain filename. pub explorer_rename: Option, + + /// Inline new-file/folder state for the explorer sidebar. When `Some`, + /// a temporary editable row is inserted in the tree under `parent_dir`. + pub explorer_new_entry: Option, } impl Engine { @@ -2595,6 +2664,8 @@ impl Engine { lsp_signature_help: None, lsp_dirty_buffers: HashMap::new(), lsp_pending_code_action: None, + lsp_pending_document_symbols: None, + lsp_pending_workspace_symbols: None, lsp_code_action_request_ctx: None, lsp_code_actions: HashMap::new(), lsp_code_action_last_line: None, @@ -2638,6 +2709,9 @@ impl Engine { tab_switcher_open: false, tab_switcher_selected: 0, tab_mru: vec![(GroupId(0), 0)], + tab_nav_history: vec![(GroupId(0), TabId(1))], + tab_nav_index: 0, + tab_nav_navigating: false, quickfix_items: Vec::new(), quickfix_selected: 0, quickfix_open: false, @@ -2815,8 +2889,10 @@ impl Engine { context_menu: None, diff_selected_file: None, pending_move: None, + pending_delete: None, explorer_needs_refresh: false, explorer_rename: None, + explorer_new_entry: None, }; // If vscode mode is configured, start in Insert mode with menu visible if engine.is_vscode_mode() { diff --git a/src/core/engine/motions.rs b/src/core/engine/motions.rs index 4a5dda94..eb9906a9 100644 --- a/src/core/engine/motions.rs +++ b/src/core/engine/motions.rs @@ -2788,6 +2788,9 @@ impl Engine { let line = self.view().cursor.line; let col = self.view().cursor.col; let chars: Vec = self.buffer().content.line(line).chars().collect(); + // Clamp col to valid range — cursor can be past end after edits or + // on lines shorter than expected (e.g. trailing newline excluded). + let col = col.min(chars.len()); let mut start = col; while start > 0 && Self::is_word_char(chars[start - 1]) { start -= 1; diff --git a/src/core/engine/panels.rs b/src/core/engine/panels.rs index d4f47518..6264929d 100644 --- a/src/core/engine/panels.rs +++ b/src/core/engine/panels.rs @@ -187,6 +187,84 @@ impl Engine { } EngineAction::None } + "confirm_delete" => { + if action == "delete" { + if let Some(path) = self.pending_delete.take() { + let name = path + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_default(); + let is_dir = path.is_dir(); + let item_type = if is_dir { "folder" } else { "file" }; + if !path.exists() { + self.message = format!("'{}' does not exist", name); + } else { + let result = if is_dir { + std::fs::remove_dir_all(&path) + } else { + std::fs::remove_file(&path) + }; + match result { + Ok(()) => { + self.message = format!("Deleted {}: '{}'", item_type, name); + // If deleted file was open, close its buffer + if !is_dir { + let path_str = path.to_string_lossy(); + if let Some(buffer_id) = + self.buffer_manager.find_by_path(&path_str) + { + let _ = self.delete_buffer(buffer_id, true); + } + } + self.explorer_needs_refresh = true; + } + Err(e) => { + self.message = format!("Error deleting: {}", e); + } + } + } + } + } else { + self.pending_delete = None; + } + EngineAction::None + } + "move_file_input" => { + if action == "move" { + if let Some((src, _)) = self.pending_move.take() { + let dest_str = input_value.unwrap_or("").trim(); + if !dest_str.is_empty() { + let dest = if std::path::Path::new(dest_str).is_absolute() { + PathBuf::from(dest_str) + } else { + self.cwd.join(dest_str) + }; + match self.move_file(&src, &dest) { + Ok(()) => { + let name = src + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_default(); + let final_dest = if dest.is_dir() { + dest.join(src.file_name().unwrap_or_default()) + } else { + dest.clone() + }; + self.message = + format!("Moved '{}' to '{}'", name, final_dest.display()); + self.explorer_needs_refresh = true; + } + Err(e) => { + self.message = e; + } + } + } + } + } else { + self.pending_move = None; + } + EngineAction::None + } "ext_remove" => { if let Some(name) = self.pending_ext_remove.take() { match action { @@ -311,10 +389,28 @@ impl Engine { } } + /// Emergency flush: write swap files for ALL dirty buffers immediately. + /// Called from panic handlers to preserve unsaved work before crashing. + /// Bypasses the `updatetime` debounce and `swap_write_needed` set. + pub fn emergency_swap_flush(&self) { + for buf_id in self.buffer_manager.list() { + let is_dirty = self + .buffer_manager + .get(buf_id) + .map(|s| s.dirty) + .unwrap_or(false); + if is_dirty { + self.swap_create_for_buffer(buf_id); + } + } + } + /// Check all open buffers for stale swap files. /// Called after session restore to catch any crashed sessions. - /// Only the first stale swap triggers a recovery dialog — the rest - /// get fresh swap files created silently. + /// Check all open buffers for stale swap files. + /// Called after session restore and after each swap recovery dialog. + /// Only the first stale swap triggers a recovery dialog — remaining + /// buffers are left alone so the next dialog dismissal re-scans them. pub fn swap_check_all_buffers(&mut self) { if !self.settings.swap_file { return; @@ -322,17 +418,32 @@ impl Engine { let buf_ids = self.buffer_manager.list(); for buf_id in buf_ids { if self.pending_swap_recovery.is_some() { - // Already showing a recovery dialog — just create swaps for the rest. - self.swap_create_for_buffer(buf_id); - } else { - self.swap_check_on_open(buf_id); + // Already showing a recovery dialog — don't touch remaining + // buffers so their stale swaps survive for the next re-scan. + break; } + self.swap_check_on_open(buf_id); } // Also scan the swap directory for orphaned swaps (files that // aren't in the restored session). self.swap_scan_stale(); } + /// Re-check open buffers for stale swaps after a recovery dialog is dismissed. + /// Unlike `swap_check_all_buffers`, this does NOT scan for orphaned swaps. + pub(crate) fn swap_recheck_open_buffers(&mut self) { + if !self.settings.swap_file { + return; + } + let buf_ids = self.buffer_manager.list(); + for buf_id in buf_ids { + if self.pending_swap_recovery.is_some() { + break; + } + self.swap_check_on_open(buf_id); + } + } + /// Scan the swap directory for stale swap files with dead PIDs that /// don't correspond to any currently-open buffer. Opens the first /// orphaned file in a new tab and offers recovery. @@ -1021,6 +1132,32 @@ impl Engine { } } } + LspEvent::DocumentSymbolResponse { + request_id, + symbols, + .. + } => { + if self.lsp_pending_document_symbols == Some(request_id) { + self.lsp_pending_document_symbols = None; + if self.picker_open && self.picker_source == PickerSource::CommandCenter { + self.picker_populate_document_symbols(symbols); + redraw = true; + } + } + } + LspEvent::WorkspaceSymbolResponse { + request_id, + symbols, + .. + } => { + if self.lsp_pending_workspace_symbols == Some(request_id) { + self.lsp_pending_workspace_symbols = None; + if self.picker_open && self.picker_source == PickerSource::CommandCenter { + self.picker_populate_workspace_symbols(symbols); + redraw = true; + } + } + } } } redraw diff --git a/src/core/engine/picker.rs b/src/core/engine/picker.rs index b2a833ea..b59d20e0 100644 --- a/src/core/engine/picker.rs +++ b/src/core/engine/picker.rs @@ -99,6 +99,15 @@ impl Engine { self.picker_title = "Live Grep".to_string(); // Grep is a live source — no pre-populate, search runs per keystroke. } + PickerSource::CommandCenter => { + self.picker_title = "Search".to_string(); + // Default mode: files. Prefix routing handled in picker_filter_command_center. + self.picker_populate_files(); + } + PickerSource::GitBranches => { + self.picker_title = "Switch Branch".to_string(); + self.picker_populate_branches(); + } _ => { self.picker_title = format!("{:?}", source); } @@ -110,6 +119,11 @@ impl Engine { self.picker_open = true; } + /// Open the Command Center picker (called from menu bar search box click). + pub fn open_command_center(&mut self) { + self.open_picker(PickerSource::CommandCenter); + } + /// Close the unified picker and clear all state. pub fn close_picker(&mut self) { self.picker_open = false; @@ -185,8 +199,43 @@ impl Engine { .collect(); } + /// Populate picker_all_items with git branches. + fn picker_populate_branches(&mut self) { + let branches = crate::core::git::list_branches(&self.cwd); + self.picker_all_items = branches + .into_iter() + .map(|b| { + let mut detail_parts = Vec::new(); + if b.is_current { + detail_parts.push("● current".to_string()); + } + if let Some(ref ab) = b.ahead_behind { + detail_parts.push(ab.clone()); + } + if let Some(ref up) = b.upstream { + detail_parts.push(format!("→ {}", up)); + } + let detail = if detail_parts.is_empty() { + None + } else { + Some(detail_parts.join(" ")) + }; + PickerItem { + display: b.name.clone(), + filter_text: b.name.clone(), + detail, + action: PickerAction::CheckoutBranch(b.name), + icon: None, + score: 0, + match_positions: Vec::new(), + } + }) + .collect(); + } + /// Filter picker_all_items by the current query and populate picker_items. /// For live sources (Grep), runs a search instead of fuzzy-filtering. + /// For CommandCenter, delegates to prefix-aware routing. pub(crate) fn picker_filter(&mut self) { const CAP: usize = 100; @@ -196,15 +245,34 @@ impl Engine { return; } - if self.picker_query.is_empty() { - self.picker_items = self.picker_all_items.iter().take(CAP).cloned().collect(); + // Command Center: dynamic prefix routing. + if self.picker_source == PickerSource::CommandCenter { + self.picker_filter_command_center(); + return; + } + + Self::fuzzy_filter_items( + &self.picker_all_items, + &self.picker_query, + CAP, + &mut self.picker_items, + ); + } + + /// Shared fuzzy filter: score `all_items` against `query`, populate `out`. + fn fuzzy_filter_items( + all_items: &[PickerItem], + query: &str, + cap: usize, + out: &mut Vec, + ) { + if query.is_empty() { + *out = all_items.iter().take(cap).cloned().collect(); } else { - let query = self.picker_query.clone(); - let mut scored: Vec = self - .picker_all_items + let mut scored: Vec = all_items .iter() .filter_map(|item| { - Self::fuzzy_score_with_positions(&item.filter_text, &query).map( + Self::fuzzy_score_with_positions(&item.filter_text, query).map( |(s, positions)| { let mut item = item.clone(); item.score = s; @@ -215,20 +283,375 @@ impl Engine { }) .collect(); scored.sort_by(|a, b| b.score.cmp(&a.score)); - scored.truncate(CAP); - self.picker_items = scored; + scored.truncate(cap); + *out = scored; + } + } + + /// Detect the prefix in `picker_query` and route to the appropriate mode. + fn picker_filter_command_center(&mut self) { + const CAP: usize = 100; + let query = self.picker_query.clone(); + + if let Some(rest) = query.strip_prefix('>') { + // Command palette mode + self.picker_title = "Commands".to_string(); + // Re-populate commands if all_items aren't command items + if self.picker_all_items.is_empty() + || !matches!( + self.picker_all_items.first().map(|i| &i.action), + Some(PickerAction::ExecuteCommand(_)) + ) + { + self.picker_populate_commands(); + } + let sub_query = rest.trim_start().to_string(); + Self::fuzzy_filter_items( + &self.picker_all_items, + &sub_query, + CAP, + &mut self.picker_items, + ); + } else if let Some(rest) = query.strip_prefix('@') { + // Document symbols mode (LSP) + self.picker_title = "Go to Symbol in File".to_string(); + let sub_query = rest.trim_start().to_string(); + // Clear file/command items and request symbols if we haven't already + let has_symbol_items = matches!( + self.picker_all_items.first().map(|i| &i.action), + Some(PickerAction::GotoSymbol(..)) + ); + if !has_symbol_items { + self.picker_all_items.clear(); + } + if self.lsp_pending_document_symbols.is_none() && self.picker_all_items.is_empty() { + self.picker_request_document_symbols(); + } + // If items are populated (from LSP response), filter them + Self::fuzzy_filter_items( + &self.picker_all_items, + &sub_query, + CAP, + &mut self.picker_items, + ); + if self.picker_items.is_empty() && self.lsp_pending_document_symbols.is_some() { + self.picker_items = vec![PickerItem { + display: "Loading symbols...".to_string(), + filter_text: String::new(), + detail: None, + action: PickerAction::Custom("loading".to_string()), + icon: None, + score: 0, + match_positions: Vec::new(), + }]; + } + } else if let Some(rest) = query.strip_prefix('#') { + // Workspace symbols mode (LSP) + self.picker_title = "Go to Symbol in Workspace".to_string(); + let sub_query = rest.trim_start().to_string(); + if sub_query.len() >= 2 { + self.picker_request_workspace_symbols(&sub_query); + } else if sub_query.is_empty() { + self.picker_items = vec![PickerItem { + display: "Type at least 2 characters to search workspace symbols..." + .to_string(), + filter_text: String::new(), + detail: None, + action: PickerAction::Custom("hint".to_string()), + icon: None, + score: 0, + match_positions: Vec::new(), + }]; + } + } else if let Some(rest) = query.strip_prefix(':') { + // Go to line mode + self.picker_title = "Go to Line".to_string(); + let trimmed = rest.trim(); + if let Ok(line_num) = trimmed.parse::() { + let line_count = self.buffer().content.len_lines(); + let clamped = line_num.clamp(1, line_count); + self.picker_items = vec![PickerItem { + display: format!("Go to line {}", clamped), + filter_text: String::new(), + detail: Some(format!("of {}", line_count)), + action: PickerAction::GotoLine(clamped.saturating_sub(1)), + icon: None, + score: 0, + match_positions: Vec::new(), + }]; + } else { + self.picker_items = vec![PickerItem { + display: "Type a line number...".to_string(), + filter_text: String::new(), + detail: Some(format!("1–{}", self.buffer().content.len_lines())), + action: PickerAction::Custom("hint".to_string()), + icon: None, + score: 0, + match_positions: Vec::new(), + }]; + } + } else if let Some(rest) = query.strip_prefix('%') { + // Live grep mode (search for text in project) + self.picker_title = "Search for Text".to_string(); + let sub_query = rest.trim_start().to_string(); + if sub_query.len() < 2 { + self.picker_items = vec![PickerItem { + display: "Type at least 2 characters to search project...".to_string(), + filter_text: String::new(), + detail: None, + action: PickerAction::Custom("hint".to_string()), + icon: None, + score: 0, + match_positions: Vec::new(), + }]; + } else { + // Reuse the grep search logic with sub_query + self.picker_cc_grep_search(&sub_query); + } + } else if query == "debug" || query.starts_with("debug ") { + // Start Debugging mode — show launch configurations + self.picker_title = "Start Debugging".to_string(); + let sub_query = query + .strip_prefix("debug") + .unwrap_or("") + .trim_start() + .to_string(); + self.picker_populate_debug_configs(&sub_query); + } else if query == "task" || query.starts_with("task ") { + // Run Task mode — show tasks from tasks.json + self.picker_title = "Run Task".to_string(); + let sub_query = query + .strip_prefix("task") + .unwrap_or("") + .trim_start() + .to_string(); + self.picker_populate_tasks(&sub_query); + } else if query == "?" { + // Help mode: show available prefixes + self.picker_title = "Help: Prefix Modes".to_string(); + self.picker_items = vec![ + Self::help_item("", "Search files by name (default)"), + Self::help_item(">", "Show and run commands"), + Self::help_item("@", "Go to symbol in current file (LSP)"), + Self::help_item("#", "Go to symbol in workspace (LSP)"), + Self::help_item(":", "Go to line number"), + Self::help_item("%", "Search for text in project"), + Self::help_item("debug", "Start debugging (launch configurations)"), + Self::help_item("task", "Run a task (from tasks.json)"), + Self::help_item("?", "Show this help"), + ]; + } else if query.is_empty() { + // Placeholder hints: show available modes when query is empty + self.picker_title = "Search".to_string(); + self.picker_items = vec![ + Self::hint_item("Go to File", "", "Type a file name"), + Self::hint_item("Show and Run Commands", ">", "Ctrl+Shift+P"), + Self::hint_item("Go to Symbol in Editor", "@", ""), + Self::hint_item("Go to Symbol in Workspace", "#", ""), + Self::hint_item("Go to Line", ":", "Ctrl+G"), + Self::hint_item("Search for Text", "%", "Ctrl+G (grep)"), + Self::hint_item("Start Debugging", "debug", "F5"), + Self::hint_item("Run Task", "task", ""), + Self::hint_item("More Help", "?", ""), + ]; + } else { + // Default: file search + self.picker_title = "Search".to_string(); + // Re-populate files if all_items aren't file items + if self.picker_all_items.is_empty() + || matches!( + self.picker_all_items.first().map(|i| &i.action), + Some(PickerAction::ExecuteCommand(_)) + ) + { + self.picker_populate_files(); + } + Self::fuzzy_filter_items(&self.picker_all_items, &query, CAP, &mut self.picker_items); + } + } + + /// Create a placeholder hint item for the empty-query Command Center dropdown. + /// `label` is the mode name, `prefix` is the prefix to set, `shortcut` is the keyboard shortcut hint. + fn hint_item(label: &str, prefix: &str, shortcut: &str) -> PickerItem { + let action_prefix = if prefix.is_empty() { + // "Go to File" — just clear the query (stay in file mode) + String::new() + } else { + prefix.to_string() + }; + PickerItem { + display: label.to_string(), + filter_text: String::new(), + detail: if shortcut.is_empty() { + Some(prefix.to_string()) + } else { + Some(format!("{} {}", prefix, shortcut)) + }, + action: PickerAction::Custom(format!("prefix:{}", action_prefix)), + icon: None, + score: 0, + match_positions: Vec::new(), + } + } + + /// Create a help item for the "?" prefix mode. + fn help_item(prefix: &str, description: &str) -> PickerItem { + PickerItem { + display: if prefix.is_empty() { + "(no prefix)".to_string() + } else { + prefix.to_string() + }, + filter_text: String::new(), + detail: Some(description.to_string()), + action: PickerAction::Custom(format!("prefix:{}", prefix)), + icon: None, + score: 0, + match_positions: Vec::new(), + } + } + + /// Request document symbols from LSP for the current buffer. + fn picker_request_document_symbols(&mut self) { + if !self.settings.lsp_enabled { + return; + } + self.ensure_lsp_manager(); + let path = match self.active_buffer_path() { + Some(p) => p, + None => return, + }; + if let Some(mgr) = &mut self.lsp_manager { + if let Some(id) = mgr.request_document_symbols(&path) { + self.lsp_pending_document_symbols = Some(id); + } } } + /// Populate picker items from a document symbol LSP response. + pub(crate) fn picker_populate_document_symbols(&mut self, symbols: Vec) { + let path = self.active_buffer_path(); + self.picker_all_items = symbols + .into_iter() + .map(|sym| { + let container_str = sym + .container + .as_ref() + .map(|c| format!(" ({})", c)) + .unwrap_or_default(); + let display = format!("{} {}{}", sym.kind.icon(), sym.name, container_str); + let detail = Some(sym.kind.label().to_string()); + let action = PickerAction::GotoSymbol( + path.clone().unwrap_or_default(), + sym.line as usize, + sym.character as usize, + ); + PickerItem { + filter_text: sym.name.clone(), + display, + detail, + action, + icon: None, + score: 0, + match_positions: Vec::new(), + } + }) + .collect(); + // Re-run filter with current query + let sub_query = self + .picker_query + .strip_prefix('@') + .unwrap_or("") + .trim_start() + .to_string(); + Self::fuzzy_filter_items( + &self.picker_all_items, + &sub_query, + 100, + &mut self.picker_items, + ); + self.picker_selected = 0; + self.picker_scroll_top = 0; + self.picker_load_preview(); + } + + /// Request workspace symbols from LSP. + fn picker_request_workspace_symbols(&mut self, query: &str) { + if !self.settings.lsp_enabled { + return; + } + self.ensure_lsp_manager(); + let path = match self.active_buffer_path() { + Some(p) => p, + None => return, + }; + if let Some(mgr) = &mut self.lsp_manager { + if let Some(id) = mgr.request_workspace_symbols(&path, query) { + self.lsp_pending_workspace_symbols = Some(id); + } + } + } + + /// Populate picker items from a workspace symbol LSP response. + pub(crate) fn picker_populate_workspace_symbols(&mut self, symbols: Vec) { + let cwd = self.cwd.clone(); + self.picker_items = symbols + .into_iter() + .take(100) + .map(|sym| { + let file_hint = sym + .path + .as_ref() + .and_then(|p| p.strip_prefix(&cwd).ok()) + .map(|r| r.to_string_lossy().into_owned()) + .unwrap_or_default(); + let container_str = sym + .container + .as_ref() + .map(|c| format!(" ({})", c)) + .unwrap_or_default(); + let display = format!("{} {}{}", sym.kind.icon(), sym.name, container_str); + let detail = if file_hint.is_empty() { + Some(sym.kind.label().to_string()) + } else { + Some(format!("{} · {}", sym.kind.label(), file_hint)) + }; + let action = PickerAction::GotoSymbol( + sym.path.unwrap_or_default(), + sym.line as usize, + sym.character as usize, + ); + PickerItem { + filter_text: sym.name.clone(), + display, + detail, + action, + icon: None, + score: 0, + match_positions: Vec::new(), + } + }) + .collect(); + self.picker_selected = 0; + self.picker_scroll_top = 0; + self.picker_load_preview(); + } + /// Run a live project search for the Grep picker source. fn picker_grep_search(&mut self) { if self.picker_query.len() < 2 { self.picker_items.clear(); return; } + self.picker_cc_grep_search(&self.picker_query.clone()); + } + + /// Run a project grep search with a given query string and populate picker_items. + /// Shared between the standalone Grep picker source and Command Center `%` prefix. + fn picker_cc_grep_search(&mut self, query: &str) { let options = project_search::SearchOptions::default(); let cwd = self.cwd.clone(); - match project_search::search_in_project(&cwd, &self.picker_query, &options) { + match project_search::search_in_project(&cwd, query, &options) { Ok(mut results) => { results.truncate(200); self.picker_items = results @@ -257,6 +680,159 @@ impl Engine { } } + /// Populate picker items with launch configurations from `.vimcode/launch.json`. + /// If no launch.json exists, offers a "Create launch.json..." option. + fn picker_populate_debug_configs(&mut self, filter_query: &str) { + use crate::core::dap_manager::{find_workspace_root, parse_launch_json}; + + let manifests = self.ext_available_manifests(); + let workspace_root = find_workspace_root(&self.cwd, &manifests); + let cwd_str = workspace_root.to_string_lossy().into_owned(); + + // Try .vimcode/launch.json first, then .vscode/launch.json + let vimcode_path = workspace_root.join(".vimcode").join("launch.json"); + let vscode_path = workspace_root.join(".vscode").join("launch.json"); + + let configs = if let Ok(content) = std::fs::read_to_string(&vimcode_path) { + parse_launch_json(&content, &cwd_str) + } else if let Ok(content) = std::fs::read_to_string(&vscode_path) { + parse_launch_json(&content, &cwd_str) + } else { + Vec::new() + }; + + if configs.is_empty() { + self.picker_items = vec![PickerItem { + display: "Create launch.json...".to_string(), + filter_text: "create launch.json".to_string(), + detail: Some("No launch configurations found".to_string()), + action: PickerAction::Custom("create_launch_json".to_string()), + icon: None, + score: 0, + match_positions: Vec::new(), + }]; + return; + } + + let all_items: Vec = configs + .iter() + .enumerate() + .map(|(idx, cfg)| { + let detail = if cfg.program.is_empty() { + cfg.adapter_type.clone() + } else { + format!("{} — {}", cfg.adapter_type, cfg.program) + }; + PickerItem { + display: cfg.name.clone(), + filter_text: format!("{} {} {}", cfg.name, cfg.adapter_type, cfg.program), + detail: Some(detail), + action: PickerAction::Custom(format!("debug_config:{}", idx)), + icon: None, + score: 0, + match_positions: Vec::new(), + } + }) + .collect(); + + if filter_query.is_empty() { + self.picker_items = all_items; + } else { + Self::fuzzy_filter_items(&all_items, filter_query, 100, &mut self.picker_items); + } + } + + /// Populate picker items with tasks from `.vimcode/tasks.json`. + /// If no tasks.json exists, offers a "Configure Tasks..." option. + fn picker_populate_tasks(&mut self, filter_query: &str) { + use crate::core::dap_manager::{ + find_workspace_root, parse_tasks_json, task_to_shell_command, + }; + + let manifests = self.ext_available_manifests(); + let workspace_root = find_workspace_root(&self.cwd, &manifests); + let cwd_str = workspace_root.to_string_lossy().into_owned(); + + // Try .vimcode/tasks.json first, then .vscode/tasks.json + let vimcode_path = workspace_root.join(".vimcode").join("tasks.json"); + let vscode_path = workspace_root.join(".vscode").join("tasks.json"); + + let tasks = if let Ok(content) = std::fs::read_to_string(&vimcode_path) { + parse_tasks_json(&content, &cwd_str) + } else if let Ok(content) = std::fs::read_to_string(&vscode_path) { + parse_tasks_json(&content, &cwd_str) + } else { + Vec::new() + }; + + if tasks.is_empty() { + self.picker_items = vec![PickerItem { + display: "Configure Tasks...".to_string(), + filter_text: "configure tasks".to_string(), + detail: Some("No tasks found — create tasks.json".to_string()), + action: PickerAction::Custom("create_tasks_json".to_string()), + icon: None, + score: 0, + match_positions: Vec::new(), + }]; + return; + } + + let all_items: Vec = tasks + .iter() + .map(|task| { + let cmd = task_to_shell_command(task); + PickerItem { + display: task.label.clone(), + filter_text: format!("{} {}", task.label, cmd), + detail: Some(cmd.clone()), + action: PickerAction::Custom(format!("task_run:{}", cmd)), + icon: None, + score: 0, + match_positions: Vec::new(), + } + }) + .collect(); + + if filter_query.is_empty() { + self.picker_items = all_items; + } else { + Self::fuzzy_filter_items(&all_items, filter_query, 100, &mut self.picker_items); + } + } + + /// Create a default tasks.json in `.vimcode/` and open it in a buffer. + fn create_and_open_tasks_json(&mut self) { + use crate::core::dap_manager::find_workspace_root; + + let manifests = self.ext_available_manifests(); + let workspace_root = find_workspace_root(&self.cwd, &manifests); + let vimcode_dir = workspace_root.join(".vimcode"); + let tasks_path = vimcode_dir.join("tasks.json"); + + if !tasks_path.exists() { + let _ = std::fs::create_dir_all(&vimcode_dir); + let template = r#"{ + "version": "2.0.0", + "tasks": [ + { + "label": "build", + "type": "shell", + "command": "cargo build" + }, + { + "label": "test", + "type": "shell", + "command": "cargo test" + } + ] +}"#; + let _ = std::fs::write(&tasks_path, template); + } + + self.open_file_in_tab(&tasks_path); + } + /// Load preview context for the currently selected picker item. pub(crate) fn picker_load_preview(&mut self) { self.picker_preview = None; @@ -414,7 +990,7 @@ impl Engine { } } PickerAction::CheckoutBranch(branch) => { - self.execute_command(&format!("Gcheckout {}", branch)) + self.execute_command(&format!("Gswitch {}", branch)) } PickerAction::JumpToMark(_mark) => { // Phase 3: mark jumping via picker @@ -424,10 +1000,79 @@ impl Engine { // Phase 3: register paste via picker EngineAction::None } - PickerAction::Custom(_key) => { - // Future: fire Lua event + PickerAction::GotoLine(line) => { + let win_id = self.active_window_id(); + self.set_cursor_for_window(win_id, line, 0); + self.ensure_cursor_visible(); EngineAction::None } + PickerAction::GotoSymbol(path, line, _col) => { + if !path.as_os_str().is_empty() { + // Check if it's a different file than the current buffer + let cur_path = self + .buffer_manager + .get(self.active_buffer_id()) + .and_then(|s| s.file_path.clone()) + .unwrap_or_default(); + if path != cur_path { + self.open_file_in_tab(&path); + } + } + let win_id = self.active_window_id(); + self.set_cursor_for_window(win_id, line, 0); + self.ensure_cursor_visible(); + EngineAction::None + } + PickerAction::Custom(key) => { + // Handle prefix selection from help mode + if let Some(prefix) = key.strip_prefix("prefix:") { + self.open_command_center(); + if prefix.is_empty() { + // "Go to File" — stay in file search mode with empty query + // (open_command_center already set up files + hints) + // Force file mode by setting a no-op query state + self.picker_populate_files(); + self.picker_items = + self.picker_all_items.iter().take(100).cloned().collect(); + } else { + self.picker_query = if prefix.contains(char::is_alphabetic) { + format!("{} ", prefix) + } else { + prefix.to_string() + }; + self.picker_selected = 0; + self.picker_scroll_top = 0; + self.picker_filter(); + self.picker_load_preview(); + } + EngineAction::None + } else if let Some(idx_str) = key.strip_prefix("debug_config:") { + // Launch a debug configuration by index + if let Ok(idx) = idx_str.parse::() { + self.dap_selected_launch_config = idx; + self.close_picker(); + let _ = self.execute_command("debug"); + } + EngineAction::None + } else if key == "create_launch_json" { + // Generate launch.json and start debugging + self.close_picker(); + let _ = self.execute_command("debug"); + EngineAction::None + } else if let Some(cmd) = key.strip_prefix("task_run:") { + // Run a task command in the integrated terminal + let cmd = cmd.to_string(); + self.close_picker(); + EngineAction::RunInTerminal(cmd) + } else if key == "create_tasks_json" { + // Open .vimcode/tasks.json for editing + self.close_picker(); + self.create_and_open_tasks_json(); + EngineAction::None + } else { + EngineAction::None + } + } } } diff --git a/src/core/engine/source_control.rs b/src/core/engine/source_control.rs index 6a8e72ce..02898201 100644 --- a/src/core/engine/source_control.rs +++ b/src/core/engine/source_control.rs @@ -523,12 +523,21 @@ impl Engine { self.sc_commit_input_active = true; true } + "C" => { + // Commit immediately if there's a message, otherwise enter input mode. + if self.sc_commit_message.trim().is_empty() { + self.sc_commit_input_active = true; + } else { + self.sc_do_commit(); + } + true + } "p" => { - self.sc_push(); + self.sc_pull(); true } "P" => { - self.sc_pull(); + self.sc_push(); true } "f" => { diff --git a/src/core/engine/tests.rs b/src/core/engine/tests.rs index 42c599aa..a6e2c80c 100644 --- a/src/core/engine/tests.rs +++ b/src/core/engine/tests.rs @@ -2247,6 +2247,73 @@ fn test_visual_line_yank() { assert!(is_linewise); } +#[test] +fn test_visual_line_yank_cursor_moves_to_start() { + let mut engine = Engine::new(); + engine.buffer_mut().insert(0, "line1\nline2\nline3\nline4"); + engine.update_syntax(); + + // Move to line 2, start visual line, select down to line 3 + press_char(&mut engine, 'j'); + press_char(&mut engine, 'j'); + press_char(&mut engine, 'V'); + press_char(&mut engine, 'j'); + // Cursor is now on line 3, selection spans lines 2-3 + + press_char(&mut engine, 'y'); + + // Vim behavior: cursor moves to start of selection (line 2, col 0) + assert_eq!(engine.view().cursor.line, 2); + assert_eq!(engine.view().cursor.col, 0); + // Verify register content + let (content, is_linewise) = engine.registers.get(&'"').unwrap(); + assert_eq!(content, "line3\nline4\n"); + assert!(is_linewise); +} + +#[test] +fn test_visual_charwise_yank_cursor_moves_to_start() { + let mut engine = Engine::new(); + engine.buffer_mut().insert(0, "hello world"); + engine.update_syntax(); + + // Move to col 6 ("w"), start visual, select to col 10 ("d") + for _ in 0..6 { + press_char(&mut engine, 'l'); + } + press_char(&mut engine, 'v'); + for _ in 0..4 { + press_char(&mut engine, 'l'); + } + // Cursor at col 10, selection from 6..10 + + press_char(&mut engine, 'y'); + + // Vim: cursor moves to start of selection + assert_eq!(engine.view().cursor.line, 0); + assert_eq!(engine.view().cursor.col, 6); +} + +#[test] +fn test_visual_line_yank_upward_selection() { + let mut engine = Engine::new(); + engine.buffer_mut().insert(0, "aaa\nbbb\nccc\nddd"); + engine.update_syntax(); + + // Start on line 2, visual line select upward to line 1 + press_char(&mut engine, 'j'); + press_char(&mut engine, 'j'); + press_char(&mut engine, 'V'); + press_char(&mut engine, 'k'); + // Selection: lines 1-2, cursor on line 1 + + press_char(&mut engine, 'y'); + + // Cursor should be at start of selection (line 1) + assert_eq!(engine.view().cursor.line, 1); + assert_eq!(engine.view().cursor.col, 0); +} + #[test] fn test_visual_line_delete() { let mut engine = Engine::new(); @@ -9020,954 +9087,1755 @@ fn test_picker_palette_command_opens_picker() { assert_eq!(engine.picker_source, PickerSource::Commands); } +// ─── Command Center tests ──────────────────────────────────────────────── + #[test] -fn test_ctrl_g_shows_file_info() { +fn test_command_center_opens() { let mut engine = Engine::new(); - engine.buffer_mut().insert(0, "hello\nworld\n"); - - press_ctrl(&mut engine, 'g'); - - assert!( - engine.message.contains("line 1 of 2"), - "msg: {}", - engine.message - ); + engine.open_command_center(); + assert!(engine.picker_open); + assert_eq!(engine.picker_source, PickerSource::CommandCenter); + assert_eq!(engine.picker_title, "Search"); } -// ─── Quickfix tests ────────────────────────────────────────────────────── - -fn make_qf_item(path: &str) -> ProjectMatch { - ProjectMatch { - file: std::path::PathBuf::from(path), - line: 0, - col: 0, - line_text: "test line".to_string(), - } +#[test] +fn test_command_center_default_files_mode() { + let mut engine = Engine::new(); + engine.open_command_center(); + // Default mode shows files (or empty in test environment) + assert_eq!(engine.picker_source, PickerSource::CommandCenter); + assert_eq!(engine.picker_title, "Search"); } #[test] -fn test_copen_requires_items() { +fn test_command_center_prefix_commands() { let mut engine = Engine::new(); - engine.execute_command("copen"); - assert!( - !engine.quickfix_open, - "copen should not open with empty list" - ); - assert!(engine.message.contains("empty")); + engine.open_command_center(); + // Type ">" to switch to command mode + engine.handle_picker_key(">", Some('>'), false); + assert_eq!(engine.picker_title, "Commands"); + assert!(!engine.picker_items.is_empty(), "should have command items"); } #[test] -fn test_copen_cclose() { +fn test_command_center_prefix_goto_line() { let mut engine = Engine::new(); - engine.quickfix_items = vec![make_qf_item("test.rs")]; - engine.execute_command("copen"); - assert!(engine.quickfix_open); - assert!(engine.quickfix_has_focus); - engine.execute_command("cclose"); - assert!(!engine.quickfix_open); - assert!(!engine.quickfix_has_focus); + engine + .buffer_mut() + .insert(0, "line1\nline2\nline3\nline4\nline5\n"); + engine.open_command_center(); + // Type ":3" to go to line 3 + engine.handle_picker_key(":", Some(':'), false); + engine.handle_picker_key("3", Some('3'), false); + assert_eq!(engine.picker_title, "Go to Line"); + assert_eq!(engine.picker_items.len(), 1); + assert!(engine.picker_items[0].display.contains("line 3")); + // Confirm should jump to line 3 + engine.picker_confirm(); + assert!(!engine.picker_open); + assert_eq!(engine.view().cursor.line, 2); // 0-indexed } #[test] -fn test_cn_cp_navigation() { +fn test_command_center_prefix_help() { let mut engine = Engine::new(); - engine.quickfix_items = vec![ - make_qf_item("a.rs"), - make_qf_item("b.rs"), - make_qf_item("c.rs"), - ]; - engine.quickfix_selected = 0; - engine.quickfix_open = true; - - // cn moves forward - engine.execute_command("cn"); - assert_eq!(engine.quickfix_selected, 1); - engine.execute_command("cn"); - assert_eq!(engine.quickfix_selected, 2); + engine.open_command_center(); + engine.handle_picker_key("?", Some('?'), false); + assert_eq!(engine.picker_title, "Help: Prefix Modes"); + assert_eq!(engine.picker_items.len(), 9); // 9 help items (including %, debug, task) +} - // cn at end clamps - engine.execute_command("cn"); - assert_eq!(engine.quickfix_selected, 2, "cn should clamp at last item"); +#[test] +fn test_command_center_prefix_symbols_no_lsp() { + let mut engine = Engine::new(); + engine.open_command_center(); + engine.handle_picker_key("@", Some('@'), false); + assert_eq!(engine.picker_title, "Go to Symbol in File"); +} - // cp moves back - engine.execute_command("cp"); - assert_eq!(engine.quickfix_selected, 1); +#[test] +fn test_command_center_prefix_workspace_symbols() { + let mut engine = Engine::new(); + engine.open_command_center(); + engine.handle_picker_key("#", Some('#'), false); + assert_eq!(engine.picker_title, "Go to Symbol in Workspace"); +} - // cp at start clamps - engine.execute_command("cp"); - engine.execute_command("cp"); - assert_eq!(engine.quickfix_selected, 0, "cp should clamp at first item"); +#[test] +fn test_command_center_prefix_switch_back_to_files() { + let mut engine = Engine::new(); + engine.open_command_center(); + // Switch to commands + engine.handle_picker_key(">", Some('>'), false); + assert_eq!(engine.picker_title, "Commands"); + // Delete the ">" to go back to files + engine.handle_picker_key("BackSpace", None, false); + assert_eq!(engine.picker_title, "Search"); } #[test] -fn test_cc_jump() { +fn test_command_center_via_execute() { let mut engine = Engine::new(); - engine.quickfix_items = vec![ - make_qf_item("a.rs"), - make_qf_item("b.rs"), - make_qf_item("c.rs"), - ]; - engine.quickfix_open = true; + engine.execute_command("CommandCenter"); + assert!(engine.picker_open); + assert_eq!(engine.picker_source, PickerSource::CommandCenter); +} - engine.execute_command("cc 2"); - assert_eq!( - engine.quickfix_selected, 1, - ":cc 2 should select index 1 (1-based)" +#[test] +fn test_command_center_goto_line_edge_cases() { + let mut engine = Engine::new(); + engine.buffer_mut().insert(0, "a\nb\nc\n"); + engine.open_command_center(); + // ":0" should clamp to line 1 + engine.handle_picker_key(":", Some(':'), false); + engine.handle_picker_key("0", Some('0'), false); + assert!(engine.picker_items[0].display.contains("line 1")); + // ":999" should clamp to last line — clear previous query first + let line_count = engine.buffer().content.len_lines(); + engine.close_picker(); + engine.open_command_center(); + engine.handle_picker_key(":", Some(':'), false); + engine.handle_picker_key("9", Some('9'), false); + engine.handle_picker_key("9", Some('9'), false); + engine.handle_picker_key("9", Some('9'), false); + let expected = format!("line {}", line_count); + assert!( + engine.picker_items[0].display.contains(&expected), + "expected '{}', got '{}'", + expected, + engine.picker_items[0].display ); } #[test] -fn test_grep_empty_pattern() { +fn test_command_center_help_confirm_sets_prefix() { let mut engine = Engine::new(); - engine.execute_command("grep "); - assert!(engine.quickfix_items.is_empty()); - assert!(engine.message.contains("Usage")); + engine.open_command_center(); + engine.handle_picker_key("?", Some('?'), false); + // Select the ">" command entry (index 1) + engine.handle_picker_key("Down", None, false); + engine.picker_confirm(); + // Should re-open with ">" prefix + assert!(engine.picker_open); + assert_eq!(engine.picker_query, ">"); + assert_eq!(engine.picker_title, "Commands"); } #[test] -fn test_grep_no_matches() { - let dir = std::env::temp_dir().join("vimcode_qf_no_match"); - std::fs::create_dir_all(&dir).unwrap(); +fn test_command_center_prefix_grep() { let mut engine = Engine::new(); - engine.cwd = dir.clone(); - engine.execute_command("grep xyzzy_no_match_anywhere_qf_test"); - assert_eq!(engine.quickfix_items.len(), 0); - assert!(engine.message.contains("0 match")); + engine.open_command_center(); + // Type "%" to switch to grep mode + engine.handle_picker_key("%", Some('%'), false); + assert_eq!(engine.picker_title, "Search for Text"); + // With only the prefix, should show a hint (need 2+ chars) + assert_eq!(engine.picker_items.len(), 1); + assert!(engine.picker_items[0].display.contains("2 characters")); } #[test] -fn test_grep_populates_quickfix() { - use std::io::Write; - let dir = std::env::temp_dir().join("vimcode_qf_grep_pop"); - std::fs::create_dir_all(&dir).unwrap(); - let file_path = dir.join("qftest.rs"); - let mut f = std::fs::File::create(&file_path).unwrap(); - writeln!(f, "fn qfmain_unique_marker() {{}}").unwrap(); - drop(f); - +fn test_command_center_grep_short_query() { let mut engine = Engine::new(); - engine.cwd = dir.clone(); - engine.execute_command("grep qfmain_unique_marker"); - - assert!( - !engine.quickfix_items.is_empty(), - "grep should find matches" - ); - assert!(engine.quickfix_open); - assert!( - !engine.quickfix_has_focus, - "focus should return to editor after :grep" - ); - assert!(engine.message.contains("match")); + engine.open_command_center(); + // Type "%a" — only 1 char after prefix, still shows hint + engine.handle_picker_key("%", Some('%'), false); + engine.handle_picker_key("a", Some('a'), false); + assert_eq!(engine.picker_title, "Search for Text"); + assert_eq!(engine.picker_items.len(), 1); + assert!(engine.picker_items[0].display.contains("2 characters")); } #[test] -fn test_vimgrep_alias() { +fn test_command_center_grep_runs_search() { use std::io::Write; - let dir = std::env::temp_dir().join("vimcode_qf_vimgrep"); + let dir = std::env::temp_dir().join("vimcode_test_cc_grep"); + let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); - let file_path = dir.join("vgtest.rs"); - let mut f = std::fs::File::create(&file_path).unwrap(); - writeln!(f, "fn vghello_unique_marker() {{}}").unwrap(); - drop(f); + // Create a test file with searchable content + let test_file = dir.join("searchable.txt"); + let mut f = std::fs::File::create(&test_file).unwrap(); + writeln!(f, "hello world").unwrap(); + writeln!(f, "foo bar baz").unwrap(); + writeln!(f, "hello again").unwrap(); let mut engine = Engine::new(); engine.cwd = dir.clone(); - engine.execute_command("vimgrep vghello_unique_marker"); - + engine.open_command_center(); + // Type "%hello" — should find matches + for ch in "%hello".chars() { + engine.handle_picker_key(&ch.to_string(), Some(ch), false); + } + assert_eq!(engine.picker_title, "Search for Text"); assert!( - !engine.quickfix_items.is_empty(), - "vimgrep should work same as grep" + engine.picker_items.len() >= 2, + "expected at least 2 grep results, got {}", + engine.picker_items.len() ); - assert!(engine.quickfix_open); -} + // Results should reference the file + assert!(engine.picker_items[0].display.contains("searchable.txt")); -// ─── rename_file / move_file tests ──────────────────────────────────────── + let _ = std::fs::remove_dir_all(&dir); +} #[test] -fn test_rename_file_updates_buffer_path() { - let dir = std::env::temp_dir().join("vimcode_rename_upd"); - std::fs::create_dir_all(&dir).unwrap(); - let old = dir.join("rename_old.txt"); - std::fs::write(&old, "hello").unwrap(); - +fn test_command_center_help_includes_grep_prefix() { let mut engine = Engine::new(); - engine - .open_file_with_mode(&old, OpenMode::Permanent) - .unwrap(); - - engine.rename_file(&old, "rename_new.txt").unwrap(); - - let new = dir.join("rename_new.txt"); - assert!(new.exists(), "new path should exist"); - assert!(!old.exists(), "old path should be gone"); - - // The open buffer's file_path should have been updated - let updated = engine.buffer_manager.list().into_iter().any(|id| { - engine - .buffer_manager - .get(id) - .and_then(|s| s.file_path.as_ref()) - == Some(&new) - }); - assert!(updated, "open buffer should point to new path"); + engine.open_command_center(); + engine.handle_picker_key("?", Some('?'), false); + // One of the help items should be the % prefix + let has_percent = engine.picker_items.iter().any(|item| item.display == "%"); + assert!(has_percent, "help menu should include % prefix"); } #[test] -fn test_rename_file_not_found() { +fn test_command_center_help_confirm_grep_prefix() { let mut engine = Engine::new(); - let result = engine.rename_file(Path::new("/vimcode_nonexistent_xyz/file.txt"), "new.txt"); - assert!(result.is_err(), "renaming missing file should fail"); + engine.open_command_center(); + engine.handle_picker_key("?", Some('?'), false); + // Find the "%" item index — it's after ":", before "?" + let percent_idx = engine + .picker_items + .iter() + .position(|item| item.display == "%") + .expect("% should be in help items"); + // Navigate to it + for _ in 0..percent_idx { + engine.handle_picker_key("Down", None, false); + } + engine.picker_confirm(); + // Should re-open with "%" prefix + assert!(engine.picker_open); + assert_eq!(engine.picker_query, "%"); + assert_eq!(engine.picker_title, "Search for Text"); } #[test] -fn test_rename_file_empty_name() { +fn test_command_center_debug_prefix_no_launch_json() { let mut engine = Engine::new(); - let result = engine.rename_file(Path::new("/tmp/whatever.txt"), ""); - assert!(result.is_err()); - assert!(result.unwrap_err().contains("empty")); + // Set cwd to a temp dir with no launch.json + let dir = std::env::temp_dir().join("vimcode_test_cc_debug_none"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + engine.cwd = dir.clone(); + engine.open_command_center(); + for ch in "debug".chars() { + engine.handle_picker_key(&ch.to_string(), Some(ch), false); + } + assert_eq!(engine.picker_title, "Start Debugging"); + assert_eq!(engine.picker_items.len(), 1); + assert!(engine.picker_items[0] + .display + .contains("Create launch.json")); + let _ = std::fs::remove_dir_all(&dir); } #[test] -fn test_move_file_basic() { - let base = std::env::temp_dir().join("vimcode_move_basic"); - let dest = base.join("subdir_mv"); - std::fs::create_dir_all(&dest).unwrap(); - let src = base.join("moveme.txt"); - std::fs::write(&src, "data").unwrap(); +fn test_command_center_debug_prefix_with_configs() { + use std::io::Write; + let dir = std::env::temp_dir().join("vimcode_test_cc_debug_configs"); + let _ = std::fs::remove_dir_all(&dir); + let vimcode_dir = dir.join(".vimcode"); + std::fs::create_dir_all(&vimcode_dir).unwrap(); + let launch_json = vimcode_dir.join("launch.json"); + let mut f = std::fs::File::create(&launch_json).unwrap(); + write!( + f, + r#"{{ + "version": "0.2.0", + "configurations": [ + {{ "type": "lldb", "request": "launch", "name": "Debug App", "program": "target/debug/app" }}, + {{ "type": "debugpy", "request": "launch", "name": "Debug Python", "program": "main.py" }} + ] +}}"# + ) + .unwrap(); let mut engine = Engine::new(); - engine.move_file(&src, &dest).unwrap(); - - assert!(!src.exists(), "source should be gone"); - assert!(dest.join("moveme.txt").exists(), "file should be in dest"); + engine.cwd = dir.clone(); + engine.open_command_center(); + for ch in "debug".chars() { + engine.handle_picker_key(&ch.to_string(), Some(ch), false); + } + assert_eq!(engine.picker_title, "Start Debugging"); + assert_eq!(engine.picker_items.len(), 2); + assert_eq!(engine.picker_items[0].display, "Debug App"); + assert_eq!(engine.picker_items[1].display, "Debug Python"); + let _ = std::fs::remove_dir_all(&dir); } #[test] -fn test_move_file_invalid_dest() { +fn test_command_center_debug_prefix_filter() { + use std::io::Write; + let dir = std::env::temp_dir().join("vimcode_test_cc_debug_filter"); + let _ = std::fs::remove_dir_all(&dir); + let vimcode_dir = dir.join(".vimcode"); + std::fs::create_dir_all(&vimcode_dir).unwrap(); + let launch_json = vimcode_dir.join("launch.json"); + let mut f = std::fs::File::create(&launch_json).unwrap(); + write!( + f, + r#"{{ + "version": "0.2.0", + "configurations": [ + {{ "type": "lldb", "request": "launch", "name": "Debug App", "program": "target/debug/app" }}, + {{ "type": "debugpy", "request": "launch", "name": "Python Script", "program": "main.py" }} + ] +}}"# + ) + .unwrap(); + let mut engine = Engine::new(); - let result = engine.move_file( - Path::new("/tmp/whatever.txt"), - Path::new("/tmp/not_a_real_dir_xyz_vc"), - ); - assert!(result.is_err()); + engine.cwd = dir.clone(); + engine.open_command_center(); + // Type "debug Python" to filter configs + for ch in "debug Python".chars() { + engine.handle_picker_key(&ch.to_string(), Some(ch), false); + } + assert_eq!(engine.picker_title, "Start Debugging"); + assert_eq!(engine.picker_items.len(), 1); + assert_eq!(engine.picker_items[0].display, "Python Script"); + let _ = std::fs::remove_dir_all(&dir); } #[test] -fn test_confirm_move_shows_dialog() { - let base = std::env::temp_dir().join("vimcode_confirm_move_dlg"); - let dest = base.join("target_dir"); - std::fs::create_dir_all(&dest).unwrap(); - let src = base.join("confirm_me.txt"); - std::fs::write(&src, "data").unwrap(); +fn test_command_center_debug_confirm_sets_config_index() { + use std::io::Write; + let dir = std::env::temp_dir().join("vimcode_test_cc_debug_confirm"); + let _ = std::fs::remove_dir_all(&dir); + let vimcode_dir = dir.join(".vimcode"); + std::fs::create_dir_all(&vimcode_dir).unwrap(); + let launch_json = vimcode_dir.join("launch.json"); + let mut f = std::fs::File::create(&launch_json).unwrap(); + write!( + f, + r#"{{ + "version": "0.2.0", + "configurations": [ + {{ "type": "lldb", "request": "launch", "name": "Config A", "program": "a" }}, + {{ "type": "lldb", "request": "launch", "name": "Config B", "program": "b" }} + ] +}}"# + ) + .unwrap(); let mut engine = Engine::new(); - engine.confirm_move_file(&src, &dest); + engine.cwd = dir.clone(); + engine.open_command_center(); + for ch in "debug".chars() { + engine.handle_picker_key(&ch.to_string(), Some(ch), false); + } + // Select the second config + engine.handle_picker_key("Down", None, false); + engine.picker_confirm(); + // Picker should be closed and config index set to 1 + assert!(!engine.picker_open); + assert_eq!(engine.dap_selected_launch_config, 1); + let _ = std::fs::remove_dir_all(&dir); +} - // Dialog should be shown. - assert!(engine.dialog.is_some()); - let dialog = engine.dialog.as_ref().unwrap(); - assert_eq!(dialog.tag, "confirm_move"); - assert!(dialog.body[0].contains("confirm_me.txt")); - assert_eq!(dialog.buttons.len(), 2); +#[test] +fn test_command_center_help_includes_debug_prefix() { + let mut engine = Engine::new(); + engine.open_command_center(); + engine.handle_picker_key("?", Some('?'), false); + let has_debug = engine + .picker_items + .iter() + .any(|item| item.display == "debug"); + assert!(has_debug, "help menu should include debug prefix"); +} - // Pending move should be stored. - assert!(engine.pending_move.is_some()); - let (ps, pd) = engine.pending_move.as_ref().unwrap(); - assert_eq!(ps, &src); - assert_eq!(pd, &dest); +#[test] +fn test_command_center_help_confirm_debug_prefix() { + let mut engine = Engine::new(); + engine.open_command_center(); + engine.handle_picker_key("?", Some('?'), false); + let debug_idx = engine + .picker_items + .iter() + .position(|item| item.display == "debug") + .expect("debug should be in help items"); + for _ in 0..debug_idx { + engine.handle_picker_key("Down", None, false); + } + engine.picker_confirm(); + // Should re-open with "debug " prefix (with trailing space for keyword) + assert!(engine.picker_open); + assert_eq!(engine.picker_query, "debug "); + assert_eq!(engine.picker_title, "Start Debugging"); +} - // Simulate pressing 'y' (Yes) — dialog handles it. - let _action = engine.handle_key("y", Some('y'), false); +#[test] +fn test_command_center_debug_reads_vscode_fallback() { + use std::io::Write; + let dir = std::env::temp_dir().join("vimcode_test_cc_debug_vscode"); + let _ = std::fs::remove_dir_all(&dir); + // No .vimcode dir, but .vscode/launch.json exists + let vscode_dir = dir.join(".vscode"); + std::fs::create_dir_all(&vscode_dir).unwrap(); + let launch_json = vscode_dir.join("launch.json"); + let mut f = std::fs::File::create(&launch_json).unwrap(); + write!( + f, + r#"{{ + "version": "0.2.0", + "configurations": [ + {{ "type": "node", "request": "launch", "name": "Launch Node", "program": "index.js" }} + ] +}}"# + ) + .unwrap(); - // File should have been moved. - assert!(!src.exists()); - assert!(dest.join("confirm_me.txt").exists()); - assert!(engine.dialog.is_none()); - assert!(engine.pending_move.is_none()); - assert!(engine.explorer_needs_refresh); + let mut engine = Engine::new(); + engine.cwd = dir.clone(); + engine.open_command_center(); + for ch in "debug".chars() { + engine.handle_picker_key(&ch.to_string(), Some(ch), false); + } + assert_eq!(engine.picker_title, "Start Debugging"); + assert_eq!(engine.picker_items.len(), 1); + assert_eq!(engine.picker_items[0].display, "Launch Node"); + let _ = std::fs::remove_dir_all(&dir); +} - // Cleanup - let _ = std::fs::remove_dir_all(&base); +#[test] +fn test_command_center_task_prefix_no_tasks_json() { + let mut engine = Engine::new(); + let dir = std::env::temp_dir().join("vimcode_test_cc_task_none"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + engine.cwd = dir.clone(); + engine.open_command_center(); + for ch in "task".chars() { + engine.handle_picker_key(&ch.to_string(), Some(ch), false); + } + assert_eq!(engine.picker_title, "Run Task"); + assert_eq!(engine.picker_items.len(), 1); + assert!(engine.picker_items[0].display.contains("Configure Tasks")); + let _ = std::fs::remove_dir_all(&dir); } #[test] -fn test_confirm_move_cancel() { - let base = std::env::temp_dir().join("vimcode_confirm_move_cancel"); - let dest = base.join("target_dir_c"); - std::fs::create_dir_all(&dest).unwrap(); - let src = base.join("stay_put.txt"); - std::fs::write(&src, "data").unwrap(); +fn test_command_center_task_prefix_with_tasks() { + use std::io::Write; + let dir = std::env::temp_dir().join("vimcode_test_cc_task_list"); + let _ = std::fs::remove_dir_all(&dir); + let vimcode_dir = dir.join(".vimcode"); + std::fs::create_dir_all(&vimcode_dir).unwrap(); + let tasks_json = vimcode_dir.join("tasks.json"); + let mut f = std::fs::File::create(&tasks_json).unwrap(); + write!( + f, + r#"{{ + "version": "2.0.0", + "tasks": [ + {{ "label": "build", "type": "shell", "command": "cargo build" }}, + {{ "label": "test", "type": "shell", "command": "cargo test" }}, + {{ "label": "lint", "type": "shell", "command": "cargo clippy" }} + ] +}}"# + ) + .unwrap(); let mut engine = Engine::new(); - engine.confirm_move_file(&src, &dest); - - // Simulate pressing 'n' (No). - let _action = engine.handle_key("n", Some('n'), false); + engine.cwd = dir.clone(); + engine.open_command_center(); + for ch in "task".chars() { + engine.handle_picker_key(&ch.to_string(), Some(ch), false); + } + assert_eq!(engine.picker_title, "Run Task"); + assert_eq!(engine.picker_items.len(), 3); + assert_eq!(engine.picker_items[0].display, "build"); + assert_eq!(engine.picker_items[1].display, "test"); + assert_eq!(engine.picker_items[2].display, "lint"); + let _ = std::fs::remove_dir_all(&dir); +} - // File should NOT have been moved. - assert!(src.exists()); - assert!(!dest.join("stay_put.txt").exists()); - assert!(engine.dialog.is_none()); - assert!(engine.pending_move.is_none()); - assert!(!engine.explorer_needs_refresh); +#[test] +fn test_command_center_task_prefix_filter() { + use std::io::Write; + let dir = std::env::temp_dir().join("vimcode_test_cc_task_filter"); + let _ = std::fs::remove_dir_all(&dir); + let vimcode_dir = dir.join(".vimcode"); + std::fs::create_dir_all(&vimcode_dir).unwrap(); + let tasks_json = vimcode_dir.join("tasks.json"); + let mut f = std::fs::File::create(&tasks_json).unwrap(); + write!( + f, + r#"{{ + "version": "2.0.0", + "tasks": [ + {{ "label": "build", "type": "shell", "command": "cargo build" }}, + {{ "label": "test", "type": "shell", "command": "cargo test" }}, + {{ "label": "lint", "type": "shell", "command": "cargo clippy" }} + ] +}}"# + ) + .unwrap(); - // Cleanup - let _ = std::fs::remove_dir_all(&base); + let mut engine = Engine::new(); + engine.cwd = dir.clone(); + engine.open_command_center(); + for ch in "task lint".chars() { + engine.handle_picker_key(&ch.to_string(), Some(ch), false); + } + assert_eq!(engine.picker_title, "Run Task"); + assert_eq!(engine.picker_items.len(), 1); + assert_eq!(engine.picker_items[0].display, "lint"); + let _ = std::fs::remove_dir_all(&dir); } #[test] -fn test_move_file_into_own_subtree() { - let base = std::env::temp_dir().join("vimcode_move_subtree"); - let parent = base.join("parent_dir"); - let child = parent.join("child_dir"); - std::fs::create_dir_all(&child).unwrap(); +fn test_command_center_task_confirm_returns_run_in_terminal() { + use std::io::Write; + let dir = std::env::temp_dir().join("vimcode_test_cc_task_confirm"); + let _ = std::fs::remove_dir_all(&dir); + let vimcode_dir = dir.join(".vimcode"); + std::fs::create_dir_all(&vimcode_dir).unwrap(); + let tasks_json = vimcode_dir.join("tasks.json"); + let mut f = std::fs::File::create(&tasks_json).unwrap(); + write!( + f, + r#"{{ + "version": "2.0.0", + "tasks": [ + {{ "label": "build", "type": "shell", "command": "cargo build" }} + ] +}}"# + ) + .unwrap(); let mut engine = Engine::new(); - let result = engine.move_file(&parent, &child); - assert!(result.is_err()); + engine.cwd = dir.clone(); + engine.open_command_center(); + for ch in "task".chars() { + engine.handle_picker_key(&ch.to_string(), Some(ch), false); + } + let action = engine.picker_confirm(); + assert!(!engine.picker_open); assert!( - result.unwrap_err().contains("subtree"), - "should reject moving folder into its own subtree" + matches!(action, EngineAction::RunInTerminal(ref cmd) if cmd == "cargo build"), + "expected RunInTerminal(\"cargo build\"), got {:?}", + action ); - - // Cleanup - let _ = std::fs::remove_dir_all(&base); + let _ = std::fs::remove_dir_all(&dir); } #[test] -fn test_move_file_same_directory_noop() { - let base = std::env::temp_dir().join("vimcode_move_noop"); - std::fs::create_dir_all(&base).unwrap(); - let src = base.join("stay.txt"); - std::fs::write(&src, "stay").unwrap(); +fn test_command_center_task_reads_vscode_fallback() { + use std::io::Write; + let dir = std::env::temp_dir().join("vimcode_test_cc_task_vscode"); + let _ = std::fs::remove_dir_all(&dir); + let vscode_dir = dir.join(".vscode"); + std::fs::create_dir_all(&vscode_dir).unwrap(); + let tasks_json = vscode_dir.join("tasks.json"); + let mut f = std::fs::File::create(&tasks_json).unwrap(); + write!( + f, + r#"{{ + "version": "2.0.0", + "tasks": [ + {{ "label": "npm start", "type": "shell", "command": "npm start" }} + ] +}}"# + ) + .unwrap(); let mut engine = Engine::new(); - // Moving a file into the directory it's already in should be a no-op. - let result = engine.move_file(&src, &base); - assert!(result.is_ok()); - assert!(src.exists(), "file should still be at original location"); + engine.cwd = dir.clone(); + engine.open_command_center(); + for ch in "task".chars() { + engine.handle_picker_key(&ch.to_string(), Some(ch), false); + } + assert_eq!(engine.picker_title, "Run Task"); + assert_eq!(engine.picker_items.len(), 1); + assert_eq!(engine.picker_items[0].display, "npm start"); + let _ = std::fs::remove_dir_all(&dir); +} - // Cleanup - let _ = std::fs::remove_dir_all(&base); +#[test] +fn test_command_center_help_includes_task_prefix() { + let mut engine = Engine::new(); + engine.open_command_center(); + engine.handle_picker_key("?", Some('?'), false); + let has_task = engine + .picker_items + .iter() + .any(|item| item.display == "task"); + assert!(has_task, "help menu should include task prefix"); } #[test] -fn test_move_directory_basic() { - let base = std::env::temp_dir().join("vimcode_move_dir"); - let src = base.join("src_dir"); - let dest = base.join("dest_dir"); - std::fs::create_dir_all(&src).unwrap(); - std::fs::create_dir_all(&dest).unwrap(); - std::fs::write(src.join("file.txt"), "content").unwrap(); +fn test_command_center_help_confirm_task_prefix() { + let mut engine = Engine::new(); + engine.open_command_center(); + engine.handle_picker_key("?", Some('?'), false); + let task_idx = engine + .picker_items + .iter() + .position(|item| item.display == "task") + .expect("task should be in help items"); + for _ in 0..task_idx { + engine.handle_picker_key("Down", None, false); + } + engine.picker_confirm(); + assert!(engine.picker_open); + assert_eq!(engine.picker_query, "task "); + assert_eq!(engine.picker_title, "Run Task"); +} + +#[test] +fn test_command_center_task_create_tasks_json() { + let dir = std::env::temp_dir().join("vimcode_test_cc_task_create"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); let mut engine = Engine::new(); - engine.move_file(&src, &dest).unwrap(); + engine.cwd = dir.clone(); + engine.open_command_center(); + for ch in "task".chars() { + engine.handle_picker_key(&ch.to_string(), Some(ch), false); + } + // Should show "Configure Tasks..." + assert_eq!(engine.picker_items.len(), 1); + assert!(engine.picker_items[0].display.contains("Configure Tasks")); + // Confirm should create tasks.json and open it + engine.picker_confirm(); + assert!(!engine.picker_open); + let tasks_path = dir.join(".vimcode").join("tasks.json"); + assert!(tasks_path.exists(), "tasks.json should have been created"); + let content = std::fs::read_to_string(&tasks_path).unwrap(); + assert!(content.contains("\"tasks\"")); + let _ = std::fs::remove_dir_all(&dir); +} - assert!(!src.exists(), "source dir should be gone"); - assert!( - dest.join("src_dir").join("file.txt").exists(), - "dir should be moved with contents" - ); +#[test] +fn test_command_center_placeholder_hints_on_open() { + let mut engine = Engine::new(); + engine.open_command_center(); + // Empty query should show placeholder hint items, not files + assert_eq!(engine.picker_query, ""); + assert_eq!(engine.picker_title, "Search"); + assert_eq!(engine.picker_items.len(), 9); + // Verify key hint items exist + let labels: Vec<&str> = engine + .picker_items + .iter() + .map(|i| i.display.as_str()) + .collect(); + assert!(labels.contains(&"Go to File")); + assert!(labels.contains(&"Show and Run Commands")); + assert!(labels.contains(&"Go to Line")); + assert!(labels.contains(&"Search for Text")); + assert!(labels.contains(&"Start Debugging")); + assert!(labels.contains(&"Run Task")); + assert!(labels.contains(&"More Help")); +} - // Cleanup - let _ = std::fs::remove_dir_all(&base); +#[test] +fn test_command_center_placeholder_hints_select_commands() { + let mut engine = Engine::new(); + engine.open_command_center(); + // Select "Show and Run Commands" (index 1) + engine.handle_picker_key("Down", None, false); + engine.picker_confirm(); + // Should switch to command mode with ">" prefix + assert!(engine.picker_open); + assert_eq!(engine.picker_query, ">"); + assert_eq!(engine.picker_title, "Commands"); } #[test] -fn test_move_file_updates_buffer_path() { - let base = std::env::temp_dir().join("vimcode_move_bufupd"); - let dest = base.join("dest_mv"); - std::fs::create_dir_all(&dest).unwrap(); - let src = base.join("tracked.txt"); - std::fs::write(&src, "tracked").unwrap(); +fn test_command_center_placeholder_hints_select_goto_line() { + let mut engine = Engine::new(); + engine.open_command_center(); + // Find "Go to Line" and select it + let idx = engine + .picker_items + .iter() + .position(|i| i.display == "Go to Line") + .unwrap(); + for _ in 0..idx { + engine.handle_picker_key("Down", None, false); + } + engine.picker_confirm(); + assert!(engine.picker_open); + assert_eq!(engine.picker_query, ":"); + assert_eq!(engine.picker_title, "Go to Line"); +} +#[test] +fn test_command_center_placeholder_hints_select_debug() { let mut engine = Engine::new(); - engine - .open_file_with_mode(&src, OpenMode::Permanent) + engine.open_command_center(); + let idx = engine + .picker_items + .iter() + .position(|i| i.display == "Start Debugging") .unwrap(); + for _ in 0..idx { + engine.handle_picker_key("Down", None, false); + } + engine.picker_confirm(); + assert!(engine.picker_open); + assert_eq!(engine.picker_query, "debug "); + assert_eq!(engine.picker_title, "Start Debugging"); +} - engine.move_file(&src, &dest).unwrap(); +#[test] +fn test_command_center_placeholder_hints_disappear_on_typing() { + let mut engine = Engine::new(); + engine.open_command_center(); + assert_eq!(engine.picker_items.len(), 9); // hints shown + // Type a character — should switch to file search mode + engine.handle_picker_key("a", Some('a'), false); + // Items should no longer be the placeholder hints + let has_hint = engine + .picker_items + .iter() + .any(|i| i.display == "Go to File"); + assert!(!has_hint, "placeholder hints should be gone after typing"); +} - let expected = dest.join("tracked.txt"); - let updated = engine.buffer_manager.list().into_iter().any(|id| { - engine - .buffer_manager - .get(id) - .and_then(|s| s.file_path.as_ref()) - == Some(&expected) - }); +#[test] +fn test_command_center_placeholder_select_go_to_file() { + let mut engine = Engine::new(); + engine.open_command_center(); + // "Go to File" is first item (index 0), confirm it + engine.picker_confirm(); + // Should stay open in file search mode (hints replaced by file list) + assert!(engine.picker_open); + let has_hint = engine + .picker_items + .iter() + .any(|i| i.display == "Go to File"); assert!( - updated, - "open buffer should point to new location after move" + !has_hint, + "placeholder hints should be replaced by file list" ); - - // Cleanup - let _ = std::fs::remove_dir_all(&base); } -// ─── LCS diff tests ─────────────────────────────────────────────────────── - #[test] -fn test_lcs_diff_same_content() { - let a = &["alpha", "beta", "gamma"]; - let b = &["alpha", "beta", "gamma"]; - let (da, db) = lcs_diff(a, b); - assert!(da.iter().all(|s| *s == DiffLine::Same)); - assert!(db.iter().all(|s| *s == DiffLine::Same)); - assert_eq!(da.len(), 3); - assert_eq!(db.len(), 3); -} +fn test_leader_sw_opens_grep_with_word() { + use std::io::Write; + let dir = std::env::temp_dir().join("vimcode_test_leader_sw"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + // Create a file with the word "foobar" so grep can find it + let test_file = dir.join("test.txt"); + let mut f = std::fs::File::create(&test_file).unwrap(); + writeln!(f, "hello foobar world").unwrap(); + writeln!(f, "foobar again").unwrap(); -#[test] -fn test_lcs_diff_added_line() { - let a = &["alpha", "gamma"]; - let b = &["alpha", "beta", "gamma"]; - let (da, db) = lcs_diff(a, b); - assert!(da.iter().all(|s| *s == DiffLine::Same)); - assert_eq!(db[0], DiffLine::Same); - assert_eq!(db[1], DiffLine::Added); - assert_eq!(db[2], DiffLine::Same); + let mut engine = engine_with_text("hello foobar world"); + engine.cwd = dir.clone(); + // Move cursor to "foobar" (col 6) + for _ in 0..6 { + press_char(&mut engine, 'l'); + } + // Press sw (default leader is space) + press_char(&mut engine, ' '); + press_char(&mut engine, 's'); + press_char(&mut engine, 'w'); + assert!(engine.picker_open); + assert_eq!(engine.picker_source, PickerSource::Grep); + assert_eq!(engine.picker_query, "foobar"); + // Should have grep results + assert!( + !engine.picker_items.is_empty(), + "should have grep results for 'foobar'" + ); + let _ = std::fs::remove_dir_all(&dir); } #[test] -fn test_lcs_diff_removed_line() { - let a = &["alpha", "beta", "gamma"]; - let b = &["alpha", "gamma"]; - let (da, db) = lcs_diff(a, b); - assert_eq!(da[0], DiffLine::Same); - assert_eq!(da[1], DiffLine::Removed); - assert_eq!(da[2], DiffLine::Same); - assert!(db.iter().all(|s| *s == DiffLine::Same)); +fn test_leader_sw_no_word() { + let mut engine = engine_with_text(""); + // Press sw on empty buffer (default leader is space) + press_char(&mut engine, ' '); + press_char(&mut engine, 's'); + press_char(&mut engine, 'w'); + // Should open grep picker with empty query (no word found) + assert!(engine.picker_open); + assert_eq!(engine.picker_source, PickerSource::Grep); + assert_eq!(engine.picker_query, ""); } #[test] -fn test_myers_diff_two_change_blocks() { - // Mirrors the README vs README2 scenario: two distinct change blocks - // separated by 2 unchanged lines — must NOT merge into one block. - let a = &[ - "# DemoConsoleGame", - "Simple demo game", - "", - "In the end he did get a degree", - "", - "Now he's doing a masters", - "", - "So, now the only point", - ]; - let b = &[ - "# DemoConsoleGame", - "Simple demo game", - "", - "In the end he did ge asdfadt a degree", - "", - "", - "asds", - "", - "zdxfasd", - "", - "", - "Now he's doing a masters", - "", - "asdfsd", - "", - "So, now the only point", - ]; - let (da, db) = lcs_diff(a, b); - // Line 3 of a should be Removed, line 3 of b should be Added (changed line). - assert_eq!(da[3], DiffLine::Removed); - assert_eq!(db[3], DiffLine::Added); - // Lines 5-10 of b should be Added (inserted block). - for i in 5..11 { - assert_eq!(db[i], DiffLine::Added, "b[{}] should be Added", i); - } - // a[5] "Now he's doing..." should be Same (not merged). - assert_eq!(da[5], DiffLine::Same); - assert_eq!(db[11], DiffLine::Same); - // b[13] "asdfsd" should be Added (second change block). - assert_eq!(db[13], DiffLine::Added); -} +fn test_grep_word_command() { + use std::io::Write; + let dir = std::env::temp_dir().join("vimcode_test_grep_word_cmd"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let test_file = dir.join("test.txt"); + let mut f = std::fs::File::create(&test_file).unwrap(); + writeln!(f, "myidentifier is here").unwrap(); -#[test] -fn test_lcs_diff_changed_line() { - let a = &["hello world"]; - let b = &["hello rust"]; - let (da, db) = lcs_diff(a, b); - assert_eq!(da[0], DiffLine::Removed); - assert_eq!(db[0], DiffLine::Added); + let mut engine = engine_with_text("myidentifier is here"); + engine.cwd = dir.clone(); + engine.execute_command("GrepWord"); + assert!(engine.picker_open); + assert_eq!(engine.picker_source, PickerSource::Grep); + assert_eq!(engine.picker_query, "myidentifier"); + let _ = std::fs::remove_dir_all(&dir); } #[test] -fn test_lcs_diff_empty() { - let (da, db) = lcs_diff(&[], &[]); - assert!(da.is_empty()); - assert!(db.is_empty()); +fn test_grep_word_command_no_word() { + let mut engine = engine_with_text(""); + engine.execute_command("GrepWord"); + assert!(!engine.picker_open); + assert!(engine.message.contains("No word")); } #[test] -fn test_merge_short_same_runs_blank_lines() { - // Blank lines inside an added block should not fragment it. - let a = &["header", "old", "footer"]; - let b = &["header", "new1", "", "new2", "", "new3", "footer"]; - let (da, mut db) = lcs_diff(a, b); - merge_short_same_runs(&mut db, DiffLine::Added); - // All lines between header and footer should be Added on the b side. - assert_eq!(db[0], DiffLine::Same, "header"); - for i in 1..6 { - assert_eq!(db[i], DiffLine::Added, "line {i} should be Added"); - } - assert_eq!(db[6], DiffLine::Same, "footer"); - // A side should still have Removed for 'old'. - assert_eq!(da[0], DiffLine::Same); - assert_eq!(da[1], DiffLine::Removed); - assert_eq!(da[2], DiffLine::Same); +fn test_ctrl_g_shows_file_info() { + let mut engine = Engine::new(); + engine.buffer_mut().insert(0, "hello\nworld\n"); + + press_ctrl(&mut engine, 'g'); + + assert!( + engine.message.contains("line 1 of 2"), + "msg: {}", + engine.message + ); } -#[test] -fn test_merge_short_same_runs_common_lines() { - // Short runs of common lines (braces, imports) between changes should - // be absorbed into the surrounding change region. - let a = &["header", "}", "footer"]; - let b = &["header", "new1", "}", "new2", "footer"]; - let (_da, mut db) = lcs_diff(a, b); - merge_short_same_runs(&mut db, DiffLine::Added); - assert_eq!(db[0], DiffLine::Same, "header"); - // "new1", "}", "new2" should all be Added (} is a short Same island). - for i in 1..4 { - assert_eq!(db[i], DiffLine::Added, "line {i} should be Added"); +// ─── Quickfix tests ────────────────────────────────────────────────────── + +fn make_qf_item(path: &str) -> ProjectMatch { + ProjectMatch { + file: std::path::PathBuf::from(path), + line: 0, + col: 0, + line_text: "test line".to_string(), } - assert_eq!(db[4], DiffLine::Same, "footer"); } #[test] -fn test_build_aligned_diff_unequal_same_tails() { - // Regression: when one side has more Same lines than the other, - // build_aligned_diff must not loop forever. - use DiffLine::*; - let da = vec![Same, Same, Removed, Same, Same]; - let db = vec![Same, Same, Same]; - // This used to hang — the fix ensures progress when one side is - // exhausted while the other still has Same lines. - let (aa, ab) = build_aligned_diff(&da, &db); - assert_eq!(aa.len(), ab.len()); +fn test_copen_requires_items() { + let mut engine = Engine::new(); + engine.execute_command("copen"); + assert!( + !engine.quickfix_open, + "copen should not open with empty list" + ); + assert!(engine.message.contains("empty")); } #[test] -fn test_build_aligned_diff_basic() { - use DiffLine::*; - let da = vec![Same, Removed, Same]; - let db = vec![Same, Added, Same]; - let (aa, ab) = build_aligned_diff(&da, &db); - assert_eq!(aa.len(), ab.len()); - // First and last should map to source lines. - assert!(aa[0].source_line.is_some()); - assert!(ab[0].source_line.is_some()); +fn test_copen_cclose() { + let mut engine = Engine::new(); + engine.quickfix_items = vec![make_qf_item("test.rs")]; + engine.execute_command("copen"); + assert!(engine.quickfix_open); + assert!(engine.quickfix_has_focus); + engine.execute_command("cclose"); + assert!(!engine.quickfix_open); + assert!(!engine.quickfix_has_focus); } #[test] -fn test_lcs_diff_large_files_with_small_diff() { - // Regression: files >5000 lines used to return all-Same due to MAX_LINES guard. - let mut a_lines: Vec = (0..8000).map(|i| format!("line {i}")).collect(); - let mut b_lines = a_lines.clone(); - // Insert 3 new lines in the middle of b. - b_lines.insert(4000, "new line 1".to_string()); - b_lines.insert(4001, "new line 2".to_string()); - b_lines.insert(4002, "new line 3".to_string()); - // Also change one line. - a_lines[100] = "original line 100".to_string(); - b_lines[100] = "modified line 100".to_string(); +fn test_cn_cp_navigation() { + let mut engine = Engine::new(); + engine.quickfix_items = vec![ + make_qf_item("a.rs"), + make_qf_item("b.rs"), + make_qf_item("c.rs"), + ]; + engine.quickfix_selected = 0; + engine.quickfix_open = true; - let a_refs: Vec<&str> = a_lines.iter().map(String::as_str).collect(); - let b_refs: Vec<&str> = b_lines.iter().map(String::as_str).collect(); - let (da, db) = lcs_diff(&a_refs, &b_refs); + // cn moves forward + engine.execute_command("cn"); + assert_eq!(engine.quickfix_selected, 1); + engine.execute_command("cn"); + assert_eq!(engine.quickfix_selected, 2); - // Should detect actual changes, not return all-Same. - let a_changes = da.iter().filter(|d| **d != DiffLine::Same).count(); - let b_changes = db.iter().filter(|d| **d != DiffLine::Same).count(); - assert!( - a_changes > 0 || b_changes > 0, - "diff should detect changes in large files" - ); - // Specifically: b should have 3 Added lines + 1 changed line. - assert!(b_changes >= 3, "b should have at least 3 Added lines"); -} + // cn at end clamps + engine.execute_command("cn"); + assert_eq!(engine.quickfix_selected, 2, "cn should clamp at last item"); -// ─── cmd_diffthis / cmd_diffoff / cmd_diffsplit tests ───────────────────── + // cp moves back + engine.execute_command("cp"); + assert_eq!(engine.quickfix_selected, 1); -#[test] -fn test_diffthis_one_window_then_diffoff() { - let mut engine = Engine::new(); - engine.execute_command("diffthis"); - assert!(engine.diff_window_pair.is_some()); - engine.execute_command("diffoff"); - assert!(engine.diff_window_pair.is_none()); - assert!(engine.diff_results.is_empty()); + // cp at start clamps + engine.execute_command("cp"); + engine.execute_command("cp"); + assert_eq!(engine.quickfix_selected, 0, "cp should clamp at first item"); } #[test] -fn test_diffthis_two_windows() { - let dir = std::env::temp_dir().join("vimcode_diffthis_two"); - std::fs::create_dir_all(&dir).unwrap(); - let f1 = dir.join("file_a_dt.txt"); - let f2 = dir.join("file_b_dt.txt"); - std::fs::write(&f1, "line1\nline2\n").unwrap(); - std::fs::write(&f2, "line1\nline3\n").unwrap(); - +fn test_cc_jump() { let mut engine = Engine::new(); - engine - .open_file_with_mode(&f1, OpenMode::Permanent) - .unwrap(); - - // Mark first window - engine.execute_command("diffthis"); - let (a_stored, _) = engine.diff_window_pair.unwrap(); - - // Open second file in a split - engine.split_window(SplitDirection::Vertical, Some(&f2)); + engine.quickfix_items = vec![ + make_qf_item("a.rs"), + make_qf_item("b.rs"), + make_qf_item("c.rs"), + ]; + engine.quickfix_open = true; - // Mark second window - engine.execute_command("diffthis"); + engine.execute_command("cc 2"); + assert_eq!( + engine.quickfix_selected, 1, + ":cc 2 should select index 1 (1-based)" + ); +} - assert!(engine.diff_window_pair.is_some()); - let (a, b) = engine.diff_window_pair.unwrap(); - assert_ne!(a, b, "pair should have two distinct windows"); - assert_eq!(a, a_stored, "first window should be preserved"); - assert!(!engine.diff_results.is_empty(), "diff results should exist"); +#[test] +fn test_grep_empty_pattern() { + let mut engine = Engine::new(); + engine.execute_command("grep "); + assert!(engine.quickfix_items.is_empty()); + assert!(engine.message.contains("Usage")); } #[test] -fn test_diffsplit_command() { - let dir = std::env::temp_dir().join("vimcode_diffsplit_vc"); +fn test_grep_no_matches() { + let dir = std::env::temp_dir().join("vimcode_qf_no_match"); std::fs::create_dir_all(&dir).unwrap(); - let f1 = dir.join("src_ds.txt"); - let f2 = dir.join("cmp_ds.txt"); - std::fs::write(&f1, "alpha\nbeta\n").unwrap(); - std::fs::write(&f2, "alpha\ngamma\n").unwrap(); - let mut engine = Engine::new(); - engine - .open_file_with_mode(&f1, OpenMode::Permanent) - .unwrap(); + engine.cwd = dir.clone(); + engine.execute_command("grep xyzzy_no_match_anywhere_qf_test"); + assert_eq!(engine.quickfix_items.len(), 0); + assert!(engine.message.contains("0 match")); +} - let initial_win_count = engine.active_tab().layout.window_ids().len(); +#[test] +fn test_grep_populates_quickfix() { + use std::io::Write; + let dir = std::env::temp_dir().join("vimcode_qf_grep_pop"); + std::fs::create_dir_all(&dir).unwrap(); + let file_path = dir.join("qftest.rs"); + let mut f = std::fs::File::create(&file_path).unwrap(); + writeln!(f, "fn qfmain_unique_marker() {{}}").unwrap(); + drop(f); - engine.execute_command(&format!("diffsplit {}", f2.display())); + let mut engine = Engine::new(); + engine.cwd = dir.clone(); + engine.execute_command("grep qfmain_unique_marker"); - let new_win_count = engine.active_tab().layout.window_ids().len(); assert!( - new_win_count > initial_win_count, - "diffsplit should open a new window" + !engine.quickfix_items.is_empty(), + "grep should find matches" ); - assert!(engine.diff_window_pair.is_some()); - assert!(!engine.diff_results.is_empty()); + assert!(engine.quickfix_open); + assert!( + !engine.quickfix_has_focus, + "focus should return to editor after :grep" + ); + assert!(engine.message.contains("match")); } -// ── Diff toolbar + navigation tests ────────────────────────────────────── - #[test] -fn test_diff_change_regions() { - let mut engine = Engine::new(); - let dir = std::env::temp_dir().join("vimcode_diff_regions"); +fn test_vimgrep_alias() { + use std::io::Write; + let dir = std::env::temp_dir().join("vimcode_qf_vimgrep"); std::fs::create_dir_all(&dir).unwrap(); - let f1 = dir.join("a_regions.txt"); - let f2 = dir.join("b_regions.txt"); - std::fs::write(&f1, "same\nalpha\nsame\nsame\nsame\nsame\nbeta\nsame\n").unwrap(); - std::fs::write(&f2, "same\nALPHA\nsame\nsame\nsame\nsame\nBETA\nsame\n").unwrap(); + let file_path = dir.join("vgtest.rs"); + let mut f = std::fs::File::create(&file_path).unwrap(); + writeln!(f, "fn vghello_unique_marker() {{}}").unwrap(); + drop(f); - engine - .open_file_with_mode(&f1, OpenMode::Permanent) - .unwrap(); - engine.execute_command(&format!("diffsplit {}", f2.display())); + let mut engine = Engine::new(); + engine.cwd = dir.clone(); + engine.execute_command("vimgrep vghello_unique_marker"); - let win_id = engine.active_window_id(); - let regions = engine.diff_change_regions(win_id); - assert_eq!(regions.len(), 2, "should detect two change regions"); - assert_eq!(regions[0], (1, 1)); - assert_eq!(regions[1], (6, 6)); + assert!( + !engine.quickfix_items.is_empty(), + "vimgrep should work same as grep" + ); + assert!(engine.quickfix_open); } +// ─── rename_file / move_file tests ──────────────────────────────────────── + #[test] -fn test_diff_jump_next_prev() { - let mut engine = Engine::new(); - let dir = std::env::temp_dir().join("vimcode_diff_jump"); +fn test_rename_file_updates_buffer_path() { + let dir = std::env::temp_dir().join("vimcode_rename_upd"); std::fs::create_dir_all(&dir).unwrap(); - let f1 = dir.join("a_jump.txt"); - let f2 = dir.join("b_jump.txt"); - std::fs::write(&f1, "same\nold1\nsame\nsame\nsame\nsame\nold2\nsame\n").unwrap(); - std::fs::write(&f2, "same\nnew1\nsame\nsame\nsame\nsame\nnew2\nsame\n").unwrap(); + let old = dir.join("rename_old.txt"); + std::fs::write(&old, "hello").unwrap(); + let mut engine = Engine::new(); engine - .open_file_with_mode(&f1, OpenMode::Permanent) + .open_file_with_mode(&old, OpenMode::Permanent) .unwrap(); - engine.execute_command(&format!("diffsplit {}", f2.display())); - - // Cursor starts at line 0. - engine.view_mut().cursor.line = 0; - engine.view_mut().cursor.col = 0; - - // Jump to next change — should land on first change (line 1). - engine.jump_next_hunk(); - assert_eq!(engine.view().cursor.line, 1); - // Jump to next change — should land on second change (line 6). - engine.jump_next_hunk(); - assert_eq!(engine.view().cursor.line, 6); + engine.rename_file(&old, "rename_new.txt").unwrap(); - // Jump to next change — should wrap to first (line 1). - engine.jump_next_hunk(); - assert_eq!(engine.view().cursor.line, 1); - assert!(engine.message.contains("Wrapped")); + let new = dir.join("rename_new.txt"); + assert!(new.exists(), "new path should exist"); + assert!(!old.exists(), "old path should be gone"); - // Jump to prev change — should wrap to last (line 6). - engine.jump_prev_hunk(); - assert_eq!(engine.view().cursor.line, 6); + // The open buffer's file_path should have been updated + let updated = engine.buffer_manager.list().into_iter().any(|id| { + engine + .buffer_manager + .get(id) + .and_then(|s| s.file_path.as_ref()) + == Some(&new) + }); + assert!(updated, "open buffer should point to new path"); } #[test] -fn test_diff_toggle_hide_unchanged() { +fn test_rename_file_not_found() { let mut engine = Engine::new(); - let dir = std::env::temp_dir().join("vimcode_diff_fold"); - std::fs::create_dir_all(&dir).unwrap(); - let f1 = dir.join("a_fold.txt"); - let f2 = dir.join("b_fold.txt"); - // 10 same lines, then 1 changed, then 10 same lines. - let same_block = "s\n".repeat(10); - std::fs::write(&f1, format!("{same_block}old\n{same_block}")).unwrap(); - std::fs::write(&f2, format!("{same_block}new\n{same_block}")).unwrap(); - - engine - .open_file_with_mode(&f1, OpenMode::Permanent) - .unwrap(); - engine.execute_command(&format!("diffsplit {}", f2.display())); - - // diff_unchanged_hidden is auto-enabled by diffsplit. - assert!(engine.diff_unchanged_hidden); - - // Both windows should have folds. - let (a, b) = engine.diff_window_pair.unwrap(); - let a_folds = &engine.windows.get(&a).unwrap().view.folds; - let b_folds = &engine.windows.get(&b).unwrap().view.folds; - assert!(!a_folds.is_empty(), "window A should have folds"); - assert!(!b_folds.is_empty(), "window B should have folds"); - - // Toggle back — folds should be cleared. - engine.diff_toggle_hide_unchanged(); - assert!(!engine.diff_unchanged_hidden); - let a_folds = &engine.windows.get(&a).unwrap().view.folds; - let b_folds = &engine.windows.get(&b).unwrap().view.folds; - assert!(a_folds.is_empty(), "window A folds should be cleared"); - assert!(b_folds.is_empty(), "window B folds should be cleared"); + let result = engine.rename_file(Path::new("/vimcode_nonexistent_xyz/file.txt"), "new.txt"); + assert!(result.is_err(), "renaming missing file should fail"); } #[test] -fn test_diff_aligned_scroll_sync() { +fn test_rename_file_empty_name() { let mut engine = Engine::new(); - let dir = std::env::temp_dir().join("vimcode_diff_aligned_scroll"); - std::fs::create_dir_all(&dir).unwrap(); - let f1 = dir.join("a_scroll.txt"); - let f2 = dir.join("b_scroll.txt"); - // Left: 5 same lines, then "old", then 5 same lines. - // Right: 5 same lines, then 10 new lines, then "new", then 5 same lines. - // This creates a large padding block on the left side. - let same5 = "s\n".repeat(5); - let added10 = "added\n".repeat(10); - std::fs::write(&f1, format!("{same5}old\n{same5}")).unwrap(); - std::fs::write(&f2, format!("{same5}{added10}new\n{same5}")).unwrap(); - - engine - .open_file_with_mode(&f1, OpenMode::Permanent) - .unwrap(); - engine.execute_command(&format!("diffsplit {}", f2.display())); + let result = engine.rename_file(Path::new("/tmp/whatever.txt"), ""); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("empty")); +} - let (a, b) = engine.diff_window_pair.unwrap(); - // Both windows should have aligned data. - assert!(engine.diff_aligned.contains_key(&a)); - assert!(engine.diff_aligned.contains_key(&b)); +#[test] +fn test_move_file_basic() { + let base = std::env::temp_dir().join("vimcode_move_basic"); + let dest = base.join("subdir_mv"); + std::fs::create_dir_all(&dest).unwrap(); + let src = base.join("moveme.txt"); + std::fs::write(&src, "data").unwrap(); - // Scroll the right window (b) down and sync. - engine.active_tab_mut().active_window = b; - engine.windows.get_mut(&b).unwrap().view.scroll_top = 8; - engine.sync_scroll_binds(); + let mut engine = Engine::new(); + engine.move_file(&src, &dest).unwrap(); - // Left window should have been mapped through aligned data, - // not set to the raw scroll_top value of 8. - let a_scroll = engine.windows.get(&a).unwrap().view.scroll_top; - // The left file only has 11 lines (5 same + "old" + 5 same), - // so a raw copy of 8 would be near the end. The aligned mapping - // should produce a smaller value since the padding absorbs the offset. - assert!( - a_scroll < 8, - "expected aligned scroll mapping to give a_scroll < 8, got {a_scroll}" - ); + assert!(!src.exists(), "source should be gone"); + assert!(dest.join("moveme.txt").exists(), "file should be in dest"); } #[test] -fn test_diff_current_change_index() { +fn test_move_file_invalid_dest() { let mut engine = Engine::new(); - let dir = std::env::temp_dir().join("vimcode_diff_idx"); - std::fs::create_dir_all(&dir).unwrap(); - let f1 = dir.join("a_idx.txt"); - let f2 = dir.join("b_idx.txt"); - std::fs::write(&f1, "s\nold1\ns\ns\ns\ns\nold2\ns\ns\ns\ns\nold3\ns\n").unwrap(); - std::fs::write(&f2, "s\nnew1\ns\ns\ns\ns\nnew2\ns\ns\ns\ns\nnew3\ns\n").unwrap(); + let result = engine.move_file( + Path::new("/tmp/whatever.txt"), + Path::new("/tmp/not_a_real_dir_xyz_vc"), + ); + assert!(result.is_err()); +} - engine - .open_file_with_mode(&f1, OpenMode::Permanent) - .unwrap(); - engine.execute_command(&format!("diffsplit {}", f2.display())); +#[test] +fn test_confirm_move_shows_dialog() { + let base = std::env::temp_dir().join("vimcode_confirm_move_dlg"); + let dest = base.join("target_dir"); + std::fs::create_dir_all(&dest).unwrap(); + let src = base.join("confirm_me.txt"); + std::fs::write(&src, "data").unwrap(); - // At line 0 (before first change). - engine.view_mut().cursor.line = 0; - let idx = engine.diff_current_change_index(); - assert_eq!(idx, Some((1, 3))); // closest after is first change + let mut engine = Engine::new(); + engine.confirm_move_file(&src, &dest); - // At line 1 (in first change). - engine.view_mut().cursor.line = 1; - let idx = engine.diff_current_change_index(); - assert_eq!(idx, Some((1, 3))); + // Dialog should be shown. + assert!(engine.dialog.is_some()); + let dialog = engine.dialog.as_ref().unwrap(); + assert_eq!(dialog.tag, "confirm_move"); + assert!(dialog.body[0].contains("confirm_me.txt")); + assert_eq!(dialog.buttons.len(), 2); - // At line 6 (in second change). - engine.view_mut().cursor.line = 6; - let idx = engine.diff_current_change_index(); - assert_eq!(idx, Some((2, 3))); + // Pending move should be stored. + assert!(engine.pending_move.is_some()); + let (ps, pd) = engine.pending_move.as_ref().unwrap(); + assert_eq!(ps, &src); + assert_eq!(pd, &dest); - // At line 11 (in third change). - engine.view_mut().cursor.line = 11; - let idx = engine.diff_current_change_index(); - assert_eq!(idx, Some((3, 3))); + // Simulate pressing 'y' (Yes) — dialog handles it. + let _action = engine.handle_key("y", Some('y'), false); + + // File should have been moved. + assert!(!src.exists()); + assert!(dest.join("confirm_me.txt").exists()); + assert!(engine.dialog.is_none()); + assert!(engine.pending_move.is_none()); + assert!(engine.explorer_needs_refresh); + + // Cleanup + let _ = std::fs::remove_dir_all(&base); } #[test] -fn test_jump_hunk_delegates_in_diff_mode() { +fn test_confirm_move_cancel() { + let base = std::env::temp_dir().join("vimcode_confirm_move_cancel"); + let dest = base.join("target_dir_c"); + std::fs::create_dir_all(&dest).unwrap(); + let src = base.join("stay_put.txt"); + std::fs::write(&src, "data").unwrap(); + let mut engine = Engine::new(); - let dir = std::env::temp_dir().join("vimcode_diff_delegate"); - std::fs::create_dir_all(&dir).unwrap(); - let f1 = dir.join("a_deleg.txt"); - let f2 = dir.join("b_deleg.txt"); - std::fs::write(&f1, "same\nold\nsame\n").unwrap(); - std::fs::write(&f2, "same\nnew\nsame\n").unwrap(); + engine.confirm_move_file(&src, &dest); - engine - .open_file_with_mode(&f1, OpenMode::Permanent) - .unwrap(); - engine.execute_command(&format!("diffsplit {}", f2.display())); + // Simulate pressing 'n' (No). + let _action = engine.handle_key("n", Some('n'), false); - // ]c should use diff_results, not git_diff. - engine.view_mut().cursor.line = 0; - engine.jump_next_hunk(); - assert_eq!( - engine.view().cursor.line, - 1, - "]c should jump to diff change region" - ); + // File should NOT have been moved. + assert!(src.exists()); + assert!(!dest.join("stay_put.txt").exists()); + assert!(engine.dialog.is_none()); + assert!(engine.pending_move.is_none()); + assert!(!engine.explorer_needs_refresh); + + // Cleanup + let _ = std::fs::remove_dir_all(&base); } #[test] -fn test_diffthis_toolbar_and_scroll_sync() { +fn test_move_file_into_own_subtree() { + let base = std::env::temp_dir().join("vimcode_move_subtree"); + let parent = base.join("parent_dir"); + let child = parent.join("child_dir"); + std::fs::create_dir_all(&child).unwrap(); + let mut engine = Engine::new(); - let dir = std::env::temp_dir().join("vimcode_diffthis_toolbar"); - std::fs::create_dir_all(&dir).unwrap(); - let f1 = dir.join("a_dt.txt"); - let f2 = dir.join("b_dt.txt"); - std::fs::write(&f1, "same\nold1\nsame\nsame\nsame\nsame\nold2\nsame\n").unwrap(); - std::fs::write(&f2, "same\nnew1\nsame\nsame\nsame\nsame\nnew2\nsame\n").unwrap(); + let result = engine.move_file(&parent, &child); + assert!(result.is_err()); + assert!( + result.unwrap_err().contains("subtree"), + "should reject moving folder into its own subtree" + ); - // Open first file and run :diffthis. - engine - .open_file_with_mode(&f1, OpenMode::Permanent) - .unwrap(); - let win_a = engine.active_window_id(); - engine.execute_command("diffthis"); - // Placeholder state: a == a, is_in_diff_view should be false. - assert!(!engine.is_in_diff_view()); + // Cleanup + let _ = std::fs::remove_dir_all(&base); +} - // Open second file in a split and run :diffthis. - engine.execute_command(&format!("vs {}", f2.display())); - let win_b = engine.active_window_id(); - assert_ne!(win_a, win_b); - engine.execute_command("diffthis"); +#[test] +fn test_move_file_same_directory_noop() { + let base = std::env::temp_dir().join("vimcode_move_noop"); + std::fs::create_dir_all(&base).unwrap(); + let src = base.join("stay.txt"); + std::fs::write(&src, "stay").unwrap(); - // Now diff should be active. - assert!(engine.is_in_diff_view()); - assert!(engine.diff_window_pair.is_some()); - let (a, b) = engine.diff_window_pair.unwrap(); - assert_ne!(a, b); + let mut engine = Engine::new(); + // Moving a file into the directory it's already in should be a no-op. + let result = engine.move_file(&src, &base); + assert!(result.is_ok()); + assert!(src.exists(), "file should still be at original location"); - // diff_results should be populated. - assert!(!engine.diff_results.is_empty()); - let regions = engine.diff_change_regions(engine.active_window_id()); - assert_eq!(regions.len(), 2, "should detect two change regions"); + // Cleanup + let _ = std::fs::remove_dir_all(&base); +} - // Scroll binding should be set up (added by diffthis). +#[test] +fn test_move_directory_basic() { + let base = std::env::temp_dir().join("vimcode_move_dir"); + let src = base.join("src_dir"); + let dest = base.join("dest_dir"); + std::fs::create_dir_all(&src).unwrap(); + std::fs::create_dir_all(&dest).unwrap(); + std::fs::write(src.join("file.txt"), "content").unwrap(); + + let mut engine = Engine::new(); + engine.move_file(&src, &dest).unwrap(); + + assert!(!src.exists(), "source dir should be gone"); assert!( - engine - .scroll_bind_pairs - .iter() - .any(|&(x, y)| (x == a && y == b) || (x == b && y == a)), - "diffthis should register scroll binding" + dest.join("src_dir").join("file.txt").exists(), + "dir should be moved with contents" ); - // diff_current_change_index should return data for toolbar. - let idx = engine.diff_current_change_index(); - assert!(idx.is_some(), "toolbar should have change index data"); + // Cleanup + let _ = std::fs::remove_dir_all(&base); } #[test] -fn test_diffthis_across_editor_groups() { - let mut engine = Engine::new(); - let dir = std::env::temp_dir().join("vimcode_diffthis_groups"); - std::fs::create_dir_all(&dir).unwrap(); - let f1 = dir.join("a_grp.txt"); - let f2 = dir.join("b_grp.txt"); - std::fs::write(&f1, "same\nold\nsame\n").unwrap(); - std::fs::write(&f2, "same\nnew\nsame\n").unwrap(); - - // Open first file and mark for diff. - engine - .open_file_with_mode(&f1, OpenMode::Permanent) - .unwrap(); - let win_a = engine.active_window_id(); - engine.execute_command("diffthis"); +fn test_move_file_updates_buffer_path() { + let base = std::env::temp_dir().join("vimcode_move_bufupd"); + let dest = base.join("dest_mv"); + std::fs::create_dir_all(&dest).unwrap(); + let src = base.join("tracked.txt"); + std::fs::write(&src, "tracked").unwrap(); - // Split into a new editor group and open the second file. - engine.open_editor_group(SplitDirection::Vertical); + let mut engine = Engine::new(); engine - .open_file_with_mode(&f2, OpenMode::Permanent) + .open_file_with_mode(&src, OpenMode::Permanent) .unwrap(); - let win_b = engine.active_window_id(); - assert_ne!(win_a, win_b); - - // Mark second window for diff. - engine.execute_command("diffthis"); - assert!(engine.is_in_diff_view()); - let (a, b) = engine.diff_window_pair.unwrap(); - assert_ne!(a, b); - // Verify both windows are in different groups. - let group_ids = engine.group_layout.group_ids(); - assert!(group_ids.len() >= 2, "should have at least 2 editor groups"); + engine.move_file(&src, &dest).unwrap(); - // Verify each group contains one of the diff windows. - for &gid in &group_ids { - if let Some(group) = engine.editor_groups.get(&gid) { - let wids = group.active_tab().layout.window_ids(); - let has_diff = wids.contains(&a) || wids.contains(&b); - if has_diff { - // This group should be detected by is_in_diff_view's logic - assert!(engine.is_in_diff_view(), "diff view should be detected"); - } - } - } + let expected = dest.join("tracked.txt"); + let updated = engine.buffer_manager.list().into_iter().any(|id| { + engine + .buffer_manager + .get(id) + .and_then(|s| s.file_path.as_ref()) + == Some(&expected) + }); + assert!( + updated, + "open buffer should point to new location after move" + ); - // Verify diff results have data. - let regions = engine.diff_change_regions(b); - assert!(!regions.is_empty(), "should detect changes"); + // Cleanup + let _ = std::fs::remove_dir_all(&base); } -// ── cmd_git_diff_split tests ──────────────────────────────────────────── +// ─── LCS diff tests ─────────────────────────────────────────────────────── -/// Create a temp git repo with one committed file, then modify it. -/// Returns (repo_dir, file_path). +#[test] +fn test_lcs_diff_same_content() { + let a = &["alpha", "beta", "gamma"]; + let b = &["alpha", "beta", "gamma"]; + let (da, db) = lcs_diff(a, b); + assert!(da.iter().all(|s| *s == DiffLine::Same)); + assert!(db.iter().all(|s| *s == DiffLine::Same)); + assert_eq!(da.len(), 3); + assert_eq!(db.len(), 3); +} + +#[test] +fn test_lcs_diff_added_line() { + let a = &["alpha", "gamma"]; + let b = &["alpha", "beta", "gamma"]; + let (da, db) = lcs_diff(a, b); + assert!(da.iter().all(|s| *s == DiffLine::Same)); + assert_eq!(db[0], DiffLine::Same); + assert_eq!(db[1], DiffLine::Added); + assert_eq!(db[2], DiffLine::Same); +} + +#[test] +fn test_lcs_diff_removed_line() { + let a = &["alpha", "beta", "gamma"]; + let b = &["alpha", "gamma"]; + let (da, db) = lcs_diff(a, b); + assert_eq!(da[0], DiffLine::Same); + assert_eq!(da[1], DiffLine::Removed); + assert_eq!(da[2], DiffLine::Same); + assert!(db.iter().all(|s| *s == DiffLine::Same)); +} + +#[test] +fn test_myers_diff_two_change_blocks() { + // Mirrors the README vs README2 scenario: two distinct change blocks + // separated by 2 unchanged lines — must NOT merge into one block. + let a = &[ + "# DemoConsoleGame", + "Simple demo game", + "", + "In the end he did get a degree", + "", + "Now he's doing a masters", + "", + "So, now the only point", + ]; + let b = &[ + "# DemoConsoleGame", + "Simple demo game", + "", + "In the end he did ge asdfadt a degree", + "", + "", + "asds", + "", + "zdxfasd", + "", + "", + "Now he's doing a masters", + "", + "asdfsd", + "", + "So, now the only point", + ]; + let (da, db) = lcs_diff(a, b); + // Line 3 of a should be Removed, line 3 of b should be Added (changed line). + assert_eq!(da[3], DiffLine::Removed); + assert_eq!(db[3], DiffLine::Added); + // Lines 5-10 of b should be Added (inserted block). + for i in 5..11 { + assert_eq!(db[i], DiffLine::Added, "b[{}] should be Added", i); + } + // a[5] "Now he's doing..." should be Same (not merged). + assert_eq!(da[5], DiffLine::Same); + assert_eq!(db[11], DiffLine::Same); + // b[13] "asdfsd" should be Added (second change block). + assert_eq!(db[13], DiffLine::Added); +} + +#[test] +fn test_lcs_diff_changed_line() { + let a = &["hello world"]; + let b = &["hello rust"]; + let (da, db) = lcs_diff(a, b); + assert_eq!(da[0], DiffLine::Removed); + assert_eq!(db[0], DiffLine::Added); +} + +#[test] +fn test_lcs_diff_empty() { + let (da, db) = lcs_diff(&[], &[]); + assert!(da.is_empty()); + assert!(db.is_empty()); +} + +#[test] +fn test_merge_short_same_runs_blank_lines() { + // Blank lines inside an added block should not fragment it. + let a = &["header", "old", "footer"]; + let b = &["header", "new1", "", "new2", "", "new3", "footer"]; + let (da, mut db) = lcs_diff(a, b); + merge_short_same_runs(&mut db, DiffLine::Added); + // All lines between header and footer should be Added on the b side. + assert_eq!(db[0], DiffLine::Same, "header"); + for i in 1..6 { + assert_eq!(db[i], DiffLine::Added, "line {i} should be Added"); + } + assert_eq!(db[6], DiffLine::Same, "footer"); + // A side should still have Removed for 'old'. + assert_eq!(da[0], DiffLine::Same); + assert_eq!(da[1], DiffLine::Removed); + assert_eq!(da[2], DiffLine::Same); +} + +#[test] +fn test_merge_short_same_runs_common_lines() { + // Short runs of common lines (braces, imports) between changes should + // be absorbed into the surrounding change region. + let a = &["header", "}", "footer"]; + let b = &["header", "new1", "}", "new2", "footer"]; + let (_da, mut db) = lcs_diff(a, b); + merge_short_same_runs(&mut db, DiffLine::Added); + assert_eq!(db[0], DiffLine::Same, "header"); + // "new1", "}", "new2" should all be Added (} is a short Same island). + for i in 1..4 { + assert_eq!(db[i], DiffLine::Added, "line {i} should be Added"); + } + assert_eq!(db[4], DiffLine::Same, "footer"); +} + +#[test] +fn test_build_aligned_diff_unequal_same_tails() { + // Regression: when one side has more Same lines than the other, + // build_aligned_diff must not loop forever. + use DiffLine::*; + let da = vec![Same, Same, Removed, Same, Same]; + let db = vec![Same, Same, Same]; + // This used to hang — the fix ensures progress when one side is + // exhausted while the other still has Same lines. + let (aa, ab) = build_aligned_diff(&da, &db); + assert_eq!(aa.len(), ab.len()); +} + +#[test] +fn test_build_aligned_diff_basic() { + use DiffLine::*; + let da = vec![Same, Removed, Same]; + let db = vec![Same, Added, Same]; + let (aa, ab) = build_aligned_diff(&da, &db); + assert_eq!(aa.len(), ab.len()); + // First and last should map to source lines. + assert!(aa[0].source_line.is_some()); + assert!(ab[0].source_line.is_some()); +} + +#[test] +fn test_lcs_diff_large_files_with_small_diff() { + // Regression: files >5000 lines used to return all-Same due to MAX_LINES guard. + let mut a_lines: Vec = (0..8000).map(|i| format!("line {i}")).collect(); + let mut b_lines = a_lines.clone(); + // Insert 3 new lines in the middle of b. + b_lines.insert(4000, "new line 1".to_string()); + b_lines.insert(4001, "new line 2".to_string()); + b_lines.insert(4002, "new line 3".to_string()); + // Also change one line. + a_lines[100] = "original line 100".to_string(); + b_lines[100] = "modified line 100".to_string(); + + let a_refs: Vec<&str> = a_lines.iter().map(String::as_str).collect(); + let b_refs: Vec<&str> = b_lines.iter().map(String::as_str).collect(); + let (da, db) = lcs_diff(&a_refs, &b_refs); + + // Should detect actual changes, not return all-Same. + let a_changes = da.iter().filter(|d| **d != DiffLine::Same).count(); + let b_changes = db.iter().filter(|d| **d != DiffLine::Same).count(); + assert!( + a_changes > 0 || b_changes > 0, + "diff should detect changes in large files" + ); + // Specifically: b should have 3 Added lines + 1 changed line. + assert!(b_changes >= 3, "b should have at least 3 Added lines"); +} + +// ─── cmd_diffthis / cmd_diffoff / cmd_diffsplit tests ───────────────────── + +#[test] +fn test_diffthis_one_window_then_diffoff() { + let mut engine = Engine::new(); + engine.execute_command("diffthis"); + assert!(engine.diff_window_pair.is_some()); + engine.execute_command("diffoff"); + assert!(engine.diff_window_pair.is_none()); + assert!(engine.diff_results.is_empty()); +} + +#[test] +fn test_diffthis_two_windows() { + let dir = std::env::temp_dir().join("vimcode_diffthis_two"); + std::fs::create_dir_all(&dir).unwrap(); + let f1 = dir.join("file_a_dt.txt"); + let f2 = dir.join("file_b_dt.txt"); + std::fs::write(&f1, "line1\nline2\n").unwrap(); + std::fs::write(&f2, "line1\nline3\n").unwrap(); + + let mut engine = Engine::new(); + engine + .open_file_with_mode(&f1, OpenMode::Permanent) + .unwrap(); + + // Mark first window + engine.execute_command("diffthis"); + let (a_stored, _) = engine.diff_window_pair.unwrap(); + + // Open second file in a split + engine.split_window(SplitDirection::Vertical, Some(&f2)); + + // Mark second window + engine.execute_command("diffthis"); + + assert!(engine.diff_window_pair.is_some()); + let (a, b) = engine.diff_window_pair.unwrap(); + assert_ne!(a, b, "pair should have two distinct windows"); + assert_eq!(a, a_stored, "first window should be preserved"); + assert!(!engine.diff_results.is_empty(), "diff results should exist"); +} + +#[test] +fn test_diffsplit_command() { + let dir = std::env::temp_dir().join("vimcode_diffsplit_vc"); + std::fs::create_dir_all(&dir).unwrap(); + let f1 = dir.join("src_ds.txt"); + let f2 = dir.join("cmp_ds.txt"); + std::fs::write(&f1, "alpha\nbeta\n").unwrap(); + std::fs::write(&f2, "alpha\ngamma\n").unwrap(); + + let mut engine = Engine::new(); + engine + .open_file_with_mode(&f1, OpenMode::Permanent) + .unwrap(); + + let initial_win_count = engine.active_tab().layout.window_ids().len(); + + engine.execute_command(&format!("diffsplit {}", f2.display())); + + let new_win_count = engine.active_tab().layout.window_ids().len(); + assert!( + new_win_count > initial_win_count, + "diffsplit should open a new window" + ); + assert!(engine.diff_window_pair.is_some()); + assert!(!engine.diff_results.is_empty()); +} + +// ── Diff toolbar + navigation tests ────────────────────────────────────── + +#[test] +fn test_diff_change_regions() { + let mut engine = Engine::new(); + let dir = std::env::temp_dir().join("vimcode_diff_regions"); + std::fs::create_dir_all(&dir).unwrap(); + let f1 = dir.join("a_regions.txt"); + let f2 = dir.join("b_regions.txt"); + std::fs::write(&f1, "same\nalpha\nsame\nsame\nsame\nsame\nbeta\nsame\n").unwrap(); + std::fs::write(&f2, "same\nALPHA\nsame\nsame\nsame\nsame\nBETA\nsame\n").unwrap(); + + engine + .open_file_with_mode(&f1, OpenMode::Permanent) + .unwrap(); + engine.execute_command(&format!("diffsplit {}", f2.display())); + + let win_id = engine.active_window_id(); + let regions = engine.diff_change_regions(win_id); + assert_eq!(regions.len(), 2, "should detect two change regions"); + assert_eq!(regions[0], (1, 1)); + assert_eq!(regions[1], (6, 6)); +} + +#[test] +fn test_diff_jump_next_prev() { + let mut engine = Engine::new(); + let dir = std::env::temp_dir().join("vimcode_diff_jump"); + std::fs::create_dir_all(&dir).unwrap(); + let f1 = dir.join("a_jump.txt"); + let f2 = dir.join("b_jump.txt"); + std::fs::write(&f1, "same\nold1\nsame\nsame\nsame\nsame\nold2\nsame\n").unwrap(); + std::fs::write(&f2, "same\nnew1\nsame\nsame\nsame\nsame\nnew2\nsame\n").unwrap(); + + engine + .open_file_with_mode(&f1, OpenMode::Permanent) + .unwrap(); + engine.execute_command(&format!("diffsplit {}", f2.display())); + + // Cursor starts at line 0. + engine.view_mut().cursor.line = 0; + engine.view_mut().cursor.col = 0; + + // Jump to next change — should land on first change (line 1). + engine.jump_next_hunk(); + assert_eq!(engine.view().cursor.line, 1); + + // Jump to next change — should land on second change (line 6). + engine.jump_next_hunk(); + assert_eq!(engine.view().cursor.line, 6); + + // Jump to next change — should wrap to first (line 1). + engine.jump_next_hunk(); + assert_eq!(engine.view().cursor.line, 1); + assert!(engine.message.contains("Wrapped")); + + // Jump to prev change — should wrap to last (line 6). + engine.jump_prev_hunk(); + assert_eq!(engine.view().cursor.line, 6); +} + +#[test] +fn test_diff_toggle_hide_unchanged() { + let mut engine = Engine::new(); + let dir = std::env::temp_dir().join("vimcode_diff_fold"); + std::fs::create_dir_all(&dir).unwrap(); + let f1 = dir.join("a_fold.txt"); + let f2 = dir.join("b_fold.txt"); + // 10 same lines, then 1 changed, then 10 same lines. + let same_block = "s\n".repeat(10); + std::fs::write(&f1, format!("{same_block}old\n{same_block}")).unwrap(); + std::fs::write(&f2, format!("{same_block}new\n{same_block}")).unwrap(); + + engine + .open_file_with_mode(&f1, OpenMode::Permanent) + .unwrap(); + engine.execute_command(&format!("diffsplit {}", f2.display())); + + // diff_unchanged_hidden is auto-enabled by diffsplit. + assert!(engine.diff_unchanged_hidden); + + // Both windows should have folds. + let (a, b) = engine.diff_window_pair.unwrap(); + let a_folds = &engine.windows.get(&a).unwrap().view.folds; + let b_folds = &engine.windows.get(&b).unwrap().view.folds; + assert!(!a_folds.is_empty(), "window A should have folds"); + assert!(!b_folds.is_empty(), "window B should have folds"); + + // Toggle back — folds should be cleared. + engine.diff_toggle_hide_unchanged(); + assert!(!engine.diff_unchanged_hidden); + let a_folds = &engine.windows.get(&a).unwrap().view.folds; + let b_folds = &engine.windows.get(&b).unwrap().view.folds; + assert!(a_folds.is_empty(), "window A folds should be cleared"); + assert!(b_folds.is_empty(), "window B folds should be cleared"); +} + +#[test] +fn test_diff_aligned_scroll_sync() { + let mut engine = Engine::new(); + let dir = std::env::temp_dir().join("vimcode_diff_aligned_scroll"); + std::fs::create_dir_all(&dir).unwrap(); + let f1 = dir.join("a_scroll.txt"); + let f2 = dir.join("b_scroll.txt"); + // Left: 5 same lines, then "old", then 5 same lines. + // Right: 5 same lines, then 10 new lines, then "new", then 5 same lines. + // This creates a large padding block on the left side. + let same5 = "s\n".repeat(5); + let added10 = "added\n".repeat(10); + std::fs::write(&f1, format!("{same5}old\n{same5}")).unwrap(); + std::fs::write(&f2, format!("{same5}{added10}new\n{same5}")).unwrap(); + + engine + .open_file_with_mode(&f1, OpenMode::Permanent) + .unwrap(); + engine.execute_command(&format!("diffsplit {}", f2.display())); + + let (a, b) = engine.diff_window_pair.unwrap(); + // Both windows should have aligned data. + assert!(engine.diff_aligned.contains_key(&a)); + assert!(engine.diff_aligned.contains_key(&b)); + + // Scroll the right window (b) down and sync. + engine.active_tab_mut().active_window = b; + engine.windows.get_mut(&b).unwrap().view.scroll_top = 8; + engine.sync_scroll_binds(); + + // Left window should have been mapped through aligned data, + // not set to the raw scroll_top value of 8. + let a_scroll = engine.windows.get(&a).unwrap().view.scroll_top; + // The left file only has 11 lines (5 same + "old" + 5 same), + // so a raw copy of 8 would be near the end. The aligned mapping + // should produce a smaller value since the padding absorbs the offset. + assert!( + a_scroll < 8, + "expected aligned scroll mapping to give a_scroll < 8, got {a_scroll}" + ); +} + +#[test] +fn test_diff_current_change_index() { + let mut engine = Engine::new(); + let dir = std::env::temp_dir().join("vimcode_diff_idx"); + std::fs::create_dir_all(&dir).unwrap(); + let f1 = dir.join("a_idx.txt"); + let f2 = dir.join("b_idx.txt"); + std::fs::write(&f1, "s\nold1\ns\ns\ns\ns\nold2\ns\ns\ns\ns\nold3\ns\n").unwrap(); + std::fs::write(&f2, "s\nnew1\ns\ns\ns\ns\nnew2\ns\ns\ns\ns\nnew3\ns\n").unwrap(); + + engine + .open_file_with_mode(&f1, OpenMode::Permanent) + .unwrap(); + engine.execute_command(&format!("diffsplit {}", f2.display())); + + // At line 0 (before first change). + engine.view_mut().cursor.line = 0; + let idx = engine.diff_current_change_index(); + assert_eq!(idx, Some((1, 3))); // closest after is first change + + // At line 1 (in first change). + engine.view_mut().cursor.line = 1; + let idx = engine.diff_current_change_index(); + assert_eq!(idx, Some((1, 3))); + + // At line 6 (in second change). + engine.view_mut().cursor.line = 6; + let idx = engine.diff_current_change_index(); + assert_eq!(idx, Some((2, 3))); + + // At line 11 (in third change). + engine.view_mut().cursor.line = 11; + let idx = engine.diff_current_change_index(); + assert_eq!(idx, Some((3, 3))); +} + +#[test] +fn test_jump_hunk_delegates_in_diff_mode() { + let mut engine = Engine::new(); + let dir = std::env::temp_dir().join("vimcode_diff_delegate"); + std::fs::create_dir_all(&dir).unwrap(); + let f1 = dir.join("a_deleg.txt"); + let f2 = dir.join("b_deleg.txt"); + std::fs::write(&f1, "same\nold\nsame\n").unwrap(); + std::fs::write(&f2, "same\nnew\nsame\n").unwrap(); + + engine + .open_file_with_mode(&f1, OpenMode::Permanent) + .unwrap(); + engine.execute_command(&format!("diffsplit {}", f2.display())); + + // ]c should use diff_results, not git_diff. + engine.view_mut().cursor.line = 0; + engine.jump_next_hunk(); + assert_eq!( + engine.view().cursor.line, + 1, + "]c should jump to diff change region" + ); +} + +#[test] +fn test_diffthis_toolbar_and_scroll_sync() { + let mut engine = Engine::new(); + let dir = std::env::temp_dir().join("vimcode_diffthis_toolbar"); + std::fs::create_dir_all(&dir).unwrap(); + let f1 = dir.join("a_dt.txt"); + let f2 = dir.join("b_dt.txt"); + std::fs::write(&f1, "same\nold1\nsame\nsame\nsame\nsame\nold2\nsame\n").unwrap(); + std::fs::write(&f2, "same\nnew1\nsame\nsame\nsame\nsame\nnew2\nsame\n").unwrap(); + + // Open first file and run :diffthis. + engine + .open_file_with_mode(&f1, OpenMode::Permanent) + .unwrap(); + let win_a = engine.active_window_id(); + engine.execute_command("diffthis"); + // Placeholder state: a == a, is_in_diff_view should be false. + assert!(!engine.is_in_diff_view()); + + // Open second file in a split and run :diffthis. + engine.execute_command(&format!("vs {}", f2.display())); + let win_b = engine.active_window_id(); + assert_ne!(win_a, win_b); + engine.execute_command("diffthis"); + + // Now diff should be active. + assert!(engine.is_in_diff_view()); + assert!(engine.diff_window_pair.is_some()); + let (a, b) = engine.diff_window_pair.unwrap(); + assert_ne!(a, b); + + // diff_results should be populated. + assert!(!engine.diff_results.is_empty()); + let regions = engine.diff_change_regions(engine.active_window_id()); + assert_eq!(regions.len(), 2, "should detect two change regions"); + + // Scroll binding should be set up (added by diffthis). + assert!( + engine + .scroll_bind_pairs + .iter() + .any(|&(x, y)| (x == a && y == b) || (x == b && y == a)), + "diffthis should register scroll binding" + ); + + // diff_current_change_index should return data for toolbar. + let idx = engine.diff_current_change_index(); + assert!(idx.is_some(), "toolbar should have change index data"); +} + +#[test] +fn test_diffthis_across_editor_groups() { + let mut engine = Engine::new(); + let dir = std::env::temp_dir().join("vimcode_diffthis_groups"); + std::fs::create_dir_all(&dir).unwrap(); + let f1 = dir.join("a_grp.txt"); + let f2 = dir.join("b_grp.txt"); + std::fs::write(&f1, "same\nold\nsame\n").unwrap(); + std::fs::write(&f2, "same\nnew\nsame\n").unwrap(); + + // Open first file and mark for diff. + engine + .open_file_with_mode(&f1, OpenMode::Permanent) + .unwrap(); + let win_a = engine.active_window_id(); + engine.execute_command("diffthis"); + + // Split into a new editor group and open the second file. + engine.open_editor_group(SplitDirection::Vertical); + engine + .open_file_with_mode(&f2, OpenMode::Permanent) + .unwrap(); + let win_b = engine.active_window_id(); + assert_ne!(win_a, win_b); + + // Mark second window for diff. + engine.execute_command("diffthis"); + assert!(engine.is_in_diff_view()); + let (a, b) = engine.diff_window_pair.unwrap(); + assert_ne!(a, b); + + // Verify both windows are in different groups. + let group_ids = engine.group_layout.group_ids(); + assert!(group_ids.len() >= 2, "should have at least 2 editor groups"); + + // Verify each group contains one of the diff windows. + for &gid in &group_ids { + if let Some(group) = engine.editor_groups.get(&gid) { + let wids = group.active_tab().layout.window_ids(); + let has_diff = wids.contains(&a) || wids.contains(&b); + if has_diff { + // This group should be detected by is_in_diff_view's logic + assert!(engine.is_in_diff_view(), "diff view should be detected"); + } + } + } + + // Verify diff results have data. + let regions = engine.diff_change_regions(b); + assert!(!regions.is_empty(), "should detect changes"); +} + +// ── cmd_git_diff_split tests ──────────────────────────────────────────── + +/// Create a temp git repo with one committed file, then modify it. +/// Returns (repo_dir, file_path). fn setup_git_diff_split_repo(suffix: &str) -> (PathBuf, PathBuf) { use std::process::Command; let dir = std::env::temp_dir().join(format!("vimcode_gds_{suffix}")); @@ -9999,4634 +10867,5393 @@ fn setup_git_diff_split_repo(suffix: &str) -> (PathBuf, PathBuf) { .output() .unwrap(); Command::new("git") - .args(["commit", "-m", "init"]) + .args(["commit", "-m", "init"]) + .current_dir(&dir) + .output() + .unwrap(); + // Modify the file (working copy differs from HEAD) + std::fs::write( + &file, + "fn main() {\n println!(\"hello world\");\n println!(\"new line\");\n}\n", + ) + .unwrap(); + (dir, file) +} + +#[test] +fn test_git_diff_split_creates_pair() { + let (dir, file) = setup_git_diff_split_repo("creates_pair"); + let mut engine = Engine::new(); + engine.cwd = dir.clone(); + let result = engine.cmd_git_diff_split(&file); + assert!( + !matches!(result, EngineAction::Error), + "cmd_git_diff_split should succeed: {}", + engine.message + ); + // Should have 2 windows + assert_eq!(engine.active_tab().layout.window_ids().len(), 2); + // diff_window_pair should be set + assert!(engine.diff_window_pair.is_some()); + // scroll_bind_pairs should have the pair + assert!(!engine.scroll_bind_pairs.is_empty()); + // diff_results should be populated + assert!(!engine.diff_results.is_empty()); + // Both windows should have diff results with non-Same entries + let (left, right) = engine.diff_window_pair.unwrap(); + let left_results = engine.diff_results.get(&left).expect("left diff_results"); + let right_results = engine.diff_results.get(&right).expect("right diff_results"); + let left_has_changes = left_results.iter().any(|d| *d != DiffLine::Same); + let right_has_changes = right_results.iter().any(|d| *d != DiffLine::Same); + assert!( + left_has_changes, + "left should have Added/Removed entries, got {:?}", + left_results + ); + assert!( + right_has_changes, + "right should have Added/Removed entries, got {:?}", + right_results + ); +} + +#[test] +fn test_git_diff_split_left_readonly() { + let (dir, file) = setup_git_diff_split_repo("left_ro"); + let mut engine = Engine::new(); + engine.cwd = dir.clone(); + engine.cmd_git_diff_split(&file); + let (left_win, _right_win) = engine.diff_window_pair.unwrap(); + let left_buf_id = engine.windows.get(&left_win).unwrap().buffer_id; + let left_state = engine.buffer_manager.get(left_buf_id).unwrap(); + assert!(left_state.read_only, "HEAD buffer should be read-only"); +} + +#[test] +fn test_git_diff_split_head_scratch_name() { + let (dir, file) = setup_git_diff_split_repo("scratch"); + let mut engine = Engine::new(); + engine.cwd = dir.clone(); + engine.cmd_git_diff_split(&file); + let (left_win, _) = engine.diff_window_pair.unwrap(); + let left_buf_id = engine.windows.get(&left_win).unwrap().buffer_id; + let left_state = engine.buffer_manager.get(left_buf_id).unwrap(); + let name = left_state.scratch_name.as_deref().unwrap_or(""); + assert!( + name.contains("(HEAD)"), + "scratch_name should contain (HEAD), got: {name}" + ); +} + +#[test] +fn test_git_diff_split_untracked_file_errors() { + use std::process::Command; + let dir = std::env::temp_dir().join("vimcode_gds_untracked"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let dir = dir.canonicalize().unwrap(); + Command::new("git") + .args(["init"]) .current_dir(&dir) .output() .unwrap(); - // Modify the file (working copy differs from HEAD) - std::fs::write( - &file, - "fn main() {\n println!(\"hello world\");\n println!(\"new line\");\n}\n", - ) - .unwrap(); - (dir, file) + let file = dir.join("new_file.txt"); + std::fs::write(&file, "untracked content\n").unwrap(); + + let mut engine = Engine::new(); + engine.cwd = dir.clone(); + let result = engine.cmd_git_diff_split(&file); + assert!( + matches!(result, EngineAction::Error), + "untracked file should error" + ); + assert!(engine.message.contains("no HEAD version")); +} + +#[test] +fn test_close_window_cleans_diff_state() { + let (dir, file) = setup_git_diff_split_repo("close_win"); + let mut engine = Engine::new(); + engine.cwd = dir.clone(); + engine.cmd_git_diff_split(&file); + assert!(engine.diff_window_pair.is_some()); + // Close active window — should clean up diff state + engine.close_window(); + assert!( + engine.diff_window_pair.is_none(), + "diff_window_pair should be cleared after closing a diff window" + ); + assert!(engine.diff_results.is_empty()); +} + +// ── Help command tests ────────────────────────────────────────────────── + +#[test] +fn test_help_command_explorer() { + let mut engine = Engine::new(); + let initial_wins = engine.active_tab().layout.window_ids().len(); + engine.execute_command("help explorer"); + let new_wins = engine.active_tab().layout.window_ids().len(); + assert_eq!(new_wins, initial_wins + 1, "help should open a vsplit"); + let content: String = engine.buffer().content.chars().collect(); + assert!(content.contains("Explorer Sidebar")); + assert!(content.contains("Explorer Mode")); +} + +#[test] +fn test_help_command_no_args() { + let mut engine = Engine::new(); + engine.execute_command("help"); + let content: String = engine.buffer().content.chars().collect(); + assert!(content.contains("VimCode Help")); + assert!(content.contains(":help explorer")); +} + +#[test] +fn test_help_alias_h() { + let mut engine = Engine::new(); + engine.execute_command("h keys"); + let content: String = engine.buffer().content.chars().collect(); + assert!(content.contains("Normal Mode Keys")); +} + +#[test] +fn test_help_unknown_topic() { + let mut engine = Engine::new(); + let initial_wins = engine.active_tab().layout.window_ids().len(); + engine.execute_command("help nonexistent"); + let new_wins = engine.active_tab().layout.window_ids().len(); + assert_eq!( + new_wins, initial_wins, + "unknown topic should not open a split" + ); + assert!(engine.message.contains("No help for")); +} + +// ── Mouse selection tests ───────────────────────────────────────────── + +#[test] +fn test_mouse_click_exits_visual_mode() { + let mut engine = Engine::new(); + engine.buffer_mut().insert(0, "hello world"); + engine.update_syntax(); + + // Enter visual mode + press_char(&mut engine, 'v'); + assert_eq!(engine.mode, Mode::Visual); + + // Click should exit visual mode + let wid = engine.active_window_id(); + engine.mouse_click(wid, 0, 3); + assert_eq!(engine.mode, Mode::Normal); + assert!(engine.visual_anchor.is_none()); +} + +#[test] +fn test_mouse_click_positions_cursor() { + let mut engine = Engine::new(); + engine.buffer_mut().insert(0, "hello world\nsecond line"); + engine.update_syntax(); + + let wid = engine.active_window_id(); + engine.mouse_click(wid, 1, 3); + assert_eq!(engine.view().cursor.line, 1); + assert_eq!(engine.view().cursor.col, 3); +} + +#[test] +fn test_mouse_drag_enters_visual_mode() { + let mut engine = Engine::new(); + engine.buffer_mut().insert(0, "hello world"); + engine.update_syntax(); + + // Position cursor at col 2 + let wid = engine.active_window_id(); + engine.mouse_click(wid, 0, 2); + assert_eq!(engine.view().cursor.col, 2); + + // First drag should enter visual mode with anchor at current position + engine.mouse_drag(wid, 0, 5); + assert_eq!(engine.mode, Mode::Visual); + assert!(engine.mouse_drag_active); + assert_eq!(engine.visual_anchor.unwrap().col, 2); // anchor at click position + assert_eq!(engine.view().cursor.col, 5); // cursor moved to drag position +} + +#[test] +fn test_mouse_drag_extends_selection() { + let mut engine = Engine::new(); + engine.buffer_mut().insert(0, "hello world"); + engine.update_syntax(); + + let wid = engine.active_window_id(); + engine.mouse_click(wid, 0, 2); + + // First drag + engine.mouse_drag(wid, 0, 5); + let anchor = engine.visual_anchor.unwrap(); + + // Second drag should extend, keeping anchor + engine.mouse_drag(wid, 0, 8); + assert_eq!(engine.visual_anchor.unwrap(), anchor); + assert_eq!(engine.view().cursor.col, 8); +} + +#[test] +fn test_mouse_drag_multiline() { + let mut engine = Engine::new(); + engine + .buffer_mut() + .insert(0, "line one\nline two\nline three"); + engine.update_syntax(); + + let wid = engine.active_window_id(); + engine.mouse_click(wid, 0, 3); + engine.mouse_drag(wid, 2, 4); + + assert_eq!(engine.mode, Mode::Visual); + assert_eq!(engine.visual_anchor.unwrap().line, 0); + assert_eq!(engine.view().cursor.line, 2); + assert_eq!(engine.view().cursor.col, 4); +} + +#[test] +fn test_mouse_double_click_selects_word() { + let mut engine = Engine::new(); + engine.buffer_mut().insert(0, "hello world"); + engine.update_syntax(); + + let wid = engine.active_window_id(); + engine.mouse_double_click(wid, 0, 1); // in "hello" + + assert_eq!(engine.mode, Mode::Visual); + assert_eq!(engine.visual_anchor.unwrap().col, 0); // word start + assert_eq!(engine.view().cursor.col, 4); // word end (inclusive) +} + +#[test] +fn test_mouse_double_click_on_non_word() { + let mut engine = Engine::new(); + engine.buffer_mut().insert(0, "hello world"); + engine.update_syntax(); + + let wid = engine.active_window_id(); + engine.mouse_double_click(wid, 0, 5); // on space + + // Should not enter visual mode + assert_eq!(engine.mode, Mode::Normal); +} + +#[test] +fn test_mouse_click_after_drag_resets() { + let mut engine = Engine::new(); + engine.buffer_mut().insert(0, "hello world"); + engine.update_syntax(); + + let wid = engine.active_window_id(); + engine.mouse_click(wid, 0, 2); + engine.mouse_drag(wid, 0, 5); + assert_eq!(engine.mode, Mode::Visual); + + // Click should exit visual mode and reset drag + engine.mouse_click(wid, 0, 0); + assert_eq!(engine.mode, Mode::Normal); + assert!(!engine.mouse_drag_active); +} + +#[test] +fn test_double_click_then_drag_preserves_word_anchor() { + let mut engine = Engine::new(); + engine.buffer_mut().insert(0, "hello world foo bar"); + engine.update_syntax(); + + let wid = engine.active_window_id(); + + // Double-click on "world" (col 6 is inside "world") + engine.mouse_double_click(wid, 0, 6); + assert_eq!(engine.mode, Mode::Visual); + // Anchor should be at word start (col 6) + assert_eq!(engine.visual_anchor.unwrap().col, 6); + // Cursor should be at word end (col 10) + assert_eq!(engine.view().cursor.col, 10); + + // Now drag to extend selection further right + engine.mouse_drag(wid, 0, 14); + assert_eq!(engine.mode, Mode::Visual); + // Anchor should still be at word start (col 6), NOT reset + assert_eq!(engine.visual_anchor.unwrap().col, 6); + // Cursor should follow the drag + assert_eq!(engine.view().cursor.col, 14); +} + +// ── Clipboard register tests ────────────────────────────────────────── + +#[test] +fn test_clipboard_register_write() { + use std::sync::{Arc, Mutex}; + let mut engine = Engine::new(); + engine.buffer_mut().insert(0, "hello"); + engine.update_syntax(); + + let written = Arc::new(Mutex::new(String::new())); + let written_clone = written.clone(); + engine.clipboard_write = Some(Box::new(move |text: &str| { + *written_clone.lock().unwrap() = text.to_string(); + Ok(()) + })); + + engine.set_register('+', "test_data".to_string(), false); + assert_eq!(*written.lock().unwrap(), "test_data"); } #[test] -fn test_git_diff_split_creates_pair() { - let (dir, file) = setup_git_diff_split_repo("creates_pair"); +fn test_clipboard_register_read() { let mut engine = Engine::new(); - engine.cwd = dir.clone(); - let result = engine.cmd_git_diff_split(&file); + engine.buffer_mut().insert(0, "hello"); + engine.update_syntax(); + + engine.clipboard_read = Some(Box::new(|| Ok("from_clipboard".to_string()))); + + let content = engine.get_register_content('+'); + assert!(content.is_some()); + let (text, linewise) = content.unwrap(); + assert_eq!(text, "from_clipboard"); + assert!(!linewise); +} + +#[test] +fn test_paste_clipboard_to_command_buffer() { + let mut engine = Engine::new(); + engine.buffer_mut().insert(0, "hello"); + engine.update_syntax(); + + engine.clipboard_read = Some(Box::new(|| Ok("pasted_text".to_string()))); + + // Enter command mode + press_char(&mut engine, ':'); + assert_eq!(engine.mode, Mode::Command); + + engine.paste_clipboard_to_input(); + assert_eq!(engine.command_buffer, "pasted_text"); +} + +#[test] +fn test_paste_clipboard_multiline_takes_first() { + let mut engine = Engine::new(); + engine.buffer_mut().insert(0, "hello"); + engine.update_syntax(); + + engine.clipboard_read = Some(Box::new(|| Ok("first line\nsecond line".to_string()))); + + // Enter command mode + press_char(&mut engine, ':'); + engine.paste_clipboard_to_input(); + assert_eq!(engine.command_buffer, "first line"); +} + +// ── VSCode editing mode tests ──────────────────────────────────────────── + +fn make_vscode_engine(text: &str) -> Engine { + let mut engine = Engine::new(); + engine.settings.editor_mode = crate::core::settings::EditorMode::Vscode; + engine.mode = Mode::Insert; + engine.buffer_mut().insert(0, text); + engine.update_syntax(); + engine +} + +fn vscode_key(engine: &mut Engine, key_name: &str, unicode: Option, ctrl: bool) { + engine.handle_key(key_name, unicode, ctrl); +} + +#[test] +fn test_vscode_mode_setting() { + let mut s = crate::core::settings::Settings::default(); + // Default is Vim + assert_eq!(s.editor_mode, crate::core::settings::EditorMode::Vim); + s.parse_set_option("mode=vscode").unwrap(); + assert_eq!(s.editor_mode, crate::core::settings::EditorMode::Vscode); + s.parse_set_option("mode=vim").unwrap(); + assert_eq!(s.editor_mode, crate::core::settings::EditorMode::Vim); + // Query + let msg = s.parse_set_option("mode?").unwrap(); + assert_eq!(msg, "mode=vim"); + s.parse_set_option("mode=vscode").unwrap(); + let msg2 = s.parse_set_option("mode?").unwrap(); + assert_eq!(msg2, "mode=vscode"); +} + +#[test] +fn test_vscode_mode_typing() { + let mut engine = make_vscode_engine("hello"); + // Colon should insert a literal ':' not enter command mode + vscode_key(&mut engine, "", Some(':'), false); + assert_eq!(engine.mode, Mode::Insert); + assert!(engine.buffer().to_string().contains(':')); +} + +#[test] +fn test_vscode_mode_ctrl_z_undo() { + let mut engine = make_vscode_engine("hello"); + // Type 'x' + vscode_key(&mut engine, "", Some('x'), false); + let text_after = engine.buffer().to_string(); + // Ctrl-Z undo + vscode_key(&mut engine, "z", Some('z'), true); + // Should restore to "hello" + assert_ne!(engine.buffer().to_string(), text_after); +} + +#[test] +fn test_vscode_mode_ctrl_y_redo() { + let mut engine = make_vscode_engine("hello"); + // Type 'x' + vscode_key(&mut engine, "", Some('x'), false); + let after_type = engine.buffer().to_string(); + // Undo + vscode_key(&mut engine, "z", Some('z'), true); + // Redo + vscode_key(&mut engine, "y", Some('y'), true); + assert_eq!(engine.buffer().to_string(), after_type); +} + +#[test] +fn test_vscode_mode_shift_arrow_selection() { + let mut engine = make_vscode_engine("hello"); + // Shift+Right: start selection + vscode_key(&mut engine, "Shift_Right", None, false); + assert!(engine.visual_anchor.is_some()); + assert_eq!(engine.mode, Mode::Visual); + assert_eq!(engine.visual_anchor.unwrap().col, 0); + assert_eq!(engine.view().cursor.col, 1); +} + +#[test] +fn test_vscode_mode_ctrl_shift_arrow_word_select() { + let mut engine = make_vscode_engine("hello world"); + // Ctrl+Shift+Right: select word + vscode_key(&mut engine, "Shift_Right", None, true); + assert!(engine.visual_anchor.is_some()); + assert_eq!(engine.mode, Mode::Visual); + // Cursor should be past the word "hello" + assert!(engine.view().cursor.col > 0); +} + +#[test] +fn test_vscode_mode_type_replaces_selection() { + let mut engine = make_vscode_engine("hello"); + // Shift+Right+Right to select "he" + vscode_key(&mut engine, "Shift_Right", None, false); + vscode_key(&mut engine, "Shift_Right", None, false); + assert!(engine.visual_anchor.is_some()); + // Type 'X' — should replace selection + vscode_key(&mut engine, "", Some('X'), false); + assert!(engine.visual_anchor.is_none()); + assert_eq!(engine.mode, Mode::Insert); + let text = engine.buffer().to_string(); + assert!(text.starts_with('X')); + assert!(text.contains("llo")); +} + +#[test] +fn test_vscode_mode_backspace_clears_selection() { + let mut engine = make_vscode_engine("hello"); + // Shift+Right+Right to select "he" + vscode_key(&mut engine, "Shift_Right", None, false); + vscode_key(&mut engine, "Shift_Right", None, false); + assert!(engine.visual_anchor.is_some()); + // Backspace — should delete selection + vscode_key(&mut engine, "BackSpace", None, false); + assert!(engine.visual_anchor.is_none()); + let text = engine.buffer().to_string(); + assert!(text.starts_with("llo")); +} + +#[test] +fn test_vscode_mode_ctrl_a_select_all() { + let mut engine = make_vscode_engine("hello\nworld"); + engine.update_syntax(); + vscode_key(&mut engine, "a", Some('a'), true); + assert!(engine.visual_anchor.is_some()); + assert_eq!(engine.visual_anchor.unwrap().line, 0); + assert_eq!(engine.visual_anchor.unwrap().col, 0); + assert_eq!(engine.mode, Mode::Visual); + // Cursor at end of last line + assert_eq!(engine.view().cursor.line, 1); +} + +#[test] +fn test_vscode_mode_escape_clears_selection() { + let mut engine = make_vscode_engine("hello"); + vscode_key(&mut engine, "Shift_Right", None, false); + assert!(engine.visual_anchor.is_some()); + vscode_key(&mut engine, "Escape", None, false); + assert!(engine.visual_anchor.is_none()); + assert_eq!(engine.mode, Mode::Insert); +} + +#[test] +fn test_vscode_mode_ctrl_x_no_selection_cuts_line() { + let mut engine = make_vscode_engine("hello\nworld"); + engine.update_syntax(); + // Cursor on first line, no selection + assert!(engine.visual_anchor.is_none()); + vscode_key(&mut engine, "x", Some('x'), true); + // First line should be deleted + let text = engine.buffer().to_string(); assert!( - !matches!(result, EngineAction::Error), - "cmd_git_diff_split should succeed: {}", - engine.message + !text.contains("hello"), + "Line should be cut: got {:?}", + text ); - // Should have 2 windows - assert_eq!(engine.active_tab().layout.window_ids().len(), 2); - // diff_window_pair should be set - assert!(engine.diff_window_pair.is_some()); - // scroll_bind_pairs should have the pair - assert!(!engine.scroll_bind_pairs.is_empty()); - // diff_results should be populated - assert!(!engine.diff_results.is_empty()); - // Both windows should have diff results with non-Same entries - let (left, right) = engine.diff_window_pair.unwrap(); - let left_results = engine.diff_results.get(&left).expect("left diff_results"); - let right_results = engine.diff_results.get(&right).expect("right diff_results"); - let left_has_changes = left_results.iter().any(|d| *d != DiffLine::Same); - let right_has_changes = right_results.iter().any(|d| *d != DiffLine::Same); - assert!( - left_has_changes, - "left should have Added/Removed entries, got {:?}", - left_results + // Register '+' should contain the cut line + let (reg_content, _) = engine.registers.get(&'+').cloned().unwrap_or_default(); + assert!(reg_content.contains("hello")); +} + +#[test] +fn test_vscode_mode_ctrl_c_no_selection_copies_line() { + let mut engine = make_vscode_engine("hello\nworld"); + engine.update_syntax(); + // Ctrl-C with no selection: copy current line + vscode_key(&mut engine, "c", Some('c'), true); + // Buffer unchanged + assert!(engine.buffer().to_string().contains("hello")); + // Register '+' should contain the line + let (reg_content, is_linewise) = engine.registers.get(&'+').cloned().unwrap_or_default(); + assert!(reg_content.contains("hello")); + assert!(is_linewise); +} + +#[test] +fn test_vscode_mode_toggle() { + let mut engine = Engine::new(); + assert_eq!( + engine.settings.editor_mode, + crate::core::settings::EditorMode::Vim + ); + assert_eq!(engine.mode, Mode::Normal); + engine.toggle_editor_mode(); + assert_eq!( + engine.settings.editor_mode, + crate::core::settings::EditorMode::Vscode ); - assert!( - right_has_changes, - "right should have Added/Removed entries, got {:?}", - right_results + assert_eq!(engine.mode, Mode::Insert); + engine.toggle_editor_mode(); + assert_eq!( + engine.settings.editor_mode, + crate::core::settings::EditorMode::Vim ); + assert_eq!(engine.mode, Mode::Normal); } #[test] -fn test_git_diff_split_left_readonly() { - let (dir, file) = setup_git_diff_split_repo("left_ro"); - let mut engine = Engine::new(); - engine.cwd = dir.clone(); - engine.cmd_git_diff_split(&file); - let (left_win, _right_win) = engine.diff_window_pair.unwrap(); - let left_buf_id = engine.windows.get(&left_win).unwrap().buffer_id; - let left_state = engine.buffer_manager.get(left_buf_id).unwrap(); - assert!(left_state.read_only, "HEAD buffer should be read-only"); +fn test_vscode_mode_smart_home() { + let mut engine = make_vscode_engine(" hello"); + // Cursor at col 0 initially — Home moves to first non-ws + vscode_key(&mut engine, "Home", None, false); + assert_eq!(engine.view().cursor.col, 2); // first non-ws is col 2 + // Home again — moves to col 0 + vscode_key(&mut engine, "Home", None, false); + assert_eq!(engine.view().cursor.col, 0); } #[test] -fn test_git_diff_split_head_scratch_name() { - let (dir, file) = setup_git_diff_split_repo("scratch"); - let mut engine = Engine::new(); - engine.cwd = dir.clone(); - engine.cmd_git_diff_split(&file); - let (left_win, _) = engine.diff_window_pair.unwrap(); - let left_buf_id = engine.windows.get(&left_win).unwrap().buffer_id; - let left_state = engine.buffer_manager.get(left_buf_id).unwrap(); - let name = left_state.scratch_name.as_deref().unwrap_or(""); +fn test_vscode_mode_comment_toggle() { + let mut engine = make_vscode_engine("hello"); + // Set language so comment style is // (not fallback #) + let buf_id = engine.active_buffer_id(); + engine + .buffer_manager + .get_mut(buf_id) + .unwrap() + .lsp_language_id = Some("rust".to_string()); + // Ctrl+/ should add "// " prefix + vscode_key(&mut engine, "/", Some('/'), true); + let text = engine.buffer().to_string(); assert!( - name.contains("(HEAD)"), - "scratch_name should contain (HEAD), got: {name}" + text.starts_with("// hello"), + "Expected '// hello', got {:?}", + text ); -} - -#[test] -fn test_git_diff_split_untracked_file_errors() { - use std::process::Command; - let dir = std::env::temp_dir().join("vimcode_gds_untracked"); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - let dir = dir.canonicalize().unwrap(); - Command::new("git") - .args(["init"]) - .current_dir(&dir) - .output() - .unwrap(); - let file = dir.join("new_file.txt"); - std::fs::write(&file, "untracked content\n").unwrap(); - - let mut engine = Engine::new(); - engine.cwd = dir.clone(); - let result = engine.cmd_git_diff_split(&file); + // Ctrl+/ again should remove "// " + vscode_key(&mut engine, "/", Some('/'), true); + let text2 = engine.buffer().to_string(); assert!( - matches!(result, EngineAction::Error), - "untracked file should error" + text2.starts_with("hello"), + "Expected 'hello', got {:?}", + text2 ); - assert!(engine.message.contains("no HEAD version")); -} - -#[test] -fn test_close_window_cleans_diff_state() { - let (dir, file) = setup_git_diff_split_repo("close_win"); - let mut engine = Engine::new(); - engine.cwd = dir.clone(); - engine.cmd_git_diff_split(&file); - assert!(engine.diff_window_pair.is_some()); - // Close active window — should clean up diff state - engine.close_window(); + // Also test with "slash" key_name (GTK/TUI send this) + vscode_key(&mut engine, "slash", None, true); + let text3 = engine.buffer().to_string(); assert!( - engine.diff_window_pair.is_none(), - "diff_window_pair should be cleared after closing a diff window" + text3.starts_with("// hello"), + "Expected '// hello' via slash key_name, got {:?}", + text3 ); - assert!(engine.diff_results.is_empty()); -} - -// ── Help command tests ────────────────────────────────────────────────── - -#[test] -fn test_help_command_explorer() { - let mut engine = Engine::new(); - let initial_wins = engine.active_tab().layout.window_ids().len(); - engine.execute_command("help explorer"); - let new_wins = engine.active_tab().layout.window_ids().len(); - assert_eq!(new_wins, initial_wins + 1, "help should open a vsplit"); - let content: String = engine.buffer().content.chars().collect(); - assert!(content.contains("Explorer Sidebar")); - assert!(content.contains("Explorer Mode")); } #[test] -fn test_help_command_no_args() { - let mut engine = Engine::new(); - engine.execute_command("help"); - let content: String = engine.buffer().content.chars().collect(); - assert!(content.contains("VimCode Help")); - assert!(content.contains(":help explorer")); +fn test_vscode_mode_f1_opens_palette() { + let mut engine = make_vscode_engine("hello"); + // F1 should open the command palette (matches real VSCode). + vscode_key(&mut engine, "F1", None, false); + assert!(engine.picker_open, "F1 should open the command palette"); + assert_eq!(engine.mode, Mode::Insert, "mode should stay Insert"); } #[test] -fn test_help_alias_h() { - let mut engine = Engine::new(); - engine.execute_command("h keys"); - let content: String = engine.buffer().content.chars().collect(); - assert!(content.contains("Normal Mode Keys")); +fn test_vscode_mode_execute_command_returns_to_insert() { + let mut engine = make_vscode_engine("hello"); + // Execute a command directly (like via the command palette). + engine.execute_command("set number"); + // Should stay in Insert (EDIT) mode. + assert_eq!(engine.mode, Mode::Insert); + assert!(engine.is_vscode_mode()); } #[test] -fn test_help_unknown_topic() { - let mut engine = Engine::new(); - let initial_wins = engine.active_tab().layout.window_ids().len(); - engine.execute_command("help nonexistent"); - let new_wins = engine.active_tab().layout.window_ids().len(); - assert_eq!( - new_wins, initial_wins, - "unknown topic should not open a split" - ); - assert!(engine.message.contains("No help for")); +fn test_vscode_mode_f1_escape_closes_palette() { + let mut engine = make_vscode_engine("hello"); + // F1 → palette, then Escape → closes palette, stays in EDIT mode. + vscode_key(&mut engine, "F1", None, false); + assert!(engine.picker_open); + engine.handle_key("Escape", None, false); + assert!(!engine.picker_open, "Escape should close palette"); + assert_eq!(engine.mode, Mode::Insert); + assert!(engine.is_vscode_mode()); } -// ── Mouse selection tests ───────────────────────────────────────────── +// ----------------------------------------------------------------------- +// Menu bar tests +// ----------------------------------------------------------------------- #[test] -fn test_mouse_click_exits_visual_mode() { +fn test_menu_bar_toggle() { let mut engine = Engine::new(); - engine.buffer_mut().insert(0, "hello world"); - engine.update_syntax(); - - // Enter visual mode - press_char(&mut engine, 'v'); - assert_eq!(engine.mode, Mode::Visual); - - // Click should exit visual mode - let wid = engine.active_window_id(); - engine.mouse_click(wid, 0, 3); - assert_eq!(engine.mode, Mode::Normal); - assert!(engine.visual_anchor.is_none()); + assert!(!engine.menu_bar_visible, "menu bar starts hidden"); + engine.toggle_menu_bar(); + assert!(engine.menu_bar_visible, "toggle_menu_bar() should show bar"); + engine.toggle_menu_bar(); + assert!(!engine.menu_bar_visible, "second toggle hides bar"); } #[test] -fn test_mouse_click_positions_cursor() { +fn test_menu_open_close() { let mut engine = Engine::new(); - engine.buffer_mut().insert(0, "hello world\nsecond line"); - engine.update_syntax(); - - let wid = engine.active_window_id(); - engine.mouse_click(wid, 1, 3); - assert_eq!(engine.view().cursor.line, 1); - assert_eq!(engine.view().cursor.col, 3); + engine.menu_bar_visible = true; + assert_eq!(engine.menu_open_idx, None); + engine.open_menu(2); + assert_eq!( + engine.menu_open_idx, + Some(2), + "open_menu sets dropdown index" + ); + engine.close_menu(); + assert_eq!(engine.menu_open_idx, None, "close_menu clears dropdown"); + assert!(engine.menu_bar_visible, "close_menu keeps bar visible"); } #[test] -fn test_mouse_drag_enters_visual_mode() { +fn test_menu_activate_dispatches_command() { let mut engine = Engine::new(); - engine.buffer_mut().insert(0, "hello world"); - engine.update_syntax(); - - // Position cursor at col 2 - let wid = engine.active_window_id(); - engine.mouse_click(wid, 0, 2); - assert_eq!(engine.view().cursor.col, 2); - - // First drag should enter visual mode with anchor at current position - engine.mouse_drag(wid, 0, 5); - assert_eq!(engine.mode, Mode::Visual); - assert!(engine.mouse_drag_active); - assert_eq!(engine.visual_anchor.unwrap().col, 2); // anchor at click position - assert_eq!(engine.view().cursor.col, 5); // cursor moved to drag position + // Load a buffer with content so we can verify save via w command. + let tmp = std::env::temp_dir().join("vimcode_menu_test_save.txt"); + let _ = std::fs::write(&tmp, "hello"); + engine + .buffer_manager + .get_mut(engine.active_buffer_id()) + .unwrap() + .file_path = Some(tmp.clone()); + engine + .buffer_manager + .get_mut(engine.active_buffer_id()) + .unwrap() + .dirty = true; + engine.menu_bar_visible = true; + engine.menu_open_idx = Some(0); + // Activate the "Save" item (File menu, action "w") via menu_activate_item. + engine.menu_activate_item(0, 2, "w"); + assert_eq!( + engine.menu_open_idx, None, + "menu_activate_item closes dropdown" + ); + // Buffer should no longer be dirty after :w + let dirty = engine + .buffer_manager + .get(engine.active_buffer_id()) + .map(|s| s.dirty) + .unwrap_or(true); + assert!(!dirty, "buffer saved after menu activate"); + let _ = std::fs::remove_file(&tmp); } +// ── Session 82: menu navigation ──────────────────────────────────────────── + #[test] -fn test_mouse_drag_extends_selection() { +fn test_menu_item_navigation() { let mut engine = Engine::new(); - engine.buffer_mut().insert(0, "hello world"); - engine.update_syntax(); + engine.menu_bar_visible = true; + engine.open_menu(0); + assert_eq!( + engine.menu_highlighted_item, None, + "starts with no highlight" + ); - let wid = engine.active_window_id(); - engine.mouse_click(wid, 0, 2); + // Items: [non-sep(0), non-sep(1), sep(2), non-sep(3)] + let seps = [false, false, true, false]; - // First drag - engine.mouse_drag(wid, 0, 5); - let anchor = engine.visual_anchor.unwrap(); + engine.menu_move_selection(1, &seps); + assert_eq!(engine.menu_highlighted_item, Some(0), "first non-sep"); - // Second drag should extend, keeping anchor - engine.mouse_drag(wid, 0, 8); - assert_eq!(engine.visual_anchor.unwrap(), anchor); - assert_eq!(engine.view().cursor.col, 8); -} + engine.menu_move_selection(1, &seps); + assert_eq!(engine.menu_highlighted_item, Some(1), "second non-sep"); -#[test] -fn test_mouse_drag_multiline() { - let mut engine = Engine::new(); - engine - .buffer_mut() - .insert(0, "line one\nline two\nline three"); - engine.update_syntax(); + engine.menu_move_selection(1, &seps); + assert_eq!(engine.menu_highlighted_item, Some(3), "skips separator"); - let wid = engine.active_window_id(); - engine.mouse_click(wid, 0, 3); - engine.mouse_drag(wid, 2, 4); + engine.menu_move_selection(1, &seps); + assert_eq!(engine.menu_highlighted_item, Some(0), "wraps around"); - assert_eq!(engine.mode, Mode::Visual); - assert_eq!(engine.visual_anchor.unwrap().line, 0); - assert_eq!(engine.view().cursor.line, 2); - assert_eq!(engine.view().cursor.col, 4); + // Reverse direction + engine.menu_move_selection(-1, &seps); + assert_eq!(engine.menu_highlighted_item, Some(3), "reverse wrap"); } #[test] -fn test_mouse_double_click_selects_word() { +fn test_menu_activate_highlighted() { let mut engine = Engine::new(); - engine.buffer_mut().insert(0, "hello world"); - engine.update_syntax(); + engine.menu_bar_visible = true; + engine.open_menu(2); // arbitrary menu index - let wid = engine.active_window_id(); - engine.mouse_double_click(wid, 0, 1); // in "hello" + // Nothing highlighted → returns None, menu stays open + let result = engine.menu_activate_highlighted(); + assert!(result.is_none(), "None when nothing highlighted"); + assert!(engine.menu_open_idx.is_some(), "menu stays open"); - assert_eq!(engine.mode, Mode::Visual); - assert_eq!(engine.visual_anchor.unwrap().col, 0); // word start - assert_eq!(engine.view().cursor.col, 4); // word end (inclusive) + // Highlight an item, then activate + engine.menu_highlighted_item = Some(3); + let result = engine.menu_activate_highlighted(); + assert_eq!(result, Some((2, 3)), "returns (menu_idx, item_idx)"); + assert!( + engine.menu_open_idx.is_none(), + "menu is closed after activate" + ); } #[test] -fn test_mouse_double_click_on_non_word() { - let mut engine = Engine::new(); - engine.buffer_mut().insert(0, "hello world"); - engine.update_syntax(); +fn test_dap_session_active_field() { + let engine = Engine::new(); + assert!( + !engine.dap_session_active, + "dap_session_active defaults to false" + ); +} - let wid = engine.active_window_id(); - engine.mouse_double_click(wid, 0, 5); // on space +// ── Session 83: DAP transport + engine methods ───────────────────────────── - // Should not enter visual mode - assert_eq!(engine.mode, Mode::Normal); +#[test] +fn test_dap_toggle_breakpoint_add() { + use crate::core::dap::BreakpointInfo; + let mut engine = Engine::new(); + engine.dap_toggle_breakpoint("/src/main.rs", 10); + let bps = engine.dap_breakpoints.get("/src/main.rs").unwrap(); + assert_eq!(bps, &vec![BreakpointInfo::new(10)], "breakpoint added"); + assert!( + engine.message.contains("Breakpoint set"), + "{}", + engine.message + ); } #[test] -fn test_mouse_click_after_drag_resets() { +fn test_dap_toggle_breakpoint_remove() { let mut engine = Engine::new(); - engine.buffer_mut().insert(0, "hello world"); - engine.update_syntax(); - - let wid = engine.active_window_id(); - engine.mouse_click(wid, 0, 2); - engine.mouse_drag(wid, 0, 5); - assert_eq!(engine.mode, Mode::Visual); - - // Click should exit visual mode and reset drag - engine.mouse_click(wid, 0, 0); - assert_eq!(engine.mode, Mode::Normal); - assert!(!engine.mouse_drag_active); + engine.dap_toggle_breakpoint("/src/main.rs", 10); + engine.dap_toggle_breakpoint("/src/main.rs", 10); + let bps = engine.dap_breakpoints.get("/src/main.rs").unwrap(); + assert!(bps.is_empty(), "second toggle removes breakpoint"); + assert!( + engine.message.contains("Breakpoint removed"), + "{}", + engine.message + ); } #[test] -fn test_double_click_then_drag_preserves_word_anchor() { +fn test_dap_breakpoints_sorted() { let mut engine = Engine::new(); - engine.buffer_mut().insert(0, "hello world foo bar"); - engine.update_syntax(); - - let wid = engine.active_window_id(); - - // Double-click on "world" (col 6 is inside "world") - engine.mouse_double_click(wid, 0, 6); - assert_eq!(engine.mode, Mode::Visual); - // Anchor should be at word start (col 6) - assert_eq!(engine.visual_anchor.unwrap().col, 6); - // Cursor should be at word end (col 10) - assert_eq!(engine.view().cursor.col, 10); - - // Now drag to extend selection further right - engine.mouse_drag(wid, 0, 14); - assert_eq!(engine.mode, Mode::Visual); - // Anchor should still be at word start (col 6), NOT reset - assert_eq!(engine.visual_anchor.unwrap().col, 6); - // Cursor should follow the drag - assert_eq!(engine.view().cursor.col, 14); + engine.dap_toggle_breakpoint("/src/lib.rs", 30); + engine.dap_toggle_breakpoint("/src/lib.rs", 5); + engine.dap_toggle_breakpoint("/src/lib.rs", 15); + let bps = engine.dap_breakpoints.get("/src/lib.rs").unwrap(); + let lines: Vec = bps.iter().map(|b| b.line).collect(); + assert_eq!(lines, vec![5, 15, 30], "breakpoints stored sorted"); } -// ── Clipboard register tests ────────────────────────────────────────── - #[test] -fn test_clipboard_register_write() { - use std::sync::{Arc, Mutex}; +fn test_dap_breakpoints_multiple_files() { + use crate::core::dap::BreakpointInfo; let mut engine = Engine::new(); - engine.buffer_mut().insert(0, "hello"); - engine.update_syntax(); - - let written = Arc::new(Mutex::new(String::new())); - let written_clone = written.clone(); - engine.clipboard_write = Some(Box::new(move |text: &str| { - *written_clone.lock().unwrap() = text.to_string(); - Ok(()) - })); - - engine.set_register('+', "test_data".to_string(), false); - assert_eq!(*written.lock().unwrap(), "test_data"); + engine.dap_toggle_breakpoint("/src/a.rs", 1); + engine.dap_toggle_breakpoint("/src/b.rs", 2); + assert_eq!( + engine.dap_breakpoints.get("/src/a.rs").unwrap(), + &vec![BreakpointInfo::new(1)] + ); + assert_eq!( + engine.dap_breakpoints.get("/src/b.rs").unwrap(), + &vec![BreakpointInfo::new(2)] + ); } #[test] -fn test_clipboard_register_read() { +fn test_dap_no_session_commands_show_message() { let mut engine = Engine::new(); - engine.buffer_mut().insert(0, "hello"); - engine.update_syntax(); - - engine.clipboard_read = Some(Box::new(|| Ok("from_clipboard".to_string()))); - - let content = engine.get_register_content('+'); - assert!(content.is_some()); - let (text, linewise) = content.unwrap(); - assert_eq!(text, "from_clipboard"); - assert!(!linewise); + engine.dap_continue(); + assert!( + engine.message.contains("no active session"), + "{}", + engine.message + ); + engine.dap_pause(); + assert!( + engine.message.contains("no active session"), + "{}", + engine.message + ); + engine.dap_step_over(); + assert!( + engine.message.contains("no active session"), + "{}", + engine.message + ); + engine.dap_step_into(); + assert!( + engine.message.contains("no active session"), + "{}", + engine.message + ); + engine.dap_step_out(); + assert!( + engine.message.contains("no active session"), + "{}", + engine.message + ); } #[test] -fn test_paste_clipboard_to_command_buffer() { +fn test_dap_stop_clears_session() { let mut engine = Engine::new(); - engine.buffer_mut().insert(0, "hello"); - engine.update_syntax(); - - engine.clipboard_read = Some(Box::new(|| Ok("pasted_text".to_string()))); - - // Enter command mode - press_char(&mut engine, ':'); - assert_eq!(engine.mode, Mode::Command); - - engine.paste_clipboard_to_input(); - assert_eq!(engine.command_buffer, "pasted_text"); + engine.dap_session_active = true; + engine.dap_stopped_thread = Some(1); + engine.dap_seq_launch = Some(42); + engine.dap_stop(); + assert!(!engine.dap_session_active, "session cleared after stop"); + assert!( + engine.dap_stopped_thread.is_none(), + "stopped_thread cleared" + ); + assert!(engine.dap_seq_launch.is_none(), "seq_launch cleared"); } #[test] -fn test_paste_clipboard_multiline_takes_first() { +fn test_dap_install_unknown_language() { let mut engine = Engine::new(); - engine.buffer_mut().insert(0, "hello"); - engine.update_syntax(); - - engine.clipboard_read = Some(Box::new(|| Ok("first line\nsecond line".to_string()))); - - // Enter command mode - press_char(&mut engine, ':'); - engine.paste_clipboard_to_input(); - assert_eq!(engine.command_buffer, "first line"); + engine.execute_command("DapInstall cobol"); + assert!( + engine.message.contains("No built-in DAP adapter"), + "{}", + engine.message + ); } -// ── VSCode editing mode tests ──────────────────────────────────────────── +#[test] +fn test_dap_install_known_language_no_lsp_message() { + // DapInstall must NEVER show "No LSP for ..." messages. + // Without registry manifests, it falls through to direct adapter install. + let mut engine = Engine::new(); + engine.execute_command("DapInstall rust"); + assert!( + !engine.message.contains("No LSP"), + "DapInstall should not emit LSP messages: {}", + engine.message + ); + // Should either redirect to ExtInstall, mention rust/codelldb, or start installing + assert!( + engine.message.contains("ExtInstall") + || engine.message.contains("rust") + || engine.message.contains("codelldb") + || engine.message.contains("Install"), + "DapInstall should produce a relevant message: {}", + engine.message + ); +} -fn make_vscode_engine(text: &str) -> Engine { +#[test] +fn test_dap_install_no_arg() { let mut engine = Engine::new(); - engine.settings.editor_mode = crate::core::settings::EditorMode::Vscode; - engine.mode = Mode::Insert; - engine.buffer_mut().insert(0, text); - engine.update_syntax(); - engine + engine.execute_command("DapInstall"); + assert!(engine.message.contains("Usage"), "{}", engine.message); } -fn vscode_key(engine: &mut Engine, key_name: &str, unicode: Option, ctrl: bool) { - engine.handle_key(key_name, unicode, ctrl); +#[test] +fn test_dap_fields_default() { + let engine = Engine::new(); + assert!(engine.dap_manager.is_none(), "dap_manager starts None"); + assert!(engine.dap_stopped_thread.is_none()); + assert!(engine.dap_breakpoints.is_empty()); + assert!(engine.dap_seq_launch.is_none()); + assert!(engine.dap_current_line.is_none()); + assert!(engine.dap_stack_frames.is_empty()); + assert!(engine.dap_variables.is_empty()); + assert!(engine.dap_output_lines.is_empty()); } #[test] -fn test_vscode_mode_setting() { - let mut s = crate::core::settings::Settings::default(); - // Default is Vim - assert_eq!(s.editor_mode, crate::core::settings::EditorMode::Vim); - s.parse_set_option("mode=vscode").unwrap(); - assert_eq!(s.editor_mode, crate::core::settings::EditorMode::Vscode); - s.parse_set_option("mode=vim").unwrap(); - assert_eq!(s.editor_mode, crate::core::settings::EditorMode::Vim); - // Query - let msg = s.parse_set_option("mode?").unwrap(); - assert_eq!(msg, "mode=vim"); - s.parse_set_option("mode=vscode").unwrap(); - let msg2 = s.parse_set_option("mode?").unwrap(); - assert_eq!(msg2, "mode=vscode"); +fn test_rust_debug_binary_no_cargo_toml() { + // A temp dir with no Cargo.toml should return an error. + let dir = std::env::temp_dir().join("vimcode_test_no_cargo"); + let _ = std::fs::create_dir_all(&dir); + let result = rust_debug_binary(&dir); + assert!( + result.is_err(), + "Should fail when Cargo.toml not found: {:?}", + result + ); + assert!( + result.unwrap_err().contains("Cargo.toml"), + "Error should mention Cargo.toml" + ); } #[test] -fn test_vscode_mode_typing() { - let mut engine = make_vscode_engine("hello"); - // Colon should insert a literal ':' not enter command mode - vscode_key(&mut engine, "", Some(':'), false); - assert_eq!(engine.mode, Mode::Insert); - assert!(engine.buffer().to_string().contains(':')); +fn test_dap_breakpoint_gutter_fields() { + // Toggle a breakpoint and verify the engine state that render.rs queries. + let mut engine = Engine::new(); + engine.execute_command("e /tmp/foo.rs"); + // Set a breakpoint via the "brkpt" command path. + engine.dap_toggle_breakpoint("/tmp/foo.rs", 5); + let bp = engine.dap_breakpoints.get("/tmp/foo.rs"); + assert!(bp.is_some(), "Breakpoint should be registered"); + assert_eq!(bp.unwrap().len(), 1); + assert_eq!(bp.unwrap()[0].line, 5); + // Toggle again to remove. + engine.dap_toggle_breakpoint("/tmp/foo.rs", 5); + let bp2 = engine.dap_breakpoints.get("/tmp/foo.rs"); + assert!( + bp2.map(|v| v.is_empty()).unwrap_or(true), + "Breakpoint should be removed" + ); } #[test] -fn test_vscode_mode_ctrl_z_undo() { - let mut engine = make_vscode_engine("hello"); - // Type 'x' - vscode_key(&mut engine, "", Some('x'), false); - let text_after = engine.buffer().to_string(); - // Ctrl-Z undo - vscode_key(&mut engine, "z", Some('z'), true); - // Should restore to "hello" - assert_ne!(engine.buffer().to_string(), text_after); +fn test_dap_current_line_set_on_stop() { + // dap_current_line starts None and can be set/cleared directly. + let mut engine = Engine::new(); + assert!(engine.dap_current_line.is_none()); + engine.dap_current_line = Some(("/tmp/foo.rs".to_string(), 10)); + assert_eq!( + engine.dap_current_line, + Some(("/tmp/foo.rs".to_string(), 10)) + ); + // Simulate Continued: clear the stopped line. + engine.dap_current_line = None; + engine.dap_stopped_thread = None; + assert!(engine.dap_current_line.is_none()); } #[test] -fn test_vscode_mode_ctrl_y_redo() { - let mut engine = make_vscode_engine("hello"); - // Type 'x' - vscode_key(&mut engine, "", Some('x'), false); - let after_type = engine.buffer().to_string(); - // Undo - vscode_key(&mut engine, "z", Some('z'), true); - // Redo - vscode_key(&mut engine, "y", Some('y'), true); - assert_eq!(engine.buffer().to_string(), after_type); +fn test_dap_current_line_cleared_on_stop_and_continued() { + // Verify that dap_session_active affects has_bp computation: + // when active, even a file with no BPs shows the gutter column. + let mut engine = Engine::new(); + engine.dap_session_active = true; + // No breakpoints set yet — but session is active. + let bp_lines = engine + .dap_breakpoints + .get("") + .map(|v| v.as_slice()) + .unwrap_or(&[]); + let has_bp = !bp_lines.is_empty() || engine.dap_session_active; + assert!( + has_bp, + "has_bp should be true when session is active even with no BPs" + ); } #[test] -fn test_vscode_mode_shift_arrow_selection() { - let mut engine = make_vscode_engine("hello"); - // Shift+Right: start selection - vscode_key(&mut engine, "Shift_Right", None, false); - assert!(engine.visual_anchor.is_some()); - assert_eq!(engine.mode, Mode::Visual); - assert_eq!(engine.visual_anchor.unwrap().col, 0); - assert_eq!(engine.view().cursor.col, 1); +fn test_dap_stack_frames_parsed_from_json() { + // Simulate what poll_dap does when a stackTrace RequestComplete arrives. + use crate::core::dap::StackFrame; + let mut engine = Engine::new(); + let frames_json = serde_json::json!([ + {"id": 1, "name": "main", "source": {"path": "/tmp/src/main.rs"}, "line": 42}, + {"id": 2, "name": "helper", "source": {"path": "/tmp/src/lib.rs"}, "line": 10}, + ]); + let frames: Vec = frames_json + .as_array() + .unwrap() + .iter() + .map(|f| StackFrame { + id: f.get("id").and_then(|v| v.as_u64()).unwrap_or(0), + name: f + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or("?") + .to_string(), + source: f + .get("source") + .and_then(|s| s.get("path")) + .and_then(|p| p.as_str()) + .map(|s| s.to_string()), + line: f.get("line").and_then(|v| v.as_u64()).unwrap_or(0), + }) + .collect(); + engine.dap_stack_frames = frames; + assert_eq!(engine.dap_stack_frames.len(), 2); + assert_eq!(engine.dap_stack_frames[0].name, "main"); + assert_eq!(engine.dap_stack_frames[0].line, 42); + assert_eq!( + engine.dap_stack_frames[0].source.as_deref(), + Some("/tmp/src/main.rs") + ); + assert_eq!(engine.dap_stack_frames[1].name, "helper"); } #[test] -fn test_vscode_mode_ctrl_shift_arrow_word_select() { - let mut engine = make_vscode_engine("hello world"); - // Ctrl+Shift+Right: select word - vscode_key(&mut engine, "Shift_Right", None, true); - assert!(engine.visual_anchor.is_some()); - assert_eq!(engine.mode, Mode::Visual); - // Cursor should be past the word "hello" - assert!(engine.view().cursor.col > 0); +fn test_dap_variables_parsed_from_json() { + use crate::core::dap::DapVariable; + let mut engine = Engine::new(); + let vars_json = serde_json::json!([ + {"name": "x", "value": "42", "variablesReference": 0}, + {"name": "msg", "value": "\"hello\"", "variablesReference": 0}, + ]); + engine.dap_variables = vars_json + .as_array() + .unwrap() + .iter() + .map(|v| DapVariable { + name: v + .get("name") + .and_then(|n| n.as_str()) + .unwrap_or("?") + .to_string(), + value: v + .get("value") + .and_then(|val| val.as_str()) + .unwrap_or("") + .to_string(), + var_ref: v + .get("variablesReference") + .and_then(|r| r.as_u64()) + .unwrap_or(0), + is_nonpublic: false, + }) + .collect(); + assert_eq!(engine.dap_variables.len(), 2); + assert_eq!(engine.dap_variables[0].name, "x"); + assert_eq!(engine.dap_variables[0].value, "42"); + assert_eq!(engine.dap_variables[1].name, "msg"); } #[test] -fn test_vscode_mode_type_replaces_selection() { - let mut engine = make_vscode_engine("hello"); - // Shift+Right+Right to select "he" - vscode_key(&mut engine, "Shift_Right", None, false); - vscode_key(&mut engine, "Shift_Right", None, false); - assert!(engine.visual_anchor.is_some()); - // Type 'X' — should replace selection - vscode_key(&mut engine, "", Some('X'), false); - assert!(engine.visual_anchor.is_none()); - assert_eq!(engine.mode, Mode::Insert); - let text = engine.buffer().to_string(); - assert!(text.starts_with('X')); - assert!(text.contains("llo")); +fn test_strip_ansi_and_control_complete_sequence() { + // Complete CSI sequence is removed. + assert_eq!( + Engine::strip_ansi_and_control("\x1b[38;2;97;175;239mhello\x1b[0m"), + "hello" + ); + // Bare text passes through unchanged. + assert_eq!(Engine::strip_ansi_and_control("plain text"), "plain text"); + // Newlines and tabs are preserved; \r and other control chars are stripped. + assert_eq!( + Engine::strip_ansi_and_control("line1\nline2\ttab\r"), + "line1\nline2\ttab" + ); } #[test] -fn test_vscode_mode_backspace_clears_selection() { - let mut engine = make_vscode_engine("hello"); - // Shift+Right+Right to select "he" - vscode_key(&mut engine, "Shift_Right", None, false); - vscode_key(&mut engine, "Shift_Right", None, false); - assert!(engine.visual_anchor.is_some()); - // Backspace — should delete selection - vscode_key(&mut engine, "BackSpace", None, false); - assert!(engine.visual_anchor.is_none()); - let text = engine.buffer().to_string(); - assert!(text.starts_with("llo")); +fn test_ansi_incomplete_tail_start() { + // No ESC → no tail. + assert_eq!(Engine::ansi_incomplete_tail_start("hello"), None); + // Complete CSI → no tail. + assert_eq!(Engine::ansi_incomplete_tail_start("text\x1b[32m"), None); + // Partial CSI at end → returns its start offset. + let s = "text\x1b[38;2;97;175;239"; + let pos = Engine::ansi_incomplete_tail_start(s).unwrap(); + assert_eq!(&s[pos..], "\x1b[38;2;97;175;239"); + // Bare ESC at end → carry. + let s2 = "hello\x1b"; + assert_eq!(Engine::ansi_incomplete_tail_start(s2), Some(5)); + // ESC [ with partial params → carry. + let s3 = "\x1b["; + assert_eq!(Engine::ansi_incomplete_tail_start(s3), Some(0)); } #[test] -fn test_vscode_mode_ctrl_a_select_all() { - let mut engine = make_vscode_engine("hello\nworld"); - engine.update_syntax(); - vscode_key(&mut engine, "a", Some('a'), true); - assert!(engine.visual_anchor.is_some()); - assert_eq!(engine.visual_anchor.unwrap().line, 0); - assert_eq!(engine.visual_anchor.unwrap().col, 0); - assert_eq!(engine.mode, Mode::Visual); - // Cursor at end of last line - assert_eq!(engine.view().cursor.line, 1); +fn test_dap_ansi_carry_handles_split_sequence() { + // Simulate two consecutive DAP output events where an ANSI RGB colour + // sequence is split: first chunk ends at `\x1b[38;2;97;175;239` (no + // final byte), second chunk supplies `mtext`. The output panel should + // receive "text", NOT "38;2;97;175;239mtext". + let first = "start\x1b[38;2;97;175;239"; + let second = "mtext end"; + // First event: tail is carried. + let combined1 = first.to_string(); + let carry = if let Some(pos) = Engine::ansi_incomplete_tail_start(&combined1) { + combined1[pos..].to_string() + } else { + String::new() + }; + let clean1 = Engine::strip_ansi_and_control(&combined1[..combined1.len() - carry.len()]); + assert_eq!(clean1, "start"); + assert_eq!(carry, "\x1b[38;2;97;175;239"); + // Second event: prepend carry, strip. + let combined2 = format!("{carry}{second}"); + let tail2 = Engine::ansi_incomplete_tail_start(&combined2); + assert_eq!(tail2, None); // sequence is now complete + let clean2 = Engine::strip_ansi_and_control(&combined2); + assert_eq!(clean2, "text end"); } #[test] -fn test_vscode_mode_escape_clears_selection() { - let mut engine = make_vscode_engine("hello"); - vscode_key(&mut engine, "Shift_Right", None, false); - assert!(engine.visual_anchor.is_some()); - vscode_key(&mut engine, "Escape", None, false); - assert!(engine.visual_anchor.is_none()); - assert_eq!(engine.mode, Mode::Insert); +fn test_dap_output_lines_appended_and_capped() { + let mut engine = Engine::new(); + // Append lines and verify they accumulate. + engine + .dap_output_lines + .push("[stdout] Hello, world!".to_string()); + engine + .dap_output_lines + .push("[stderr] Error: oops".to_string()); + assert_eq!(engine.dap_output_lines.len(), 2); + assert_eq!(engine.dap_output_lines[0], "[stdout] Hello, world!"); + + // Verify cap: fill to > 1000 and drain. + engine.dap_output_lines.clear(); + for i in 0..1005 { + engine.dap_output_lines.push(format!("line {i}")); + } + if engine.dap_output_lines.len() > 1000 { + let excess = engine.dap_output_lines.len() - 1000; + engine.dap_output_lines.drain(..excess); + } + assert_eq!(engine.dap_output_lines.len(), 1000); + // After draining, the oldest 5 lines are gone; line 5 should now be first. + assert_eq!(engine.dap_output_lines[0], "line 5"); } #[test] -fn test_vscode_mode_ctrl_x_no_selection_cuts_line() { - let mut engine = make_vscode_engine("hello\nworld"); - engine.update_syntax(); - // Cursor on first line, no selection - assert!(engine.visual_anchor.is_none()); - vscode_key(&mut engine, "x", Some('x'), true); - // First line should be deleted - let text = engine.buffer().to_string(); - assert!( - !text.contains("hello"), - "Line should be cut: got {:?}", - text - ); - // Register '+' should contain the cut line - let (reg_content, _) = engine.registers.get(&'+').cloned().unwrap_or_default(); - assert!(reg_content.contains("hello")); +fn test_dap_frames_and_vars_cleared_on_continued() { + use crate::core::dap::{DapVariable, StackFrame}; + let mut engine = Engine::new(); + engine.dap_stack_frames = vec![StackFrame { + id: 1, + name: "main".to_string(), + source: None, + line: 5, + }]; + engine.dap_variables = vec![DapVariable { + name: "x".to_string(), + value: "10".to_string(), + var_ref: 0, + is_nonpublic: false, + }]; + // Simulate Continued event clearing. + engine.dap_stack_frames.clear(); + engine.dap_variables.clear(); + engine.dap_current_line = None; + assert!(engine.dap_stack_frames.is_empty()); + assert!(engine.dap_variables.is_empty()); + assert!(engine.dap_current_line.is_none()); } #[test] -fn test_vscode_mode_ctrl_c_no_selection_copies_line() { - let mut engine = make_vscode_engine("hello\nworld"); - engine.update_syntax(); - // Ctrl-C with no selection: copy current line - vscode_key(&mut engine, "c", Some('c'), true); - // Buffer unchanged - assert!(engine.buffer().to_string().contains("hello")); - // Register '+' should contain the line - let (reg_content, is_linewise) = engine.registers.get(&'+').cloned().unwrap_or_default(); - assert!(reg_content.contains("hello")); - assert!(is_linewise); +fn test_dap_sidebar_section_navigation() { + let mut engine = Engine::new(); + assert_eq!(engine.dap_sidebar_section, DebugSidebarSection::Variables); + engine.handle_debug_sidebar_key("Tab", false); + assert_eq!(engine.dap_sidebar_section, DebugSidebarSection::Watch); + engine.handle_debug_sidebar_key("Tab", false); + assert_eq!(engine.dap_sidebar_section, DebugSidebarSection::CallStack); + engine.handle_debug_sidebar_key("Tab", false); + assert_eq!(engine.dap_sidebar_section, DebugSidebarSection::Breakpoints); + engine.handle_debug_sidebar_key("Tab", false); + assert_eq!(engine.dap_sidebar_section, DebugSidebarSection::Variables); } #[test] -fn test_vscode_mode_toggle() { - let mut engine = Engine::new(); +fn test_dap_sidebar_section_index() { assert_eq!( - engine.settings.editor_mode, - crate::core::settings::EditorMode::Vim + Engine::dap_sidebar_section_index(DebugSidebarSection::Variables), + 0 ); - assert_eq!(engine.mode, Mode::Normal); - engine.toggle_editor_mode(); assert_eq!( - engine.settings.editor_mode, - crate::core::settings::EditorMode::Vscode + Engine::dap_sidebar_section_index(DebugSidebarSection::Watch), + 1 ); - assert_eq!(engine.mode, Mode::Insert); - engine.toggle_editor_mode(); assert_eq!( - engine.settings.editor_mode, - crate::core::settings::EditorMode::Vim + Engine::dap_sidebar_section_index(DebugSidebarSection::CallStack), + 2 + ); + assert_eq!( + Engine::dap_sidebar_section_index(DebugSidebarSection::Breakpoints), + 3 ); - assert_eq!(engine.mode, Mode::Normal); -} - -#[test] -fn test_vscode_mode_smart_home() { - let mut engine = make_vscode_engine(" hello"); - // Cursor at col 0 initially — Home moves to first non-ws - vscode_key(&mut engine, "Home", None, false); - assert_eq!(engine.view().cursor.col, 2); // first non-ws is col 2 - // Home again — moves to col 0 - vscode_key(&mut engine, "Home", None, false); - assert_eq!(engine.view().cursor.col, 0); } #[test] -fn test_vscode_mode_comment_toggle() { - let mut engine = make_vscode_engine("hello"); - // Set language so comment style is // (not fallback #) - let buf_id = engine.active_buffer_id(); - engine - .buffer_manager - .get_mut(buf_id) - .unwrap() - .lsp_language_id = Some("rust".to_string()); - // Ctrl+/ should add "// " prefix - vscode_key(&mut engine, "/", Some('/'), true); - let text = engine.buffer().to_string(); - assert!( - text.starts_with("// hello"), - "Expected '// hello', got {:?}", - text - ); - // Ctrl+/ again should remove "// " - vscode_key(&mut engine, "/", Some('/'), true); - let text2 = engine.buffer().to_string(); - assert!( - text2.starts_with("hello"), - "Expected 'hello', got {:?}", - text2 - ); - // Also test with "slash" key_name (GTK/TUI send this) - vscode_key(&mut engine, "slash", None, true); - let text3 = engine.buffer().to_string(); - assert!( - text3.starts_with("// hello"), - "Expected '// hello' via slash key_name, got {:?}", - text3 - ); +fn test_dap_sidebar_ensure_visible_scrolls_down() { + let mut engine = Engine::new(); + engine.dap_sidebar_section = DebugSidebarSection::Variables; + engine.dap_sidebar_section_heights = [5, 5, 5, 5]; + engine.dap_sidebar_scroll = [0; 4]; + // Simulate selecting item 7 (beyond the 5-row viewport). + engine.dap_sidebar_selected = 7; + engine.dap_sidebar_ensure_visible(); + // scroll should adjust so item 7 is the last visible: scroll = 7 - 5 + 1 = 3 + assert_eq!(engine.dap_sidebar_scroll[0], 3); } #[test] -fn test_vscode_mode_f1_opens_palette() { - let mut engine = make_vscode_engine("hello"); - // F1 should open the command palette (matches real VSCode). - vscode_key(&mut engine, "F1", None, false); - assert!(engine.picker_open, "F1 should open the command palette"); - assert_eq!(engine.mode, Mode::Insert, "mode should stay Insert"); +fn test_dap_sidebar_ensure_visible_scrolls_up() { + let mut engine = Engine::new(); + engine.dap_sidebar_section = DebugSidebarSection::Watch; + engine.dap_sidebar_section_heights = [5, 5, 5, 5]; + engine.dap_sidebar_scroll = [0, 10, 0, 0]; // Watch scroll at 10 + // Select item 3 which is before the scroll window. + engine.dap_sidebar_selected = 3; + engine.dap_sidebar_ensure_visible(); + // scroll should jump to 3 + assert_eq!(engine.dap_sidebar_scroll[1], 3); } #[test] -fn test_vscode_mode_execute_command_returns_to_insert() { - let mut engine = make_vscode_engine("hello"); - // Execute a command directly (like via the command palette). - engine.execute_command("set number"); - // Should stay in Insert (EDIT) mode. - assert_eq!(engine.mode, Mode::Insert); - assert!(engine.is_vscode_mode()); +fn test_dap_sidebar_ensure_visible_no_change_when_visible() { + let mut engine = Engine::new(); + engine.dap_sidebar_section = DebugSidebarSection::CallStack; + engine.dap_sidebar_section_heights = [5, 5, 5, 5]; + engine.dap_sidebar_scroll = [0, 0, 2, 0]; // CallStack scroll at 2 + engine.dap_sidebar_selected = 4; // visible: items 2,3,4,5,6 + engine.dap_sidebar_ensure_visible(); + assert_eq!(engine.dap_sidebar_scroll[2], 2); // unchanged } #[test] -fn test_vscode_mode_f1_escape_closes_palette() { - let mut engine = make_vscode_engine("hello"); - // F1 → palette, then Escape → closes palette, stays in EDIT mode. - vscode_key(&mut engine, "F1", None, false); - assert!(engine.picker_open); - engine.handle_key("Escape", None, false); - assert!(!engine.picker_open, "Escape should close palette"); - assert_eq!(engine.mode, Mode::Insert); - assert!(engine.is_vscode_mode()); +fn test_dap_sidebar_ensure_visible_zero_height_noop() { + let mut engine = Engine::new(); + engine.dap_sidebar_section = DebugSidebarSection::Variables; + engine.dap_sidebar_section_heights = [0, 0, 0, 0]; // not yet laid out + engine.dap_sidebar_selected = 10; + engine.dap_sidebar_ensure_visible(); + assert_eq!(engine.dap_sidebar_scroll[0], 0); // unchanged } -// ----------------------------------------------------------------------- -// Menu bar tests -// ----------------------------------------------------------------------- - #[test] -fn test_menu_bar_toggle() { +fn test_dap_sidebar_resize_section() { let mut engine = Engine::new(); - assert!(!engine.menu_bar_visible, "menu bar starts hidden"); - engine.toggle_menu_bar(); - assert!(engine.menu_bar_visible, "toggle_menu_bar() should show bar"); - engine.toggle_menu_bar(); - assert!(!engine.menu_bar_visible, "second toggle hides bar"); + engine.dap_sidebar_section_heights = [10, 10, 10, 10]; + // Grow section 0 by 3, shrink section 1 by 3. + engine.dap_sidebar_resize_section(0, 3); + assert_eq!(engine.dap_sidebar_section_heights[0], 13); + assert_eq!(engine.dap_sidebar_section_heights[1], 7); + // Total preserved. + assert_eq!(engine.dap_sidebar_section_heights.iter().sum::(), 40); } #[test] -fn test_menu_open_close() { +fn test_dap_sidebar_resize_section_clamps_min() { let mut engine = Engine::new(); - engine.menu_bar_visible = true; - assert_eq!(engine.menu_open_idx, None); - engine.open_menu(2); + engine.dap_sidebar_section_heights = [3, 3, 10, 10]; + // Try to shrink section 0 by 10 — should clamp to 1. + engine.dap_sidebar_resize_section(0, -10); + assert_eq!(engine.dap_sidebar_section_heights[0], 1); + assert_eq!(engine.dap_sidebar_section_heights[1], 5); // 3 + 3 - 1 = 5 + // Total preserved. assert_eq!( - engine.menu_open_idx, - Some(2), - "open_menu sets dropdown index" + engine.dap_sidebar_section_heights[0] + engine.dap_sidebar_section_heights[1], + 6 ); - engine.close_menu(); - assert_eq!(engine.menu_open_idx, None, "close_menu clears dropdown"); - assert!(engine.menu_bar_visible, "close_menu keeps bar visible"); } #[test] -fn test_menu_activate_dispatches_command() { +fn test_dap_sidebar_resize_section_last_noop() { let mut engine = Engine::new(); - // Load a buffer with content so we can verify save via w command. - let tmp = std::env::temp_dir().join("vimcode_menu_test_save.txt"); - let _ = std::fs::write(&tmp, "hello"); - engine - .buffer_manager - .get_mut(engine.active_buffer_id()) - .unwrap() - .file_path = Some(tmp.clone()); - engine - .buffer_manager - .get_mut(engine.active_buffer_id()) - .unwrap() - .dirty = true; - engine.menu_bar_visible = true; - engine.menu_open_idx = Some(0); - // Activate the "Save" item (File menu, action "w") via menu_activate_item. - engine.menu_activate_item(0, 2, "w"); - assert_eq!( - engine.menu_open_idx, None, - "menu_activate_item closes dropdown" - ); - // Buffer should no longer be dirty after :w - let dirty = engine - .buffer_manager - .get(engine.active_buffer_id()) - .map(|s| s.dirty) - .unwrap_or(true); - assert!(!dirty, "buffer saved after menu activate"); - let _ = std::fs::remove_file(&tmp); + engine.dap_sidebar_section_heights = [10, 10, 10, 10]; + // section_idx=3 has no next section — should be a no-op. + engine.dap_sidebar_resize_section(3, 5); + assert_eq!(engine.dap_sidebar_section_heights, [10, 10, 10, 10]); } -// ── Session 82: menu navigation ──────────────────────────────────────────── - #[test] -fn test_menu_item_navigation() { +fn test_dap_sidebar_scroll_reset_on_stop() { let mut engine = Engine::new(); - engine.menu_bar_visible = true; - engine.open_menu(0); - assert_eq!( - engine.menu_highlighted_item, None, - "starts with no highlight" - ); - - // Items: [non-sep(0), non-sep(1), sep(2), non-sep(3)] - let seps = [false, false, true, false]; + engine.dap_sidebar_scroll = [5, 10, 3, 7]; + engine.dap_session_active = true; + engine.dap_stop(); + assert_eq!(engine.dap_sidebar_scroll, [0, 0, 0, 0]); +} - engine.menu_move_selection(1, &seps); - assert_eq!(engine.menu_highlighted_item, Some(0), "first non-sep"); +#[test] +fn test_dap_sidebar_jk_triggers_ensure_visible() { + use crate::core::dap::DapVariable; + let mut engine = Engine::new(); + engine.dap_sidebar_has_focus = true; + engine.dap_sidebar_section = DebugSidebarSection::Variables; + engine.dap_sidebar_section_heights = [3, 3, 3, 3]; + // Create 10 variables so we can scroll. + engine.dap_variables = (0..10) + .map(|i| DapVariable { + name: format!("v{i}"), + value: format!("{i}"), + var_ref: 0, + is_nonpublic: false, + }) + .collect(); + engine.dap_sidebar_selected = 0; + // Press j 5 times to go to item 5. + for _ in 0..5 { + engine.handle_debug_sidebar_key("j", false); + } + assert_eq!(engine.dap_sidebar_selected, 5); + // Scroll should have adjusted: 5 >= 0 + 3 → scroll = 5 - 3 + 1 = 3 + assert_eq!(engine.dap_sidebar_scroll[0], 3); +} - engine.menu_move_selection(1, &seps); - assert_eq!(engine.menu_highlighted_item, Some(1), "second non-sep"); +#[test] +fn test_dap_select_frame_clamps() { + use crate::core::dap::StackFrame; + let mut engine = Engine::new(); + engine.dap_stack_frames = vec![ + StackFrame { + id: 1, + name: "main".to_string(), + source: None, + line: 1, + }, + StackFrame { + id: 2, + name: "foo".to_string(), + source: None, + line: 2, + }, + StackFrame { + id: 3, + name: "bar".to_string(), + source: None, + line: 3, + }, + ]; + // Select within bounds. + engine.dap_select_frame(1); + assert_eq!(engine.dap_active_frame, 1); + // Select beyond bounds: clamps to last. + engine.dap_select_frame(99); + assert_eq!(engine.dap_active_frame, 2); + // Select 0. + engine.dap_select_frame(0); + assert_eq!(engine.dap_active_frame, 0); +} - engine.menu_move_selection(1, &seps); - assert_eq!(engine.menu_highlighted_item, Some(3), "skips separator"); +#[test] +fn test_dap_variable_expand_tracking() { + let mut engine = Engine::new(); + assert!(!engine.dap_expanded_vars.contains(&5)); + // Toggle on. + engine.dap_expanded_vars.insert(5); + assert!(engine.dap_expanded_vars.contains(&5)); + // Toggle off. + engine.dap_expanded_vars.remove(&5); + assert!(!engine.dap_expanded_vars.contains(&5)); +} - engine.menu_move_selection(1, &seps); - assert_eq!(engine.menu_highlighted_item, Some(0), "wraps around"); +#[test] +fn test_dap_eval_result_field_default() { + let engine = Engine::new(); + assert!(engine.dap_eval_result.is_none()); + assert_eq!(engine.dap_sidebar_section, DebugSidebarSection::Variables); + assert_eq!(engine.dap_active_frame, 0); + assert!(engine.dap_expanded_vars.is_empty()); + assert!(engine.dap_child_variables.is_empty()); +} - // Reverse direction - engine.menu_move_selection(-1, &seps); - assert_eq!(engine.menu_highlighted_item, Some(3), "reverse wrap"); +#[test] +fn test_visual_rows_for_line() { + assert_eq!(engine_visual_rows_for_line(0, 80), 1); // empty line = 1 row + assert_eq!(engine_visual_rows_for_line(80, 80), 1); // exactly one row + assert_eq!(engine_visual_rows_for_line(81, 80), 2); // one char overflow + assert_eq!(engine_visual_rows_for_line(160, 80), 2); // exactly two rows + assert_eq!(engine_visual_rows_for_line(161, 80), 3); + assert_eq!(engine_visual_rows_for_line(10, 0), 1); // zero cols = 1 row } #[test] -fn test_menu_activate_highlighted() { +fn test_ensure_cursor_visible_wrap_scrolls_down() { let mut engine = Engine::new(); - engine.menu_bar_visible = true; - engine.open_menu(2); // arbitrary menu index - - // Nothing highlighted → returns None, menu stays open - let result = engine.menu_activate_highlighted(); - assert!(result.is_none(), "None when nothing highlighted"); - assert!(engine.menu_open_idx.is_some(), "menu stays open"); - - // Highlight an item, then activate - engine.menu_highlighted_item = Some(3); - let result = engine.menu_activate_highlighted(); - assert_eq!(result, Some((2, 3)), "returns (menu_idx, item_idx)"); - assert!( - engine.menu_open_idx.is_none(), - "menu is closed after activate" - ); + engine.settings.wrap = true; + // Fill buffer with 20 short lines so the content exists. + let text = (0..20).map(|i| format!("line {i}\n")).collect::(); + engine.buffer_mut().content = ropey::Rope::from_str(&text); + // Viewport: 10 lines of 80 cols + engine.view_mut().viewport_lines = 10; + engine.view_mut().viewport_cols = 80; + engine.view_mut().scroll_top = 0; + // Move cursor to line 15 — beyond the viewport. + engine.view_mut().cursor.line = 15; + engine.view_mut().cursor.col = 0; + engine.ensure_cursor_visible(); + // scroll_top should have advanced so cursor is visible. + assert!(engine.view().scroll_top > 0); + assert!(engine.view().cursor.line >= engine.view().scroll_top); + assert!(engine.view().cursor.line < engine.view().scroll_top + engine.view().viewport_lines); } #[test] -fn test_dap_session_active_field() { - let engine = Engine::new(); - assert!( - !engine.dap_session_active, - "dap_session_active defaults to false" - ); +fn test_ensure_cursor_visible_wrap_scrolls_up() { + let mut engine = Engine::new(); + engine.settings.wrap = true; + let text = (0..20).map(|i| format!("line {i}\n")).collect::(); + engine.buffer_mut().content = ropey::Rope::from_str(&text); + engine.view_mut().viewport_lines = 10; + engine.view_mut().viewport_cols = 80; + engine.view_mut().scroll_top = 15; + // Move cursor above scroll_top. + engine.view_mut().cursor.line = 5; + engine.view_mut().cursor.col = 0; + engine.ensure_cursor_visible(); + assert_eq!(engine.view().scroll_top, 5); } -// ── Session 83: DAP transport + engine methods ───────────────────────────── - #[test] -fn test_dap_toggle_breakpoint_add() { - use crate::core::dap::BreakpointInfo; +fn test_dap_add_remove_watch() { let mut engine = Engine::new(); - engine.dap_toggle_breakpoint("/src/main.rs", 10); - let bps = engine.dap_breakpoints.get("/src/main.rs").unwrap(); - assert_eq!(bps, &vec![BreakpointInfo::new(10)], "breakpoint added"); - assert!( - engine.message.contains("Breakpoint set"), - "{}", - engine.message - ); + engine.dap_add_watch("x + 1".to_string()); + engine.dap_add_watch("y".to_string()); + assert_eq!(engine.dap_watch_expressions, vec!["x + 1", "y"]); + assert_eq!(engine.dap_watch_values.len(), 2); + assert!(engine.dap_watch_values[0].is_none()); + // Remove the first watch. + engine.dap_remove_watch(0); + assert_eq!(engine.dap_watch_expressions, vec!["y"]); + assert_eq!(engine.dap_watch_values.len(), 1); + // Remove out-of-bounds: no-op. + engine.dap_remove_watch(99); + assert_eq!(engine.dap_watch_expressions.len(), 1); } #[test] -fn test_dap_toggle_breakpoint_remove() { - let mut engine = Engine::new(); - engine.dap_toggle_breakpoint("/src/main.rs", 10); - engine.dap_toggle_breakpoint("/src/main.rs", 10); - let bps = engine.dap_breakpoints.get("/src/main.rs").unwrap(); - assert!(bps.is_empty(), "second toggle removes breakpoint"); - assert!( - engine.message.contains("Breakpoint removed"), - "{}", - engine.message - ); +fn test_dap_bottom_panel_kind_default() { + let engine = Engine::new(); + assert_eq!(engine.bottom_panel_kind, BottomPanelKind::Terminal); } #[test] -fn test_dap_breakpoints_sorted() { - let mut engine = Engine::new(); - engine.dap_toggle_breakpoint("/src/lib.rs", 30); - engine.dap_toggle_breakpoint("/src/lib.rs", 5); - engine.dap_toggle_breakpoint("/src/lib.rs", 15); - let bps = engine.dap_breakpoints.get("/src/lib.rs").unwrap(); - let lines: Vec = bps.iter().map(|b| b.line).collect(); - assert_eq!(lines, vec![5, 15, 30], "breakpoints stored sorted"); +fn test_dap_launch_configs_default() { + let engine = Engine::new(); + assert!(engine.dap_launch_configs.is_empty()); + assert_eq!(engine.dap_selected_launch_config, 0); } #[test] -fn test_dap_breakpoints_multiple_files() { - use crate::core::dap::BreakpointInfo; - let mut engine = Engine::new(); - engine.dap_toggle_breakpoint("/src/a.rs", 1); - engine.dap_toggle_breakpoint("/src/b.rs", 2); - assert_eq!( - engine.dap_breakpoints.get("/src/a.rs").unwrap(), - &vec![BreakpointInfo::new(1)] - ); - assert_eq!( - engine.dap_breakpoints.get("/src/b.rs").unwrap(), - &vec![BreakpointInfo::new(2)] +fn test_debug_toolbar_default_false() { + let engine = Engine::new(); + assert!( + !engine.debug_toolbar_visible, + "toolbar should default to hidden" ); } +// ── Session 90: Interactive debug sidebar + conditional breakpoints ────── + #[test] -fn test_dap_no_session_commands_show_message() { +fn test_sidebar_var_expand_via_enter() { + use crate::core::dap::DapVariable; let mut engine = Engine::new(); - engine.dap_continue(); - assert!( - engine.message.contains("no active session"), - "{}", - engine.message - ); - engine.dap_pause(); - assert!( - engine.message.contains("no active session"), - "{}", - engine.message - ); - engine.dap_step_over(); - assert!( - engine.message.contains("no active session"), - "{}", - engine.message - ); - engine.dap_step_into(); + engine.dap_variables = vec![ + DapVariable { + name: "x".to_string(), + value: "42".to_string(), + var_ref: 10, + is_nonpublic: false, + }, + DapVariable { + name: "y".to_string(), + value: "7".to_string(), + var_ref: 0, + is_nonpublic: false, + }, + ]; + engine.dap_sidebar_section = DebugSidebarSection::Variables; + engine.dap_sidebar_selected = 0; + // Enter on expandable var should toggle expand. + engine.handle_debug_sidebar_key("Return", false); assert!( - engine.message.contains("no active session"), - "{}", - engine.message + engine.dap_expanded_vars.contains(&10), + "var_ref 10 should be expanded" ); - engine.dap_step_out(); + // Enter again should collapse. + engine.dap_sidebar_selected = 0; + engine.handle_debug_sidebar_key("Return", false); assert!( - engine.message.contains("no active session"), - "{}", - engine.message + !engine.dap_expanded_vars.contains(&10), + "var_ref 10 should be collapsed" ); } #[test] -fn test_dap_stop_clears_session() { +fn test_sidebar_var_enter_on_non_expandable() { + use crate::core::dap::DapVariable; let mut engine = Engine::new(); - engine.dap_session_active = true; - engine.dap_stopped_thread = Some(1); - engine.dap_seq_launch = Some(42); - engine.dap_stop(); - assert!(!engine.dap_session_active, "session cleared after stop"); - assert!( - engine.dap_stopped_thread.is_none(), - "stopped_thread cleared" - ); - assert!(engine.dap_seq_launch.is_none(), "seq_launch cleared"); + engine.dap_variables = vec![DapVariable { + name: "x".to_string(), + value: "42".to_string(), + var_ref: 0, + is_nonpublic: false, + }]; + engine.dap_sidebar_section = DebugSidebarSection::Variables; + engine.dap_sidebar_selected = 0; + engine.handle_debug_sidebar_key("Return", false); + // No expansion should happen for var_ref=0. + assert!(engine.dap_expanded_vars.is_empty()); } #[test] -fn test_dap_install_unknown_language() { +fn test_sidebar_callstack_enter_selects_frame() { + use crate::core::dap::StackFrame; let mut engine = Engine::new(); - engine.execute_command("DapInstall cobol"); - assert!( - engine.message.contains("No built-in DAP adapter"), - "{}", - engine.message - ); + engine.dap_stack_frames = vec![ + StackFrame { + id: 1, + name: "main".to_string(), + source: None, + line: 10, + }, + StackFrame { + id: 2, + name: "foo".to_string(), + source: None, + line: 20, + }, + ]; + engine.dap_sidebar_section = DebugSidebarSection::CallStack; + engine.dap_sidebar_selected = 1; + engine.handle_debug_sidebar_key("Return", false); + assert_eq!(engine.dap_active_frame, 1, "should select frame 1"); } #[test] -fn test_dap_install_known_language_no_lsp_message() { - // DapInstall must NEVER show "No LSP for ..." messages. - // Without registry manifests, it falls through to direct adapter install. +fn test_sidebar_section_len_variables() { + use crate::core::dap::DapVariable; let mut engine = Engine::new(); - engine.execute_command("DapInstall rust"); - assert!( - !engine.message.contains("No LSP"), - "DapInstall should not emit LSP messages: {}", - engine.message - ); - // Should either redirect to ExtInstall, mention rust/codelldb, or start installing - assert!( - engine.message.contains("ExtInstall") - || engine.message.contains("rust") - || engine.message.contains("codelldb") - || engine.message.contains("Install"), - "DapInstall should produce a relevant message: {}", - engine.message + engine.dap_variables = vec![ + DapVariable { + name: "a".to_string(), + value: "1".to_string(), + var_ref: 5, + is_nonpublic: false, + }, + DapVariable { + name: "b".to_string(), + value: "2".to_string(), + var_ref: 0, + is_nonpublic: false, + }, + ]; + engine.dap_sidebar_section = DebugSidebarSection::Variables; + // 2 top-level vars, none expanded. + assert_eq!(engine.dap_sidebar_section_len(), 2); + // Expand var_ref=5 with 3 children. + engine.dap_expanded_vars.insert(5); + engine.dap_child_variables.insert( + 5, + vec![ + DapVariable { + name: "c1".to_string(), + value: "x".to_string(), + var_ref: 0, + is_nonpublic: false, + }, + DapVariable { + name: "c2".to_string(), + value: "y".to_string(), + var_ref: 0, + is_nonpublic: false, + }, + DapVariable { + name: "c3".to_string(), + value: "z".to_string(), + var_ref: 0, + is_nonpublic: false, + }, + ], ); + assert_eq!(engine.dap_sidebar_section_len(), 5); } #[test] -fn test_dap_install_no_arg() { +fn test_sidebar_j_k_clamped() { + use crate::core::dap::DapVariable; let mut engine = Engine::new(); - engine.execute_command("DapInstall"); - assert!(engine.message.contains("Usage"), "{}", engine.message); + engine.dap_variables = vec![DapVariable { + name: "x".to_string(), + value: "1".to_string(), + var_ref: 0, + is_nonpublic: false, + }]; + engine.dap_sidebar_section = DebugSidebarSection::Variables; + engine.dap_sidebar_selected = 0; + // j should not go past last item. + engine.handle_debug_sidebar_key("j", false); + assert_eq!(engine.dap_sidebar_selected, 0, "clamped at end"); + // k should not go below 0. + engine.handle_debug_sidebar_key("k", false); + assert_eq!(engine.dap_sidebar_selected, 0, "clamped at start"); } #[test] -fn test_dap_fields_default() { - let engine = Engine::new(); - assert!(engine.dap_manager.is_none(), "dap_manager starts None"); - assert!(engine.dap_stopped_thread.is_none()); - assert!(engine.dap_breakpoints.is_empty()); - assert!(engine.dap_seq_launch.is_none()); - assert!(engine.dap_current_line.is_none()); - assert!(engine.dap_stack_frames.is_empty()); - assert!(engine.dap_variables.is_empty()); - assert!(engine.dap_output_lines.is_empty()); +fn test_sidebar_delete_watch() { + let mut engine = Engine::new(); + engine.dap_add_watch("expr1".to_string()); + engine.dap_add_watch("expr2".to_string()); + engine.dap_sidebar_section = DebugSidebarSection::Watch; + engine.dap_sidebar_selected = 0; + engine.handle_debug_sidebar_key("x", false); + assert_eq!(engine.dap_watch_expressions.len(), 1); + assert_eq!(engine.dap_watch_expressions[0], "expr2"); } #[test] -fn test_rust_debug_binary_no_cargo_toml() { - // A temp dir with no Cargo.toml should return an error. - let dir = std::env::temp_dir().join("vimcode_test_no_cargo"); - let _ = std::fs::create_dir_all(&dir); - let result = rust_debug_binary(&dir); - assert!( - result.is_err(), - "Should fail when Cargo.toml not found: {:?}", - result - ); - assert!( - result.unwrap_err().contains("Cargo.toml"), - "Error should mention Cargo.toml" - ); +fn test_sidebar_delete_breakpoint() { + let mut engine = Engine::new(); + engine.dap_toggle_breakpoint("/tmp/a.rs", 5); + engine.dap_toggle_breakpoint("/tmp/a.rs", 10); + engine.dap_sidebar_section = DebugSidebarSection::Breakpoints; + engine.dap_sidebar_selected = 0; + engine.handle_debug_sidebar_key("d", false); + let bps = engine.dap_breakpoints.get("/tmp/a.rs").unwrap(); + assert_eq!(bps.len(), 1); + assert_eq!(bps[0].line, 10); } #[test] -fn test_dap_breakpoint_gutter_fields() { - // Toggle a breakpoint and verify the engine state that render.rs queries. +fn test_conditional_breakpoint() { let mut engine = Engine::new(); - engine.execute_command("e /tmp/foo.rs"); - // Set a breakpoint via the "brkpt" command path. - engine.dap_toggle_breakpoint("/tmp/foo.rs", 5); - let bp = engine.dap_breakpoints.get("/tmp/foo.rs"); - assert!(bp.is_some(), "Breakpoint should be registered"); - assert_eq!(bp.unwrap().len(), 1); - assert_eq!(bp.unwrap()[0].line, 5); - // Toggle again to remove. - engine.dap_toggle_breakpoint("/tmp/foo.rs", 5); - let bp2 = engine.dap_breakpoints.get("/tmp/foo.rs"); - assert!( - bp2.map(|v| v.is_empty()).unwrap_or(true), - "Breakpoint should be removed" - ); + engine.dap_toggle_breakpoint("/tmp/a.rs", 5); + engine.dap_set_breakpoint_condition("/tmp/a.rs", 5, Some("x > 10".to_string())); + let bps = engine.dap_breakpoints.get("/tmp/a.rs").unwrap(); + assert_eq!(bps[0].condition.as_deref(), Some("x > 10")); + // Clear condition. + engine.dap_set_breakpoint_condition("/tmp/a.rs", 5, None); + let bps = engine.dap_breakpoints.get("/tmp/a.rs").unwrap(); + assert!(bps[0].condition.is_none()); } #[test] -fn test_dap_current_line_set_on_stop() { - // dap_current_line starts None and can be set/cleared directly. +fn test_conditional_breakpoint_creates_bp() { let mut engine = Engine::new(); - assert!(engine.dap_current_line.is_none()); - engine.dap_current_line = Some(("/tmp/foo.rs".to_string(), 10)); - assert_eq!( - engine.dap_current_line, - Some(("/tmp/foo.rs".to_string(), 10)) - ); - // Simulate Continued: clear the stopped line. - engine.dap_current_line = None; - engine.dap_stopped_thread = None; - assert!(engine.dap_current_line.is_none()); + // Setting a condition on a non-existent breakpoint should create one. + engine.dap_set_breakpoint_condition("/tmp/b.rs", 10, Some("i == 3".to_string())); + let bps = engine.dap_breakpoints.get("/tmp/b.rs").unwrap(); + assert_eq!(bps.len(), 1); + assert_eq!(bps[0].line, 10); + assert_eq!(bps[0].condition.as_deref(), Some("i == 3")); } #[test] -fn test_dap_current_line_cleared_on_stop_and_continued() { - // Verify that dap_session_active affects has_bp computation: - // when active, even a file with no BPs shows the gutter column. +fn test_hit_condition_breakpoint() { let mut engine = Engine::new(); - engine.dap_session_active = true; - // No breakpoints set yet — but session is active. - let bp_lines = engine - .dap_breakpoints - .get("") - .map(|v| v.as_slice()) - .unwrap_or(&[]); - let has_bp = !bp_lines.is_empty() || engine.dap_session_active; - assert!( - has_bp, - "has_bp should be true when session is active even with no BPs" - ); + engine.dap_toggle_breakpoint("/tmp/c.rs", 7); + engine.dap_set_breakpoint_hit_condition("/tmp/c.rs", 7, Some(">= 5".to_string())); + let bps = engine.dap_breakpoints.get("/tmp/c.rs").unwrap(); + assert_eq!(bps[0].hit_condition.as_deref(), Some(">= 5")); } #[test] -fn test_dap_stack_frames_parsed_from_json() { - // Simulate what poll_dap does when a stackTrace RequestComplete arrives. - use crate::core::dap::StackFrame; +fn test_dap_condition_command() { let mut engine = Engine::new(); - let frames_json = serde_json::json!([ - {"id": 1, "name": "main", "source": {"path": "/tmp/src/main.rs"}, "line": 42}, - {"id": 2, "name": "helper", "source": {"path": "/tmp/src/lib.rs"}, "line": 10}, - ]); - let frames: Vec = frames_json - .as_array() - .unwrap() - .iter() - .map(|f| StackFrame { - id: f.get("id").and_then(|v| v.as_u64()).unwrap_or(0), - name: f - .get("name") - .and_then(|v| v.as_str()) - .unwrap_or("?") - .to_string(), - source: f - .get("source") - .and_then(|s| s.get("path")) - .and_then(|p| p.as_str()) - .map(|s| s.to_string()), - line: f.get("line").and_then(|v| v.as_u64()).unwrap_or(0), - }) - .collect(); - engine.dap_stack_frames = frames; - assert_eq!(engine.dap_stack_frames.len(), 2); - assert_eq!(engine.dap_stack_frames[0].name, "main"); - assert_eq!(engine.dap_stack_frames[0].line, 42); - assert_eq!( - engine.dap_stack_frames[0].source.as_deref(), - Some("/tmp/src/main.rs") - ); - assert_eq!(engine.dap_stack_frames[1].name, "helper"); + // Set the buffer file_path directly so the command resolves it. + engine.active_buffer_state_mut().file_path = + Some(std::path::PathBuf::from("/tmp/test_cond.rs")); + // Set a regular breakpoint first. + engine.dap_toggle_breakpoint("/tmp/test_cond.rs", 1); + // Cursor is at line 0 (0-based), so DapCondition targets line 1 (1-based). + engine.execute_command("DapCondition x > 5"); + let bps = engine.dap_breakpoints.get("/tmp/test_cond.rs").unwrap(); + assert_eq!(bps[0].condition.as_deref(), Some("x > 5")); } #[test] -fn test_dap_variables_parsed_from_json() { +fn test_var_ref_at_flat_index_with_children() { use crate::core::dap::DapVariable; let mut engine = Engine::new(); - let vars_json = serde_json::json!([ - {"name": "x", "value": "42", "variablesReference": 0}, - {"name": "msg", "value": "\"hello\"", "variablesReference": 0}, - ]); - engine.dap_variables = vars_json - .as_array() - .unwrap() - .iter() - .map(|v| DapVariable { - name: v - .get("name") - .and_then(|n| n.as_str()) - .unwrap_or("?") - .to_string(), - value: v - .get("value") - .and_then(|val| val.as_str()) - .unwrap_or("") - .to_string(), - var_ref: v - .get("variablesReference") - .and_then(|r| r.as_u64()) - .unwrap_or(0), + engine.dap_variables = vec![ + DapVariable { + name: "a".to_string(), + value: "1".to_string(), + var_ref: 5, is_nonpublic: false, - }) - .collect(); - assert_eq!(engine.dap_variables.len(), 2); - assert_eq!(engine.dap_variables[0].name, "x"); - assert_eq!(engine.dap_variables[0].value, "42"); - assert_eq!(engine.dap_variables[1].name, "msg"); -} - -#[test] -fn test_strip_ansi_and_control_complete_sequence() { - // Complete CSI sequence is removed. - assert_eq!( - Engine::strip_ansi_and_control("\x1b[38;2;97;175;239mhello\x1b[0m"), - "hello" - ); - // Bare text passes through unchanged. - assert_eq!(Engine::strip_ansi_and_control("plain text"), "plain text"); - // Newlines and tabs are preserved; \r and other control chars are stripped. - assert_eq!( - Engine::strip_ansi_and_control("line1\nline2\ttab\r"), - "line1\nline2\ttab" + }, + DapVariable { + name: "b".to_string(), + value: "2".to_string(), + var_ref: 0, + is_nonpublic: false, + }, + ]; + // Not expanded: flat [a(idx=0), b(idx=1)]. + assert_eq!(engine.dap_var_ref_at_flat_index(0), Some(5)); + assert_eq!(engine.dap_var_ref_at_flat_index(1), Some(0)); + assert_eq!(engine.dap_var_ref_at_flat_index(2), None); + // Expand a → children c1, c2. + engine.dap_expanded_vars.insert(5); + engine.dap_child_variables.insert( + 5, + vec![ + DapVariable { + name: "c1".to_string(), + value: "x".to_string(), + var_ref: 0, + is_nonpublic: false, + }, + DapVariable { + name: "c2".to_string(), + value: "y".to_string(), + var_ref: 0, + is_nonpublic: false, + }, + ], ); + // Now flat: [a(0), c1(1), c2(2), b(3)]. + assert_eq!(engine.dap_var_ref_at_flat_index(0), Some(5)); + assert_eq!(engine.dap_var_ref_at_flat_index(1), Some(0)); // c1 + assert_eq!(engine.dap_var_ref_at_flat_index(2), Some(0)); // c2 + assert_eq!(engine.dap_var_ref_at_flat_index(3), Some(0)); // b } #[test] -fn test_ansi_incomplete_tail_start() { - // No ESC → no tail. - assert_eq!(Engine::ansi_incomplete_tail_start("hello"), None); - // Complete CSI → no tail. - assert_eq!(Engine::ansi_incomplete_tail_start("text\x1b[32m"), None); - // Partial CSI at end → returns its start offset. - let s = "text\x1b[38;2;97;175;239"; - let pos = Engine::ansi_incomplete_tail_start(s).unwrap(); - assert_eq!(&s[pos..], "\x1b[38;2;97;175;239"); - // Bare ESC at end → carry. - let s2 = "hello\x1b"; - assert_eq!(Engine::ansi_incomplete_tail_start(s2), Some(5)); - // ESC [ with partial params → carry. - let s3 = "\x1b["; - assert_eq!(Engine::ansi_incomplete_tail_start(s3), Some(0)); +fn test_dap_scope_groups_default_empty() { + let engine = Engine::new(); + assert!(engine.dap_scope_groups.is_empty()); } #[test] -fn test_dap_ansi_carry_handles_split_sequence() { - // Simulate two consecutive DAP output events where an ANSI RGB colour - // sequence is split: first chunk ends at `\x1b[38;2;97;175;239` (no - // final byte), second chunk supplies `mtext`. The output panel should - // receive "text", NOT "38;2;97;175;239mtext". - let first = "start\x1b[38;2;97;175;239"; - let second = "mtext end"; - // First event: tail is carried. - let combined1 = first.to_string(); - let carry = if let Some(pos) = Engine::ansi_incomplete_tail_start(&combined1) { - combined1[pos..].to_string() - } else { - String::new() - }; - let clean1 = Engine::strip_ansi_and_control(&combined1[..combined1.len() - carry.len()]); - assert_eq!(clean1, "start"); - assert_eq!(carry, "\x1b[38;2;97;175;239"); - // Second event: prepend carry, strip. - let combined2 = format!("{carry}{second}"); - let tail2 = Engine::ansi_incomplete_tail_start(&combined2); - assert_eq!(tail2, None); // sequence is now complete - let clean2 = Engine::strip_ansi_and_control(&combined2); - assert_eq!(clean2, "text end"); +fn test_dap_scope_groups_cleared_on_stop() { + let mut engine = Engine::new(); + engine.dap_scope_groups.push(("Statics".to_string(), 42)); + engine.dap_stop(); + assert!(engine.dap_scope_groups.is_empty()); } #[test] -fn test_dap_output_lines_appended_and_capped() { +fn test_dap_scope_groups_cleared_on_select_frame() { let mut engine = Engine::new(); - // Append lines and verify they accumulate. - engine - .dap_output_lines - .push("[stdout] Hello, world!".to_string()); - engine - .dap_output_lines - .push("[stderr] Error: oops".to_string()); - assert_eq!(engine.dap_output_lines.len(), 2); - assert_eq!(engine.dap_output_lines[0], "[stdout] Hello, world!"); - - // Verify cap: fill to > 1000 and drain. - engine.dap_output_lines.clear(); - for i in 0..1005 { - engine.dap_output_lines.push(format!("line {i}")); - } - if engine.dap_output_lines.len() > 1000 { - let excess = engine.dap_output_lines.len() - 1000; - engine.dap_output_lines.drain(..excess); - } - assert_eq!(engine.dap_output_lines.len(), 1000); - // After draining, the oldest 5 lines are gone; line 5 should now be first. - assert_eq!(engine.dap_output_lines[0], "line 5"); + engine.dap_scope_groups.push(("Statics".to_string(), 42)); + engine.dap_select_frame(0); + assert!(engine.dap_scope_groups.is_empty()); } #[test] -fn test_dap_frames_and_vars_cleared_on_continued() { - use crate::core::dap::{DapVariable, StackFrame}; +fn test_dap_var_flat_count_with_scope_groups() { + use crate::core::dap::DapVariable; let mut engine = Engine::new(); - engine.dap_stack_frames = vec![StackFrame { - id: 1, - name: "main".to_string(), - source: None, - line: 5, - }]; engine.dap_variables = vec![DapVariable { name: "x".to_string(), - value: "10".to_string(), + value: "1".to_string(), var_ref: 0, is_nonpublic: false, }]; - // Simulate Continued event clearing. - engine.dap_stack_frames.clear(); - engine.dap_variables.clear(); - engine.dap_current_line = None; - assert!(engine.dap_stack_frames.is_empty()); - assert!(engine.dap_variables.is_empty()); - assert!(engine.dap_current_line.is_none()); + // 1 variable, no scope groups. + assert_eq!(engine.dap_var_flat_count(), 1); + // Add two scope groups. + engine.dap_scope_groups = vec![("Statics".to_string(), 100), ("Registers".to_string(), 200)]; + // 1 var + 2 group headers = 3. + assert_eq!(engine.dap_var_flat_count(), 3); + // Expand "Statics" with 2 children. + engine.dap_expanded_vars.insert(100); + engine.dap_child_variables.insert( + 100, + vec![ + DapVariable { + name: "s1".to_string(), + value: "a".to_string(), + var_ref: 0, + is_nonpublic: false, + }, + DapVariable { + name: "s2".to_string(), + value: "b".to_string(), + var_ref: 0, + is_nonpublic: false, + }, + ], + ); + // 1 var + (1 header + 2 children) + 1 header = 5. + assert_eq!(engine.dap_var_flat_count(), 5); } #[test] -fn test_dap_sidebar_section_navigation() { +fn test_dap_var_ref_at_flat_index_with_scope_groups() { + use crate::core::dap::DapVariable; let mut engine = Engine::new(); - assert_eq!(engine.dap_sidebar_section, DebugSidebarSection::Variables); - engine.handle_debug_sidebar_key("Tab", false); - assert_eq!(engine.dap_sidebar_section, DebugSidebarSection::Watch); - engine.handle_debug_sidebar_key("Tab", false); - assert_eq!(engine.dap_sidebar_section, DebugSidebarSection::CallStack); - engine.handle_debug_sidebar_key("Tab", false); - assert_eq!(engine.dap_sidebar_section, DebugSidebarSection::Breakpoints); - engine.handle_debug_sidebar_key("Tab", false); - assert_eq!(engine.dap_sidebar_section, DebugSidebarSection::Variables); -} - -#[test] -fn test_dap_sidebar_section_index() { - assert_eq!( - Engine::dap_sidebar_section_index(DebugSidebarSection::Variables), - 0 - ); - assert_eq!( - Engine::dap_sidebar_section_index(DebugSidebarSection::Watch), - 1 - ); - assert_eq!( - Engine::dap_sidebar_section_index(DebugSidebarSection::CallStack), - 2 - ); - assert_eq!( - Engine::dap_sidebar_section_index(DebugSidebarSection::Breakpoints), - 3 + engine.dap_variables = vec![DapVariable { + name: "x".to_string(), + value: "1".to_string(), + var_ref: 0, + is_nonpublic: false, + }]; + engine.dap_scope_groups = vec![("Statics".to_string(), 100)]; + // Flat: [x(0), Statics-header(1)]. + assert_eq!(engine.dap_var_ref_at_flat_index(0), Some(0)); // x + assert_eq!(engine.dap_var_ref_at_flat_index(1), Some(100)); // Statics header + assert_eq!(engine.dap_var_ref_at_flat_index(2), None); + // Expand Statics with children. + engine.dap_expanded_vars.insert(100); + engine.dap_child_variables.insert( + 100, + vec![DapVariable { + name: "s1".to_string(), + value: "a".to_string(), + var_ref: 0, + is_nonpublic: false, + }], ); + // Flat: [x(0), Statics-header(1), s1(2)]. + assert_eq!(engine.dap_var_ref_at_flat_index(1), Some(100)); // Statics header + assert_eq!(engine.dap_var_ref_at_flat_index(2), Some(0)); // s1 + assert_eq!(engine.dap_var_ref_at_flat_index(3), None); } -#[test] -fn test_dap_sidebar_ensure_visible_scrolls_down() { - let mut engine = Engine::new(); - engine.dap_sidebar_section = DebugSidebarSection::Variables; - engine.dap_sidebar_section_heights = [5, 5, 5, 5]; - engine.dap_sidebar_scroll = [0; 4]; - // Simulate selecting item 7 (beyond the 5-row viewport). - engine.dap_sidebar_selected = 7; - engine.dap_sidebar_ensure_visible(); - // scroll should adjust so item 7 is the last visible: scroll = 7 - 5 + 1 = 3 - assert_eq!(engine.dap_sidebar_scroll[0], 3); -} +// ── Workspace / open-folder tests ───────────────────────────────────────── #[test] -fn test_dap_sidebar_ensure_visible_scrolls_up() { - let mut engine = Engine::new(); - engine.dap_sidebar_section = DebugSidebarSection::Watch; - engine.dap_sidebar_section_heights = [5, 5, 5, 5]; - engine.dap_sidebar_scroll = [0, 10, 0, 0]; // Watch scroll at 10 - // Select item 3 which is before the scroll window. - engine.dap_sidebar_selected = 3; - engine.dap_sidebar_ensure_visible(); - // scroll should jump to 3 - assert_eq!(engine.dap_sidebar_scroll[1], 3); -} +fn test_open_folder_resets_cwd() { + let dir = std::env::temp_dir().join("vimcode_test_open_folder"); + std::fs::create_dir_all(&dir).unwrap(); -#[test] -fn test_dap_sidebar_ensure_visible_no_change_when_visible() { let mut engine = Engine::new(); - engine.dap_sidebar_section = DebugSidebarSection::CallStack; - engine.dap_sidebar_section_heights = [5, 5, 5, 5]; - engine.dap_sidebar_scroll = [0, 0, 2, 0]; // CallStack scroll at 2 - engine.dap_sidebar_selected = 4; // visible: items 2,3,4,5,6 - engine.dap_sidebar_ensure_visible(); - assert_eq!(engine.dap_sidebar_scroll[2], 2); // unchanged -} + let original_cwd = engine.cwd.clone(); -#[test] -fn test_dap_sidebar_ensure_visible_zero_height_noop() { - let mut engine = Engine::new(); - engine.dap_sidebar_section = DebugSidebarSection::Variables; - engine.dap_sidebar_section_heights = [0, 0, 0, 0]; // not yet laid out - engine.dap_sidebar_selected = 10; - engine.dap_sidebar_ensure_visible(); - assert_eq!(engine.dap_sidebar_scroll[0], 0); // unchanged -} + engine.open_folder(&dir); -#[test] -fn test_dap_sidebar_resize_section() { - let mut engine = Engine::new(); - engine.dap_sidebar_section_heights = [10, 10, 10, 10]; - // Grow section 0 by 3, shrink section 1 by 3. - engine.dap_sidebar_resize_section(0, 3); - assert_eq!(engine.dap_sidebar_section_heights[0], 13); - assert_eq!(engine.dap_sidebar_section_heights[1], 7); - // Total preserved. - assert_eq!(engine.dap_sidebar_section_heights.iter().sum::(), 40); + // cwd should have changed + let expected = dir.canonicalize().unwrap_or(dir.clone()); + assert_eq!(engine.cwd, expected); + assert_ne!(engine.cwd, original_cwd); + // workspace_root should be set + assert_eq!(engine.workspace_root, Some(expected)); + // Should have exactly one tab with one empty buffer + assert_eq!(engine.active_group().tabs.len(), 1); } #[test] -fn test_dap_sidebar_resize_section_clamps_min() { +fn test_open_workspace_parses_json() { + let dir = std::env::temp_dir().join("vimcode_test_workspace_json"); + std::fs::create_dir_all(&dir).unwrap(); + let ws_path = dir.join(".vimcode-workspace"); + let json = + r#"{"version":1,"folders":[{"path":"."}],"settings":{"tabstop":4,"expandtab":true}}"#; + std::fs::write(&ws_path, json).unwrap(); + let mut engine = Engine::new(); - engine.dap_sidebar_section_heights = [3, 3, 10, 10]; - // Try to shrink section 0 by 10 — should clamp to 1. - engine.dap_sidebar_resize_section(0, -10); - assert_eq!(engine.dap_sidebar_section_heights[0], 1); - assert_eq!(engine.dap_sidebar_section_heights[1], 5); // 3 + 3 - 1 = 5 - // Total preserved. - assert_eq!( - engine.dap_sidebar_section_heights[0] + engine.dap_sidebar_section_heights[1], - 6 - ); + engine.open_workspace(&ws_path); + + // Should have changed cwd to dir + let expected = dir.canonicalize().unwrap_or(dir.clone()); + assert_eq!(engine.cwd, expected); + // workspace_file should be recorded + assert_eq!(engine.workspace_file, Some(ws_path)); + // Settings overlay: tabstop = 4 + assert_eq!(engine.settings.tabstop, 4); + // expandtab = true + assert!(engine.settings.expand_tab); +} + +// ========================================================================= +// Plugin system engine-integration tests +// ========================================================================= + +fn write_plugin_lua(name: &str, code: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!("vimcode_plugins_{name}")); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join(format!("{name}.lua")); + std::fs::write(&path, code).unwrap(); + dir } #[test] -fn test_dap_sidebar_resize_section_last_noop() { +fn test_plugin_loads_and_command_runs() { + let dir = write_plugin_lua( + "test_cmd", + r#"vimcode.command("TestHello", function(args) vimcode.message("hi:" .. args) end)"#, + ); let mut engine = Engine::new(); - engine.dap_sidebar_section_heights = [10, 10, 10, 10]; - // section_idx=3 has no next section — should be a no-op. - engine.dap_sidebar_resize_section(3, 5); - assert_eq!(engine.dap_sidebar_section_heights, [10, 10, 10, 10]); + match plugin::PluginManager::new() { + Ok(mut mgr) => { + mgr.load_plugins_dir(&dir, &[]); + engine.plugin_manager = Some(mgr); + } + Err(_) => return, + } + let action = engine.execute_command("TestHello world"); + assert_eq!(action, EngineAction::None); + assert_eq!(engine.message, "hi:world"); } #[test] -fn test_dap_sidebar_scroll_reset_on_stop() { +fn test_plugin_on_save_fires() { + let dir = write_plugin_lua( + "test_save", + r#"vimcode.on("save", function(path) vimcode.message("saved:" .. path) end)"#, + ); let mut engine = Engine::new(); - engine.dap_sidebar_scroll = [5, 10, 3, 7]; - engine.dap_session_active = true; - engine.dap_stop(); - assert_eq!(engine.dap_sidebar_scroll, [0, 0, 0, 0]); + match plugin::PluginManager::new() { + Ok(mut mgr) => { + mgr.load_plugins_dir(&dir, &[]); + engine.plugin_manager = Some(mgr); + } + Err(_) => return, + } + let tmp_file = std::env::temp_dir().join("vimcode_save_hook_test.txt"); + std::fs::write(&tmp_file, "hello\n").unwrap(); + engine.open_file_in_tab(&tmp_file); + let _ = engine.save(); + assert!( + engine.message.starts_with("saved:"), + "expected save hook to fire, got: {}", + engine.message + ); + let _ = std::fs::remove_file(&tmp_file); } #[test] -fn test_dap_sidebar_jk_triggers_ensure_visible() { - use crate::core::dap::DapVariable; +fn test_plugin_disabled_not_registered() { + let dir = write_plugin_lua( + "test_disabled", + r#"vimcode.command("DisabledCmd", function() vimcode.message("should not run") end)"#, + ); let mut engine = Engine::new(); - engine.dap_sidebar_has_focus = true; - engine.dap_sidebar_section = DebugSidebarSection::Variables; - engine.dap_sidebar_section_heights = [3, 3, 3, 3]; - // Create 10 variables so we can scroll. - engine.dap_variables = (0..10) - .map(|i| DapVariable { - name: format!("v{i}"), - value: format!("{i}"), - var_ref: 0, - is_nonpublic: false, - }) - .collect(); - engine.dap_sidebar_selected = 0; - // Press j 5 times to go to item 5. - for _ in 0..5 { - engine.handle_debug_sidebar_key("j", false); + match plugin::PluginManager::new() { + Ok(mut mgr) => { + mgr.load_plugins_dir(&dir, &["test_disabled".to_string()]); + engine.plugin_manager = Some(mgr); + } + Err(_) => return, } - assert_eq!(engine.dap_sidebar_selected, 5); - // Scroll should have adjusted: 5 >= 0 + 3 → scroll = 5 - 3 + 1 = 3 - assert_eq!(engine.dap_sidebar_scroll[0], 3); + let action = engine.execute_command("DisabledCmd"); + assert_eq!(action, EngineAction::Error); + assert!(engine.message.contains("Not an editor command")); } -#[test] -fn test_dap_select_frame_clamps() { - use crate::core::dap::StackFrame; +// ─── Source Control (Session 99) ───────────────────────────────────────── + +/// Build an engine with synthetic SC file statuses for testing. +fn make_sc_engine_with_files() -> Engine { let mut engine = Engine::new(); - engine.dap_stack_frames = vec![ - StackFrame { - id: 1, - name: "main".to_string(), - source: None, - line: 1, - }, - StackFrame { - id: 2, - name: "foo".to_string(), - source: None, - line: 2, + engine.sc_file_statuses = vec![ + git::FileStatus { + path: "a.rs".to_string(), + staged: Some(git::StatusKind::Modified), + unstaged: None, }, - StackFrame { - id: 3, - name: "bar".to_string(), - source: None, - line: 3, + git::FileStatus { + path: "b.rs".to_string(), + staged: None, + unstaged: Some(git::StatusKind::Modified), }, ]; - // Select within bounds. - engine.dap_select_frame(1); - assert_eq!(engine.dap_active_frame, 1); - // Select beyond bounds: clamps to last. - engine.dap_select_frame(99); - assert_eq!(engine.dap_active_frame, 2); - // Select 0. - engine.dap_select_frame(0); - assert_eq!(engine.dap_active_frame, 0); -} - -#[test] -fn test_dap_variable_expand_tracking() { - let mut engine = Engine::new(); - assert!(!engine.dap_expanded_vars.contains(&5)); - // Toggle on. - engine.dap_expanded_vars.insert(5); - assert!(engine.dap_expanded_vars.contains(&5)); - // Toggle off. - engine.dap_expanded_vars.remove(&5); - assert!(!engine.dap_expanded_vars.contains(&5)); -} - -#[test] -fn test_dap_eval_result_field_default() { - let engine = Engine::new(); - assert!(engine.dap_eval_result.is_none()); - assert_eq!(engine.dap_sidebar_section, DebugSidebarSection::Variables); - assert_eq!(engine.dap_active_frame, 0); - assert!(engine.dap_expanded_vars.is_empty()); - assert!(engine.dap_child_variables.is_empty()); + engine.sc_sections_expanded = [true, true, false, true]; + engine } #[test] -fn test_visual_rows_for_line() { - assert_eq!(engine_visual_rows_for_line(0, 80), 1); // empty line = 1 row - assert_eq!(engine_visual_rows_for_line(80, 80), 1); // exactly one row - assert_eq!(engine_visual_rows_for_line(81, 80), 2); // one char overflow - assert_eq!(engine_visual_rows_for_line(160, 80), 2); // exactly two rows - assert_eq!(engine_visual_rows_for_line(161, 80), 3); - assert_eq!(engine_visual_rows_for_line(10, 0), 1); // zero cols = 1 row +fn test_sc_commit_input_mode_toggle() { + let mut engine = make_sc_engine_with_files(); + assert!(!engine.sc_commit_input_active); + engine.handle_sc_key("c", false, None); + assert!(engine.sc_commit_input_active); + engine.handle_sc_key("Escape", false, None); + assert!(!engine.sc_commit_input_active); } #[test] -fn test_ensure_cursor_visible_wrap_scrolls_down() { - let mut engine = Engine::new(); - engine.settings.wrap = true; - // Fill buffer with 20 short lines so the content exists. - let text = (0..20).map(|i| format!("line {i}\n")).collect::(); - engine.buffer_mut().content = ropey::Rope::from_str(&text); - // Viewport: 10 lines of 80 cols - engine.view_mut().viewport_lines = 10; - engine.view_mut().viewport_cols = 80; - engine.view_mut().scroll_top = 0; - // Move cursor to line 15 — beyond the viewport. - engine.view_mut().cursor.line = 15; - engine.view_mut().cursor.col = 0; - engine.ensure_cursor_visible(); - // scroll_top should have advanced so cursor is visible. - assert!(engine.view().scroll_top > 0); - assert!(engine.view().cursor.line >= engine.view().scroll_top); - assert!(engine.view().cursor.line < engine.view().scroll_top + engine.view().viewport_lines); +fn test_sc_commit_input_typing() { + let mut engine = make_sc_engine_with_files(); + engine.sc_commit_input_active = true; + engine.handle_sc_key("", false, Some('h')); + engine.handle_sc_key("", false, Some('i')); + assert_eq!(engine.sc_commit_message, "hi"); } #[test] -fn test_ensure_cursor_visible_wrap_scrolls_up() { - let mut engine = Engine::new(); - engine.settings.wrap = true; - let text = (0..20).map(|i| format!("line {i}\n")).collect::(); - engine.buffer_mut().content = ropey::Rope::from_str(&text); - engine.view_mut().viewport_lines = 10; - engine.view_mut().viewport_cols = 80; - engine.view_mut().scroll_top = 15; - // Move cursor above scroll_top. - engine.view_mut().cursor.line = 5; - engine.view_mut().cursor.col = 0; - engine.ensure_cursor_visible(); - assert_eq!(engine.view().scroll_top, 5); +fn test_sc_commit_input_backspace() { + let mut engine = make_sc_engine_with_files(); + engine.sc_commit_input_active = true; + engine.sc_commit_message = "abc".to_string(); + engine.sc_commit_cursor = 3; + engine.handle_sc_key("BackSpace", false, None); + assert_eq!(engine.sc_commit_message, "ab"); } #[test] -fn test_dap_add_remove_watch() { - let mut engine = Engine::new(); - engine.dap_add_watch("x + 1".to_string()); - engine.dap_add_watch("y".to_string()); - assert_eq!(engine.dap_watch_expressions, vec!["x + 1", "y"]); - assert_eq!(engine.dap_watch_values.len(), 2); - assert!(engine.dap_watch_values[0].is_none()); - // Remove the first watch. - engine.dap_remove_watch(0); - assert_eq!(engine.dap_watch_expressions, vec!["y"]); - assert_eq!(engine.dap_watch_values.len(), 1); - // Remove out-of-bounds: no-op. - engine.dap_remove_watch(99); - assert_eq!(engine.dap_watch_expressions.len(), 1); +fn test_sc_commit_empty_message_error() { + let mut engine = make_sc_engine_with_files(); + engine.sc_commit_input_active = true; + engine.sc_commit_message = "".to_string(); + // simulate Enter with empty message + engine.sc_do_commit(); + assert!(engine.message.contains("empty")); + // Input mode stays active implicitly (message set, but no state change needed) } #[test] -fn test_dap_bottom_panel_kind_default() { - let engine = Engine::new(); - assert_eq!(engine.bottom_panel_kind, BottomPanelKind::Terminal); +fn test_sc_nav_j_k() { + let mut engine = make_sc_engine_with_files(); + engine.sc_has_focus = true; + assert_eq!(engine.sc_selected, 0); + engine.handle_sc_key("j", false, None); + assert_eq!(engine.sc_selected, 1); + engine.handle_sc_key("k", false, None); + assert_eq!(engine.sc_selected, 0); } #[test] -fn test_dap_launch_configs_default() { - let engine = Engine::new(); - assert!(engine.dap_launch_configs.is_empty()); - assert_eq!(engine.dap_selected_launch_config, 0); +fn test_sc_nav_clamps_at_bottom() { + let mut engine = make_sc_engine_with_files(); + engine.sc_has_focus = true; + let len = engine.sc_flat_len(); + for _ in 0..len + 5 { + engine.handle_sc_key("j", false, None); + } + assert_eq!(engine.sc_selected, len - 1); } #[test] -fn test_debug_toolbar_default_false() { - let engine = Engine::new(); - assert!( - !engine.debug_toolbar_visible, - "toolbar should default to hidden" - ); +fn test_sc_nav_clamps_at_top() { + let mut engine = make_sc_engine_with_files(); + engine.sc_has_focus = true; + engine.handle_sc_key("k", false, None); + assert_eq!(engine.sc_selected, 0); } -// ── Session 90: Interactive debug sidebar + conditional breakpoints ────── - #[test] -fn test_sidebar_var_expand_via_enter() { - use crate::core::dap::DapVariable; - let mut engine = Engine::new(); - engine.dap_variables = vec![ - DapVariable { - name: "x".to_string(), - value: "42".to_string(), - var_ref: 10, - is_nonpublic: false, - }, - DapVariable { - name: "y".to_string(), - value: "7".to_string(), - var_ref: 0, - is_nonpublic: false, - }, - ]; - engine.dap_sidebar_section = DebugSidebarSection::Variables; - engine.dap_sidebar_selected = 0; - // Enter on expandable var should toggle expand. - engine.handle_debug_sidebar_key("Return", false); - assert!( - engine.dap_expanded_vars.contains(&10), - "var_ref 10 should be expanded" - ); - // Enter again should collapse. - engine.dap_sidebar_selected = 0; - engine.handle_debug_sidebar_key("Return", false); - assert!( - !engine.dap_expanded_vars.contains(&10), - "var_ref 10 should be collapsed" - ); +fn test_sc_tab_toggles_section() { + let mut engine = make_sc_engine_with_files(); + assert!(engine.sc_sections_expanded[0]); + engine.handle_sc_key("Tab", false, None); + assert!(!engine.sc_sections_expanded[0]); + engine.handle_sc_key("Tab", false, None); + assert!(engine.sc_sections_expanded[0]); } #[test] -fn test_sidebar_var_enter_on_non_expandable() { - use crate::core::dap::DapVariable; - let mut engine = Engine::new(); - engine.dap_variables = vec![DapVariable { - name: "x".to_string(), - value: "42".to_string(), - var_ref: 0, - is_nonpublic: false, - }]; - engine.dap_sidebar_section = DebugSidebarSection::Variables; - engine.dap_sidebar_selected = 0; - engine.handle_debug_sidebar_key("Return", false); - // No expansion should happen for var_ref=0. - assert!(engine.dap_expanded_vars.is_empty()); +fn test_sc_escape_unfocuses() { + let mut engine = make_sc_engine_with_files(); + engine.sc_has_focus = true; + engine.handle_sc_key("Escape", false, None); + assert!(!engine.sc_has_focus); } #[test] -fn test_sidebar_callstack_enter_selects_frame() { - use crate::core::dap::StackFrame; - let mut engine = Engine::new(); - engine.dap_stack_frames = vec![ - StackFrame { - id: 1, - name: "main".to_string(), - source: None, - line: 10, - }, - StackFrame { - id: 2, - name: "foo".to_string(), - source: None, - line: 20, - }, - ]; - engine.dap_sidebar_section = DebugSidebarSection::CallStack; - engine.dap_sidebar_selected = 1; - engine.handle_debug_sidebar_key("Return", false); - assert_eq!(engine.dap_active_frame, 1, "should select frame 1"); +fn test_sc_q_unfocuses() { + let mut engine = make_sc_engine_with_files(); + engine.sc_has_focus = true; + engine.handle_sc_key("q", false, None); + assert!(!engine.sc_has_focus); } #[test] -fn test_sidebar_section_len_variables() { - use crate::core::dap::DapVariable; - let mut engine = Engine::new(); - engine.dap_variables = vec![ - DapVariable { - name: "a".to_string(), - value: "1".to_string(), - var_ref: 5, - is_nonpublic: false, - }, - DapVariable { - name: "b".to_string(), - value: "2".to_string(), - var_ref: 0, - is_nonpublic: false, - }, - ]; - engine.dap_sidebar_section = DebugSidebarSection::Variables; - // 2 top-level vars, none expanded. - assert_eq!(engine.dap_sidebar_section_len(), 2); - // Expand var_ref=5 with 3 children. - engine.dap_expanded_vars.insert(5); - engine.dap_child_variables.insert( - 5, - vec![ - DapVariable { - name: "c1".to_string(), - value: "x".to_string(), - var_ref: 0, - is_nonpublic: false, - }, - DapVariable { - name: "c2".to_string(), - value: "y".to_string(), - var_ref: 0, - is_nonpublic: false, - }, - DapVariable { - name: "c3".to_string(), - value: "z".to_string(), - var_ref: 0, - is_nonpublic: false, - }, - ], - ); - assert_eq!(engine.dap_sidebar_section_len(), 5); +fn test_sc_commit_input_blocks_nav() { + let mut engine = make_sc_engine_with_files(); + engine.sc_commit_input_active = true; + let before = engine.sc_selected; + // 'j' should go to commit input handler, not navigate + engine.handle_sc_key("j", false, None); + // selected should not have changed (j has no meaning in commit input) + assert_eq!(engine.sc_selected, before); + // but the commit input should still be active + assert!(engine.sc_commit_input_active); } #[test] -fn test_sidebar_j_k_clamped() { - use crate::core::dap::DapVariable; - let mut engine = Engine::new(); - engine.dap_variables = vec![DapVariable { - name: "x".to_string(), - value: "1".to_string(), - var_ref: 0, - is_nonpublic: false, - }]; - engine.dap_sidebar_section = DebugSidebarSection::Variables; - engine.dap_sidebar_selected = 0; - // j should not go past last item. - engine.handle_debug_sidebar_key("j", false); - assert_eq!(engine.dap_sidebar_selected, 0, "clamped at end"); - // k should not go below 0. - engine.handle_debug_sidebar_key("k", false); - assert_eq!(engine.dap_sidebar_selected, 0, "clamped at start"); +fn test_sc_commit_multiline_enter_inserts_newline() { + let mut engine = make_sc_engine_with_files(); + engine.sc_commit_input_active = true; + engine.handle_sc_commit_input_key("", false, Some('H')); + engine.handle_sc_commit_input_key("", false, Some('i')); + engine.handle_sc_commit_input_key("Return", false, None); + engine.handle_sc_commit_input_key("", false, Some('b')); + assert_eq!(engine.sc_commit_message, "Hi\nb"); + assert!(engine.sc_commit_input_active); } #[test] -fn test_sidebar_delete_watch() { - let mut engine = Engine::new(); - engine.dap_add_watch("expr1".to_string()); - engine.dap_add_watch("expr2".to_string()); - engine.dap_sidebar_section = DebugSidebarSection::Watch; - engine.dap_sidebar_selected = 0; - engine.handle_debug_sidebar_key("x", false); - assert_eq!(engine.dap_watch_expressions.len(), 1); - assert_eq!(engine.dap_watch_expressions[0], "expr2"); +fn test_sc_commit_ctrl_enter_commits() { + let mut engine = make_sc_engine_with_files(); + engine.sc_commit_input_active = true; + engine.sc_commit_message = "test\nmultiline".to_string(); + engine.sc_commit_cursor = engine.sc_commit_message.len(); + engine.handle_sc_commit_input_key("Return", true, None); + // Commit fails (no real repo), but input mode should be deactivated. + // The commit_message should be cleared if commit succeeded or stay if it failed. + // Since there's no git repo, sc_do_commit will error but won't clear. + assert!(!engine.sc_commit_input_active || !engine.sc_commit_message.is_empty()); } #[test] -fn test_sidebar_delete_breakpoint() { - let mut engine = Engine::new(); - engine.dap_toggle_breakpoint("/tmp/a.rs", 5); - engine.dap_toggle_breakpoint("/tmp/a.rs", 10); - engine.dap_sidebar_section = DebugSidebarSection::Breakpoints; - engine.dap_sidebar_selected = 0; - engine.handle_debug_sidebar_key("d", false); - let bps = engine.dap_breakpoints.get("/tmp/a.rs").unwrap(); - assert_eq!(bps.len(), 1); - assert_eq!(bps[0].line, 10); +fn test_ssh_passphrase_dialog_shown() { + let mut engine = make_sc_engine_with_files(); + // Simulate showing passphrase dialog + engine.sc_show_passphrase_dialog("pull"); + assert!(engine.dialog.is_some()); + let dialog = engine.dialog.as_ref().unwrap(); + assert_eq!(dialog.tag, "ssh_passphrase"); + assert!(dialog.input.is_some()); + assert!(dialog.input.as_ref().unwrap().is_password); + assert_eq!(engine.pending_git_remote_op, Some("pull".to_string())); } #[test] -fn test_conditional_breakpoint() { - let mut engine = Engine::new(); - engine.dap_toggle_breakpoint("/tmp/a.rs", 5); - engine.dap_set_breakpoint_condition("/tmp/a.rs", 5, Some("x > 10".to_string())); - let bps = engine.dap_breakpoints.get("/tmp/a.rs").unwrap(); - assert_eq!(bps[0].condition.as_deref(), Some("x > 10")); - // Clear condition. - engine.dap_set_breakpoint_condition("/tmp/a.rs", 5, None); - let bps = engine.dap_breakpoints.get("/tmp/a.rs").unwrap(); - assert!(bps[0].condition.is_none()); +fn test_dialog_input_typing() { + let mut engine = make_sc_engine_with_files(); + engine.sc_show_passphrase_dialog("push"); + // Type into the dialog input + engine.handle_key("", Some('a'), false); + engine.handle_key("", Some('b'), false); + engine.handle_key("", Some('c'), false); + let input_val = engine + .dialog + .as_ref() + .unwrap() + .input + .as_ref() + .unwrap() + .value + .clone(); + assert_eq!(input_val, "abc"); + // Backspace + engine.handle_key("BackSpace", None, false); + let input_val = engine + .dialog + .as_ref() + .unwrap() + .input + .as_ref() + .unwrap() + .value + .clone(); + assert_eq!(input_val, "ab"); } #[test] -fn test_conditional_breakpoint_creates_bp() { - let mut engine = Engine::new(); - // Setting a condition on a non-existent breakpoint should create one. - engine.dap_set_breakpoint_condition("/tmp/b.rs", 10, Some("i == 3".to_string())); - let bps = engine.dap_breakpoints.get("/tmp/b.rs").unwrap(); - assert_eq!(bps.len(), 1); - assert_eq!(bps[0].line, 10); - assert_eq!(bps[0].condition.as_deref(), Some("i == 3")); +fn test_dialog_input_cancel() { + let mut engine = make_sc_engine_with_files(); + engine.sc_show_passphrase_dialog("fetch"); + engine.handle_key("Escape", None, false); + assert!(engine.dialog.is_none()); + assert!(engine.pending_git_remote_op.is_none()); } #[test] -fn test_hit_condition_breakpoint() { - let mut engine = Engine::new(); - engine.dap_toggle_breakpoint("/tmp/c.rs", 7); - engine.dap_set_breakpoint_hit_condition("/tmp/c.rs", 7, Some(">= 5".to_string())); - let bps = engine.dap_breakpoints.get("/tmp/c.rs").unwrap(); - assert_eq!(bps[0].hit_condition.as_deref(), Some(">= 5")); +fn test_sc_commit_cursor_arrow_keys() { + let mut engine = make_sc_engine_with_files(); + engine.sc_commit_input_active = true; + // Type "abc" + engine.handle_sc_commit_input_key("", false, Some('a')); + engine.handle_sc_commit_input_key("", false, Some('b')); + engine.handle_sc_commit_input_key("", false, Some('c')); + assert_eq!(engine.sc_commit_cursor, 3); + // Left moves cursor back. + engine.handle_sc_commit_input_key("Left", false, None); + assert_eq!(engine.sc_commit_cursor, 2); + // Insert at cursor position. + engine.handle_sc_commit_input_key("", false, Some('X')); + assert_eq!(engine.sc_commit_message, "abXc"); + assert_eq!(engine.sc_commit_cursor, 3); + // Right moves cursor forward. + engine.handle_sc_commit_input_key("Right", false, None); + assert_eq!(engine.sc_commit_cursor, 4); + // Home moves to start of line. + engine.handle_sc_commit_input_key("Home", false, None); + assert_eq!(engine.sc_commit_cursor, 0); + // End moves to end of line. + engine.handle_sc_commit_input_key("End", false, None); + assert_eq!(engine.sc_commit_cursor, 4); } #[test] -fn test_dap_condition_command() { - let mut engine = Engine::new(); - // Set the buffer file_path directly so the command resolves it. - engine.active_buffer_state_mut().file_path = - Some(std::path::PathBuf::from("/tmp/test_cond.rs")); - // Set a regular breakpoint first. - engine.dap_toggle_breakpoint("/tmp/test_cond.rs", 1); - // Cursor is at line 0 (0-based), so DapCondition targets line 1 (1-based). - engine.execute_command("DapCondition x > 5"); - let bps = engine.dap_breakpoints.get("/tmp/test_cond.rs").unwrap(); - assert_eq!(bps[0].condition.as_deref(), Some("x > 5")); +fn test_sc_commit_cursor_up_down() { + let mut engine = make_sc_engine_with_files(); + engine.sc_commit_input_active = true; + engine.sc_commit_message = "abc\nde\nfghij".to_string(); + engine.sc_commit_cursor = 5; // on 'e' in "de" + // Down moves to next line, same column. + engine.handle_sc_commit_input_key("Down", false, None); + assert_eq!(engine.sc_commit_cursor, 8); // 'h' in "fghij" + // Up moves back. + engine.handle_sc_commit_input_key("Up", false, None); + assert_eq!(engine.sc_commit_cursor, 5); // 'e' in "de" + // Up again to first line. + engine.handle_sc_commit_input_key("Up", false, None); + assert_eq!(engine.sc_commit_cursor, 1); // 'b' in "abc" (col 1) } #[test] -fn test_var_ref_at_flat_index_with_children() { - use crate::core::dap::DapVariable; - let mut engine = Engine::new(); - engine.dap_variables = vec![ - DapVariable { - name: "a".to_string(), - value: "1".to_string(), - var_ref: 5, - is_nonpublic: false, - }, - DapVariable { - name: "b".to_string(), - value: "2".to_string(), - var_ref: 0, - is_nonpublic: false, - }, - ]; - // Not expanded: flat [a(idx=0), b(idx=1)]. - assert_eq!(engine.dap_var_ref_at_flat_index(0), Some(5)); - assert_eq!(engine.dap_var_ref_at_flat_index(1), Some(0)); - assert_eq!(engine.dap_var_ref_at_flat_index(2), None); - // Expand a → children c1, c2. - engine.dap_expanded_vars.insert(5); - engine.dap_child_variables.insert( - 5, - vec![ - DapVariable { - name: "c1".to_string(), - value: "x".to_string(), - var_ref: 0, - is_nonpublic: false, - }, - DapVariable { - name: "c2".to_string(), - value: "y".to_string(), - var_ref: 0, - is_nonpublic: false, - }, - ], - ); - // Now flat: [a(0), c1(1), c2(2), b(3)]. - assert_eq!(engine.dap_var_ref_at_flat_index(0), Some(5)); - assert_eq!(engine.dap_var_ref_at_flat_index(1), Some(0)); // c1 - assert_eq!(engine.dap_var_ref_at_flat_index(2), Some(0)); // c2 - assert_eq!(engine.dap_var_ref_at_flat_index(3), Some(0)); // b +fn test_sc_commit_cursor_backspace_at_position() { + let mut engine = make_sc_engine_with_files(); + engine.sc_commit_input_active = true; + engine.sc_commit_message = "hello".to_string(); + engine.sc_commit_cursor = 3; // after "hel" + engine.handle_sc_commit_input_key("BackSpace", false, None); + assert_eq!(engine.sc_commit_message, "helo"); + assert_eq!(engine.sc_commit_cursor, 2); } #[test] -fn test_dap_scope_groups_default_empty() { - let engine = Engine::new(); - assert!(engine.dap_scope_groups.is_empty()); +fn test_sc_commit_cursor_delete() { + let mut engine = make_sc_engine_with_files(); + engine.sc_commit_input_active = true; + engine.sc_commit_message = "hello".to_string(); + engine.sc_commit_cursor = 2; + engine.handle_sc_commit_input_key("Delete", false, None); + assert_eq!(engine.sc_commit_message, "helo"); + assert_eq!(engine.sc_commit_cursor, 2); } #[test] -fn test_dap_scope_groups_cleared_on_stop() { - let mut engine = Engine::new(); - engine.dap_scope_groups.push(("Statics".to_string(), 42)); - engine.dap_stop(); - assert!(engine.dap_scope_groups.is_empty()); +fn test_gpull_and_gfetch_commands_exist() { + let mut engine = make_sc_engine_with_files(); + // These will fail with a git error since cwd is not a real repo, but + // they should not return EngineAction::Error ("Not an editor command"). + let r1 = engine.execute_command("Gpull"); + let r2 = engine.execute_command("Gfetch"); + assert_ne!(r1, EngineAction::Error); + assert_ne!(r2, EngineAction::Error); } #[test] -fn test_dap_scope_groups_cleared_on_select_frame() { - let mut engine = Engine::new(); - engine.dap_scope_groups.push(("Statics".to_string(), 42)); - engine.dap_select_frame(0); - assert!(engine.dap_scope_groups.is_empty()); +fn test_sc_branch_picker_open_close() { + let mut engine = make_sc_engine_with_files(); + engine.sc_has_focus = true; + assert!(!engine.sc_branch_picker_open); + engine.handle_sc_key("b", false, None); + assert!(engine.sc_branch_picker_open); + // Escape closes it + engine.handle_sc_key("Escape", false, None); + assert!(!engine.sc_branch_picker_open); + assert!(engine.sc_branch_picker_query.is_empty()); } #[test] -fn test_dap_var_flat_count_with_scope_groups() { - use crate::core::dap::DapVariable; - let mut engine = Engine::new(); - engine.dap_variables = vec![DapVariable { - name: "x".to_string(), - value: "1".to_string(), - var_ref: 0, - is_nonpublic: false, - }]; - // 1 variable, no scope groups. - assert_eq!(engine.dap_var_flat_count(), 1); - // Add two scope groups. - engine.dap_scope_groups = vec![("Statics".to_string(), 100), ("Registers".to_string(), 200)]; - // 1 var + 2 group headers = 3. - assert_eq!(engine.dap_var_flat_count(), 3); - // Expand "Statics" with 2 children. - engine.dap_expanded_vars.insert(100); - engine.dap_child_variables.insert( - 100, - vec![ - DapVariable { - name: "s1".to_string(), - value: "a".to_string(), - var_ref: 0, - is_nonpublic: false, - }, - DapVariable { - name: "s2".to_string(), - value: "b".to_string(), - var_ref: 0, - is_nonpublic: false, - }, - ], - ); - // 1 var + (1 header + 2 children) + 1 header = 5. - assert_eq!(engine.dap_var_flat_count(), 5); +fn test_sc_branch_picker_typing_filters() { + let mut engine = make_sc_engine_with_files(); + engine.sc_has_focus = true; + engine.handle_sc_key("b", false, None); + assert!(engine.sc_branch_picker_open); + // Type a query character + engine.handle_sc_key("", false, Some('m')); + assert_eq!(engine.sc_branch_picker_query, "m"); + engine.handle_sc_key("", false, Some('a')); + assert_eq!(engine.sc_branch_picker_query, "ma"); + // Backspace removes + engine.handle_sc_key("BackSpace", false, None); + assert_eq!(engine.sc_branch_picker_query, "m"); } #[test] -fn test_dap_var_ref_at_flat_index_with_scope_groups() { - use crate::core::dap::DapVariable; - let mut engine = Engine::new(); - engine.dap_variables = vec![DapVariable { - name: "x".to_string(), - value: "1".to_string(), - var_ref: 0, - is_nonpublic: false, - }]; - engine.dap_scope_groups = vec![("Statics".to_string(), 100)]; - // Flat: [x(0), Statics-header(1)]. - assert_eq!(engine.dap_var_ref_at_flat_index(0), Some(0)); // x - assert_eq!(engine.dap_var_ref_at_flat_index(1), Some(100)); // Statics header - assert_eq!(engine.dap_var_ref_at_flat_index(2), None); - // Expand Statics with children. - engine.dap_expanded_vars.insert(100); - engine.dap_child_variables.insert( - 100, - vec![DapVariable { - name: "s1".to_string(), - value: "a".to_string(), - var_ref: 0, - is_nonpublic: false, - }], - ); - // Flat: [x(0), Statics-header(1), s1(2)]. - assert_eq!(engine.dap_var_ref_at_flat_index(1), Some(100)); // Statics header - assert_eq!(engine.dap_var_ref_at_flat_index(2), Some(0)); // s1 - assert_eq!(engine.dap_var_ref_at_flat_index(3), None); +fn test_sc_branch_create_mode() { + let mut engine = make_sc_engine_with_files(); + engine.sc_has_focus = true; + engine.handle_sc_key("B", false, None); + assert!(engine.sc_branch_create_mode); + // Type branch name + engine.handle_sc_key("", false, Some('f')); + engine.handle_sc_key("", false, Some('o')); + engine.handle_sc_key("", false, Some('o')); + assert_eq!(engine.sc_branch_create_input, "foo"); + // Escape cancels + engine.handle_sc_key("Escape", false, None); + assert!(!engine.sc_branch_create_mode); + assert!(engine.sc_branch_create_input.is_empty()); } -// ── Workspace / open-folder tests ───────────────────────────────────────── - #[test] -fn test_open_folder_resets_cwd() { - let dir = std::env::temp_dir().join("vimcode_test_open_folder"); - std::fs::create_dir_all(&dir).unwrap(); - - let mut engine = Engine::new(); - let original_cwd = engine.cwd.clone(); - - engine.open_folder(&dir); - - // cwd should have changed - let expected = dir.canonicalize().unwrap_or(dir.clone()); - assert_eq!(engine.cwd, expected); - assert_ne!(engine.cwd, original_cwd); - // workspace_root should be set - assert_eq!(engine.workspace_root, Some(expected)); - // Should have exactly one tab with one empty buffer - assert_eq!(engine.active_group().tabs.len(), 1); +fn test_sc_help_toggle() { + let mut engine = make_sc_engine_with_files(); + engine.sc_has_focus = true; + assert!(!engine.sc_help_open); + engine.handle_sc_key("?", false, None); + assert!(engine.sc_help_open); + // Any key closes + engine.handle_sc_key("j", false, None); + assert!(!engine.sc_help_open); } #[test] -fn test_open_workspace_parses_json() { - let dir = std::env::temp_dir().join("vimcode_test_workspace_json"); - std::fs::create_dir_all(&dir).unwrap(); - let ws_path = dir.join(".vimcode-workspace"); - let json = - r#"{"version":1,"folders":[{"path":"."}],"settings":{"tabstop":4,"expandtab":true}}"#; - std::fs::write(&ws_path, json).unwrap(); - - let mut engine = Engine::new(); - engine.open_workspace(&ws_path); - - // Should have changed cwd to dir - let expected = dir.canonicalize().unwrap_or(dir.clone()); - assert_eq!(engine.cwd, expected); - // workspace_file should be recorded - assert_eq!(engine.workspace_file, Some(ws_path)); - // Settings overlay: tabstop = 4 - assert_eq!(engine.settings.tabstop, 4); - // expandtab = true - assert!(engine.settings.expand_tab); +fn test_sc_help_escape_closes() { + let mut engine = make_sc_engine_with_files(); + engine.sc_has_focus = true; + engine.handle_sc_key("?", false, None); + assert!(engine.sc_help_open); + engine.handle_sc_key("Escape", false, None); + assert!(!engine.sc_help_open); } -// ========================================================================= -// Plugin system engine-integration tests -// ========================================================================= +// ─── sc_visual_row_to_flat tests (click-math correctness) ───────────────── -fn write_plugin_lua(name: &str, code: &str) -> std::path::PathBuf { - let dir = std::env::temp_dir().join(format!("vimcode_plugins_{name}")); - std::fs::create_dir_all(&dir).unwrap(); - let path = dir.join(format!("{name}.lua")); - std::fs::write(&path, code).unwrap(); - dir +/// make_sc_engine_with_files gives: 1 staged file + 1 unstaged file, +/// sections_expanded = [true, true, false, true], sc_worktrees = [] (no linked worktrees). +/// GTK (no hint) flat layout (row 0=header, 1=commit, 2=buttons, 3+=sections): +/// row 3 → flat 0 (STAGED header) +/// row 4 → flat 1 (a.rs – staged) +/// row 5 → flat 2 (CHANGES header) +/// row 6 → flat 3 (b.rs – unstaged) +/// row 7 → flat 4 (LOG header — WORKTREES hidden, sc_worktrees.len() == 0) +/// row 8 → None (log expanded but empty, no hint in GTK mode) +#[test] +fn test_sc_visual_row_to_flat_gtk() { + let engine = make_sc_engine_with_files(); // staged=1, unstaged=1, worktrees=0 + // Rows 0–2 are header / commit input / button row — should return None. + assert_eq!(engine.sc_visual_row_to_flat(0, false), None); + assert_eq!(engine.sc_visual_row_to_flat(1, false), None); + assert_eq!(engine.sc_visual_row_to_flat(2, false), None); + // STAGED header + assert_eq!(engine.sc_visual_row_to_flat(3, false), Some((0, true))); + // staged file a.rs + assert_eq!(engine.sc_visual_row_to_flat(4, false), Some((1, false))); + // CHANGES header + assert_eq!(engine.sc_visual_row_to_flat(5, false), Some((2, true))); + // unstaged file b.rs + assert_eq!(engine.sc_visual_row_to_flat(6, false), Some((3, false))); + // LOG header (WORKTREES hidden, log section is always present) + assert_eq!(engine.sc_visual_row_to_flat(7, false), Some((4, true))); + // row 8: log expanded but sc_log is empty in test, no GTK hint → None + assert_eq!(engine.sc_visual_row_to_flat(8, false), None); } +/// TUI: STAGED is expanded but empty; CHANGES has 1 file. +/// TUI adds a "(no changes)" visual row for the empty STAGED section. +/// sc_worktrees is empty so WORKTREES section is hidden. +/// sc_sections_expanded[3]=false so LOG is collapsed (header only). +/// Visual layout (row 0=header, 1=commit, 2=buttons, 3+=sections): +/// row 3 → flat 0 (STAGED header) +/// row 4 → visual "(no changes)" — NO flat entry +/// row 5 → flat 1 (CHANGES header) +/// row 6 → flat 2 (b.rs – unstaged) +/// row 7 → flat 3 (LOG header — WORKTREES hidden, log collapsed) +/// row 8 → None (log collapsed, no items shown) #[test] -fn test_plugin_loads_and_command_runs() { - let dir = write_plugin_lua( - "test_cmd", - r#"vimcode.command("TestHello", function(args) vimcode.message("hi:" .. args) end)"#, - ); +fn test_sc_visual_row_to_flat_tui_empty_staged() { let mut engine = Engine::new(); - match plugin::PluginManager::new() { - Ok(mut mgr) => { - mgr.load_plugins_dir(&dir, &[]); - engine.plugin_manager = Some(mgr); - } - Err(_) => return, - } - let action = engine.execute_command("TestHello world"); - assert_eq!(action, EngineAction::None); - assert_eq!(engine.message, "hi:world"); + engine.sc_file_statuses = vec![git::FileStatus { + path: "b.rs".to_string(), + staged: None, + unstaged: Some(git::StatusKind::Modified), + }]; + engine.sc_sections_expanded = [true, true, false, false]; // staged expanded but empty; log collapsed + // Row 3: STAGED header (flat 0) + assert_eq!(engine.sc_visual_row_to_flat(3, true), Some((0, true))); + // Row 4: "(no changes)" hint — None + assert_eq!(engine.sc_visual_row_to_flat(4, true), None); + // Row 5: CHANGES header (flat 1) + assert_eq!(engine.sc_visual_row_to_flat(5, true), Some((1, true))); + // Row 6: b.rs (flat 2) + assert_eq!(engine.sc_visual_row_to_flat(6, true), Some((2, false))); + // Row 7: LOG header (flat 3) — WORKTREES hidden, log section always present + assert_eq!(engine.sc_visual_row_to_flat(7, true), Some((3, true))); + // Row 8: log collapsed → None + assert_eq!(engine.sc_visual_row_to_flat(8, true), None); } +/// s on STAGED section header calls sc_unstage_all path (no panic in test context). #[test] -fn test_plugin_on_save_fires() { - let dir = write_plugin_lua( - "test_save", - r#"vimcode.on("save", function(path) vimcode.message("saved:" .. path) end)"#, - ); - let mut engine = Engine::new(); - match plugin::PluginManager::new() { - Ok(mut mgr) => { - mgr.load_plugins_dir(&dir, &[]); - engine.plugin_manager = Some(mgr); - } - Err(_) => return, - } - let tmp_file = std::env::temp_dir().join("vimcode_save_hook_test.txt"); - std::fs::write(&tmp_file, "hello\n").unwrap(); - engine.open_file_in_tab(&tmp_file); - let _ = engine.save(); - assert!( - engine.message.starts_with("saved:"), - "expected save hook to fire, got: {}", - engine.message - ); - let _ = std::fs::remove_file(&tmp_file); +fn test_sc_stage_selected_on_staged_header_is_not_noop() { + let mut engine = make_sc_engine_with_files(); + // flat 0 = STAGED header + engine.sc_selected = 0; + let before_count = engine + .sc_file_statuses + .iter() + .filter(|f| f.staged.is_some()) + .count(); + // sc_stage_selected should detect idx==MAX and call sc_unstage_all + // (which will fail silently since we're not in a real git repo) + engine.sc_stage_selected(); + // The function should not panic and should call sc_refresh (resets to empty). + let _ = before_count; // no panics = pass } +/// s on CHANGES section header calls sc_stage_all path (no panic in test context). #[test] -fn test_plugin_disabled_not_registered() { - let dir = write_plugin_lua( - "test_disabled", - r#"vimcode.command("DisabledCmd", function() vimcode.message("should not run") end)"#, - ); - let mut engine = Engine::new(); - match plugin::PluginManager::new() { - Ok(mut mgr) => { - mgr.load_plugins_dir(&dir, &["test_disabled".to_string()]); - engine.plugin_manager = Some(mgr); - } - Err(_) => return, - } - let action = engine.execute_command("DisabledCmd"); - assert_eq!(action, EngineAction::Error); - assert!(engine.message.contains("Not an editor command")); +fn test_sc_stage_selected_on_changes_header_is_not_noop() { + let mut engine = make_sc_engine_with_files(); + // flat 2 = CHANGES header (1 staged file shifts CHANGES header to flat 2) + engine.sc_selected = 2; + engine.sc_stage_selected(); // should not panic } -// ─── Source Control (Session 99) ───────────────────────────────────────── +// ── Multi-cursor tests ──────────────────────────────────────────────────── -/// Build an engine with synthetic SC file statuses for testing. -fn make_sc_engine_with_files() -> Engine { +fn engine_with_text(text: &str) -> Engine { let mut engine = Engine::new(); - engine.sc_file_statuses = vec![ - git::FileStatus { - path: "a.rs".to_string(), - staged: Some(git::StatusKind::Modified), - unstaged: None, - }, - git::FileStatus { - path: "b.rs".to_string(), - staged: None, - unstaged: Some(git::StatusKind::Modified), - }, - ]; - engine.sc_sections_expanded = [true, true, false, true]; + engine.buffer_mut().insert(0, text); engine } #[test] -fn test_sc_commit_input_mode_toggle() { - let mut engine = make_sc_engine_with_files(); - assert!(!engine.sc_commit_input_active); - engine.handle_sc_key("c", false, None); - assert!(engine.sc_commit_input_active); - engine.handle_sc_key("Escape", false, None); - assert!(!engine.sc_commit_input_active); -} - -#[test] -fn test_sc_commit_input_typing() { - let mut engine = make_sc_engine_with_files(); - engine.sc_commit_input_active = true; - engine.handle_sc_key("", false, Some('h')); - engine.handle_sc_key("", false, Some('i')); - assert_eq!(engine.sc_commit_message, "hi"); +fn test_alt_d_adds_extra_cursor() { + // "foo bar foo" — cursor on first "foo", Alt-D should add cursor at second "foo" + let mut engine = engine_with_text("foo bar foo\n"); + // Cursor is at line 0, col 0 (on "foo") + assert_eq!(engine.view().cursor, Cursor { line: 0, col: 0 }); + engine.add_cursor_at_next_match(); + assert_eq!(engine.view().extra_cursors.len(), 1); + assert_eq!(engine.view().extra_cursors[0], Cursor { line: 0, col: 8 }); } #[test] -fn test_sc_commit_input_backspace() { - let mut engine = make_sc_engine_with_files(); - engine.sc_commit_input_active = true; - engine.sc_commit_message = "abc".to_string(); - engine.sc_commit_cursor = 3; - engine.handle_sc_key("BackSpace", false, None); - assert_eq!(engine.sc_commit_message, "ab"); +fn test_alt_d_multiple_presses() { + // "foo foo foo" — two Alt-D presses add two extra cursors in order + let mut engine = engine_with_text("foo foo foo\n"); + engine.add_cursor_at_next_match(); + assert_eq!(engine.view().extra_cursors.len(), 1); + assert_eq!(engine.view().extra_cursors[0].col, 4); + engine.add_cursor_at_next_match(); + assert_eq!(engine.view().extra_cursors.len(), 2); + assert_eq!(engine.view().extra_cursors[1].col, 8); } #[test] -fn test_sc_commit_empty_message_error() { - let mut engine = make_sc_engine_with_files(); - engine.sc_commit_input_active = true; - engine.sc_commit_message = "".to_string(); - // simulate Enter with empty message - engine.sc_do_commit(); - assert!(engine.message.contains("empty")); - // Input mode stays active implicitly (message set, but no state change needed) +fn test_alt_d_no_more_matches() { + // Only one "foo" — Alt-D should show a message and not add a cursor + let mut engine = engine_with_text("foo bar baz\n"); + engine.add_cursor_at_next_match(); + assert!(engine.view().extra_cursors.is_empty()); + assert!(engine.message.contains("No more occurrences")); } #[test] -fn test_sc_nav_j_k() { - let mut engine = make_sc_engine_with_files(); - engine.sc_has_focus = true; - assert_eq!(engine.sc_selected, 0); - engine.handle_sc_key("j", false, None); - assert_eq!(engine.sc_selected, 1); - engine.handle_sc_key("k", false, None); - assert_eq!(engine.sc_selected, 0); +fn test_multi_cursor_insert_char() { + // "foo bar foo" — add extra cursor at second "foo", then type 'x' in insert mode + let mut engine = engine_with_text("foo bar foo\n"); + engine.add_cursor_at_next_match(); + assert_eq!(engine.view().extra_cursors.len(), 1); + + // Enter insert mode and type 'x' + engine.handle_key("i", Some('i'), false); + assert_eq!(engine.mode, super::Mode::Insert); + engine.handle_key("x", Some('x'), false); + + let buf = engine.buffer().to_string(); + // Both "foo" occurrences should have 'x' prepended: "xfoo bar xfoo\n" + assert_eq!(buf, "xfoo bar xfoo\n"); } #[test] -fn test_sc_nav_clamps_at_bottom() { - let mut engine = make_sc_engine_with_files(); - engine.sc_has_focus = true; - let len = engine.sc_flat_len(); - for _ in 0..len + 5 { - engine.handle_sc_key("j", false, None); - } - assert_eq!(engine.sc_selected, len - 1); +fn test_multi_cursor_backspace() { + // "foo bar foo" — primary at col 1, extra at col 9 + // BackSpace should remove char before each cursor + let mut engine = engine_with_text("foo bar foo\n"); + // Move primary cursor to col 1 + engine.view_mut().cursor.col = 1; + // Extra cursor at second "foo", col 9 + engine.view_mut().extra_cursors = vec![Cursor { line: 0, col: 9 }]; + + engine.handle_key("i", Some('i'), false); + engine.handle_key("BackSpace", None, false); + engine.handle_key("Escape", None, false); + + let buf = engine.buffer().to_string(); + // Primary deletes char before col 1 (the 'f'), extra deletes char before col 9 (the 'f' of second foo) + assert_eq!(buf, "oo bar oo\n"); } #[test] -fn test_sc_nav_clamps_at_top() { - let mut engine = make_sc_engine_with_files(); - engine.sc_has_focus = true; - engine.handle_sc_key("k", false, None); - assert_eq!(engine.sc_selected, 0); +fn test_multi_cursor_escape_collapses() { + // Escape from insert mode should clear extra cursors + let mut engine = engine_with_text("foo bar foo\n"); + engine.add_cursor_at_next_match(); + assert_eq!(engine.view().extra_cursors.len(), 1); + + engine.handle_key("i", Some('i'), false); + engine.handle_key("Escape", None, false); + + assert!( + engine.view().extra_cursors.is_empty(), + "extra cursors should be cleared on Escape" + ); + assert_eq!(engine.mode, super::Mode::Normal); } #[test] -fn test_sc_tab_toggles_section() { - let mut engine = make_sc_engine_with_files(); - assert!(engine.sc_sections_expanded[0]); - engine.handle_sc_key("Tab", false, None); - assert!(!engine.sc_sections_expanded[0]); - engine.handle_sc_key("Tab", false, None); - assert!(engine.sc_sections_expanded[0]); +fn test_multi_cursor_undo() { + // Type 'x' with 2 cursors, then undo — both insertions should be reverted atomically + let mut engine = engine_with_text("foo bar foo\n"); + engine.add_cursor_at_next_match(); + + engine.handle_key("i", Some('i'), false); + engine.handle_key("x", Some('x'), false); + + let buf_after = engine.buffer().to_string(); + assert_eq!(buf_after, "xfoo bar xfoo\n"); + + engine.handle_key("Escape", None, false); + engine.handle_key("u", Some('u'), false); // undo + + let buf_undone = engine.buffer().to_string(); + assert_eq!( + buf_undone, "foo bar foo\n", + "undo should revert all multi-cursor insertions" + ); } #[test] -fn test_sc_escape_unfocuses() { - let mut engine = make_sc_engine_with_files(); - engine.sc_has_focus = true; - engine.handle_sc_key("Escape", false, None); - assert!(!engine.sc_has_focus); +fn test_add_cursor_keybinding_configurable() { + // Verify that the default binding parses correctly + use crate::core::settings::parse_key_binding; + let default = crate::core::settings::PanelKeys::default(); + assert_eq!(default.add_cursor, ""); + let parsed = parse_key_binding(&default.add_cursor); + assert_eq!(parsed, Some((false, false, true, 'd'))); } +// ── select_all_word_occurrences tests ───────────────────────────────────── + #[test] -fn test_sc_q_unfocuses() { - let mut engine = make_sc_engine_with_files(); - engine.sc_has_focus = true; - engine.handle_sc_key("q", false, None); - assert!(!engine.sc_has_focus); +fn test_select_all_word_occurrences() { + // "foo bar foo foo\n" — cursor on first "foo" → 2 extra cursors + let mut engine = engine_with_text("foo bar foo foo\n"); + // cursor at (0,0) which is on "foo" + engine.select_all_word_occurrences(); + // primary stays on first foo; extra cursors at second and third + assert_eq!(engine.view().extra_cursors.len(), 2); + assert_eq!(engine.view().extra_cursors[0], Cursor { line: 0, col: 8 }); + assert_eq!(engine.view().extra_cursors[1], Cursor { line: 0, col: 12 }); + assert!(engine.message.contains("3 cursors")); + assert!(engine.message.contains("foo")); } #[test] -fn test_sc_commit_input_blocks_nav() { - let mut engine = make_sc_engine_with_files(); - engine.sc_commit_input_active = true; - let before = engine.sc_selected; - // 'j' should go to commit input handler, not navigate - engine.handle_sc_key("j", false, None); - // selected should not have changed (j has no meaning in commit input) - assert_eq!(engine.sc_selected, before); - // but the commit input should still be active - assert!(engine.sc_commit_input_active); +fn test_select_all_single_occurrence() { + // Only one "foo" — extra_cursors should be empty, primary stays + // n+1 == 1, message says "1 cursors (all occurrences of 'foo')" + let mut engine = engine_with_text("foo bar baz\n"); + engine.select_all_word_occurrences(); + assert!(engine.view().extra_cursors.is_empty()); + assert!(engine.message.contains("1 cursors")); } +// ── add_cursor_at_pos tests ─────────────────────────────────────────────── + #[test] -fn test_sc_commit_multiline_enter_inserts_newline() { - let mut engine = make_sc_engine_with_files(); - engine.sc_commit_input_active = true; - engine.handle_sc_commit_input_key("", false, Some('H')); - engine.handle_sc_commit_input_key("", false, Some('i')); - engine.handle_sc_commit_input_key("Return", false, None); - engine.handle_sc_commit_input_key("", false, Some('b')); - assert_eq!(engine.sc_commit_message, "Hi\nb"); - assert!(engine.sc_commit_input_active); +fn test_add_cursor_at_pos_basic() { + let mut engine = engine_with_text("hello world\n"); + // primary is at (0,0); add a cursor at col 6 + engine.add_cursor_at_pos(0, 6); + assert_eq!(engine.view().extra_cursors.len(), 1); + assert_eq!(engine.view().extra_cursors[0], Cursor { line: 0, col: 6 }); } #[test] -fn test_sc_commit_ctrl_enter_commits() { - let mut engine = make_sc_engine_with_files(); - engine.sc_commit_input_active = true; - engine.sc_commit_message = "test\nmultiline".to_string(); - engine.sc_commit_cursor = engine.sc_commit_message.len(); - engine.handle_sc_commit_input_key("Return", true, None); - // Commit fails (no real repo), but input mode should be deactivated. - // The commit_message should be cleared if commit succeeded or stay if it failed. - // Since there's no git repo, sc_do_commit will error but won't clear. - assert!(!engine.sc_commit_input_active || !engine.sc_commit_message.is_empty()); +fn test_add_cursor_at_pos_no_duplicate_primary() { + let mut engine = engine_with_text("hello world\n"); + // primary is at (0,0) — adding there should not create an extra cursor + engine.add_cursor_at_pos(0, 0); + assert!(engine.view().extra_cursors.is_empty()); } #[test] -fn test_ssh_passphrase_dialog_shown() { - let mut engine = make_sc_engine_with_files(); - // Simulate showing passphrase dialog - engine.sc_show_passphrase_dialog("pull"); - assert!(engine.dialog.is_some()); - let dialog = engine.dialog.as_ref().unwrap(); - assert_eq!(dialog.tag, "ssh_passphrase"); - assert!(dialog.input.is_some()); - assert!(dialog.input.as_ref().unwrap().is_password); - assert_eq!(engine.pending_git_remote_op, Some("pull".to_string())); +fn test_add_cursor_at_pos_no_duplicate_extra() { + let mut engine = engine_with_text("hello world\n"); + engine.add_cursor_at_pos(0, 6); + assert_eq!(engine.view().extra_cursors.len(), 1); + // Adding the same position again should be a no-op + engine.add_cursor_at_pos(0, 6); + assert_eq!(engine.view().extra_cursors.len(), 1); } +// ── Regression: Alt+D across blank lines + trailing spaces ─────────────── + #[test] -fn test_dialog_input_typing() { - let mut engine = make_sc_engine_with_files(); - engine.sc_show_passphrase_dialog("push"); - // Type into the dialog input - engine.handle_key("", Some('a'), false); - engine.handle_key("", Some('b'), false); - engine.handle_key("", Some('c'), false); - let input_val = engine - .dialog - .as_ref() - .unwrap() - .input - .as_ref() - .unwrap() - .value - .clone(); - assert_eq!(input_val, "abc"); - // Backspace - engine.handle_key("BackSpace", None, false); - let input_val = engine - .dialog - .as_ref() - .unwrap() - .input - .as_ref() - .unwrap() - .value - .clone(); - assert_eq!(input_val, "ab"); +fn test_add_cursor_next_match_blank_lines_trailing_space() { + // Exact text the user reported: 4 "foo" occurrences across blank lines + // and a line with trailing space. + let text = "foo\n\nfoo \n foo foo\n"; + let mut engine = engine_with_text(text); + // cursor at (0,0) + engine.add_cursor_at_next_match(); // → line 2, col 0 + assert_eq!( + engine.view().extra_cursors.len(), + 1, + "after 1st: cursors={:?}", + engine.view().extra_cursors + ); + engine.add_cursor_at_next_match(); // → line 3, col 3 + assert_eq!( + engine.view().extra_cursors.len(), + 2, + "after 2nd: cursors={:?}", + engine.view().extra_cursors + ); + engine.add_cursor_at_next_match(); // → line 3, col 7 + assert_eq!( + engine.view().extra_cursors.len(), + 3, + "after 3rd: cursors={:?}", + engine.view().extra_cursors + ); } +// ── Regression: yyp pastes on next line ────────────────────────────────── + #[test] -fn test_dialog_input_cancel() { - let mut engine = make_sc_engine_with_files(); - engine.sc_show_passphrase_dialog("fetch"); - engine.handle_key("Escape", None, false); - assert!(engine.dialog.is_none()); - assert!(engine.pending_git_remote_op.is_none()); +fn test_yyp_pastes_on_next_line() { + let text = "foo\n\nfoo \n foo foo\n"; + let mut engine = engine_with_text(text); + // cursor at line 0 + press_char(&mut engine, 'y'); + press_char(&mut engine, 'y'); + let (reg_content, is_lw) = engine.registers.get(&'"').unwrap().clone(); + assert_eq!(reg_content, "foo\n", "yanked content"); + assert!(is_lw, "should be linewise"); + press_char(&mut engine, 'p'); + // Expected: "foo\nfoo\n\nfoo \n foo foo\n" + let result = engine.buffer().to_string(); + assert!( + result.starts_with("foo\nfoo\n"), + "paste should be on next line; got: {:?}", + result + ); + assert_eq!( + engine.view().cursor.line, + 1, + "cursor should move to pasted line" + ); } #[test] -fn test_sc_commit_cursor_arrow_keys() { - let mut engine = make_sc_engine_with_files(); - engine.sc_commit_input_active = true; - // Type "abc" - engine.handle_sc_commit_input_key("", false, Some('a')); - engine.handle_sc_commit_input_key("", false, Some('b')); - engine.handle_sc_commit_input_key("", false, Some('c')); - assert_eq!(engine.sc_commit_cursor, 3); - // Left moves cursor back. - engine.handle_sc_commit_input_key("Left", false, None); - assert_eq!(engine.sc_commit_cursor, 2); - // Insert at cursor position. - engine.handle_sc_commit_input_key("", false, Some('X')); - assert_eq!(engine.sc_commit_message, "abXc"); - assert_eq!(engine.sc_commit_cursor, 3); - // Right moves cursor forward. - engine.handle_sc_commit_input_key("Right", false, None); - assert_eq!(engine.sc_commit_cursor, 4); - // Home moves to start of line. - engine.handle_sc_commit_input_key("Home", false, None); - assert_eq!(engine.sc_commit_cursor, 0); - // End moves to end of line. - engine.handle_sc_commit_input_key("End", false, None); - assert_eq!(engine.sc_commit_cursor, 4); +fn test_extra_cursors_cleared_on_normal_mode_paste() { + // Extra cursors become stale after a normal-mode buffer modification. + // Verify they are cleared automatically so the user doesn't see ghost cursors. + let mut engine = engine_with_text("foo bar foo\n"); + // Add an extra cursor at (0, 8) + engine.view_mut().extra_cursors = vec![Cursor { line: 0, col: 8 }]; + // yank the line then paste — this is a normal-mode buffer modification + press_char(&mut engine, 'y'); + press_char(&mut engine, 'y'); + press_char(&mut engine, 'p'); + // After paste, extra_cursors must be empty + assert!( + engine.view().extra_cursors.is_empty(), + "extra_cursors should be cleared after normal-mode paste" + ); } #[test] -fn test_sc_commit_cursor_up_down() { - let mut engine = make_sc_engine_with_files(); - engine.sc_commit_input_active = true; - engine.sc_commit_message = "abc\nde\nfghij".to_string(); - engine.sc_commit_cursor = 5; // on 'e' in "de" - // Down moves to next line, same column. - engine.handle_sc_commit_input_key("Down", false, None); - assert_eq!(engine.sc_commit_cursor, 8); // 'h' in "fghij" - // Up moves back. - engine.handle_sc_commit_input_key("Up", false, None); - assert_eq!(engine.sc_commit_cursor, 5); // 'e' in "de" - // Up again to first line. - engine.handle_sc_commit_input_key("Up", false, None); - assert_eq!(engine.sc_commit_cursor, 1); // 'b' in "abc" (col 1) +fn test_extra_cursors_preserved_on_insert_mode_entry() { + // Pressing 'i' (no buffer modification) must NOT clear extra cursors. + let mut engine = engine_with_text("foo bar\n"); + engine.view_mut().extra_cursors = vec![Cursor { line: 0, col: 4 }]; + engine.handle_key("i", Some('i'), false); + assert!( + !engine.view().extra_cursors.is_empty(), + "extra_cursors should be preserved when entering insert mode with 'i'" + ); } #[test] -fn test_sc_commit_cursor_backspace_at_position() { - let mut engine = make_sc_engine_with_files(); - engine.sc_commit_input_active = true; - engine.sc_commit_message = "hello".to_string(); - engine.sc_commit_cursor = 3; // after "hel" - engine.handle_sc_commit_input_key("BackSpace", false, None); - assert_eq!(engine.sc_commit_message, "helo"); - assert_eq!(engine.sc_commit_cursor, 2); +fn test_add_cursor_at_next_match_shows_count_message() { + let mut engine = engine_with_text("foo bar foo\n"); + engine.add_cursor_at_next_match(); + assert!( + engine.message.contains("2 cursors"), + "message should show cursor count; got: {:?}", + engine.message + ); } #[test] -fn test_sc_commit_cursor_delete() { - let mut engine = make_sc_engine_with_files(); - engine.sc_commit_input_active = true; - engine.sc_commit_message = "hello".to_string(); - engine.sc_commit_cursor = 2; - engine.handle_sc_commit_input_key("Delete", false, None); - assert_eq!(engine.sc_commit_message, "helo"); - assert_eq!(engine.sc_commit_cursor, 2); +fn test_load_clipboard_for_paste_preserves_linewise() { + // When clipboard content matches the existing '"' register, is_linewise is kept. + let mut engine = engine_with_text("foo\nbar\n"); + engine.registers.insert('"', ("foo\n".to_string(), true)); + engine.load_clipboard_for_paste("foo\n".to_string()); + let (_, lw) = engine.registers[&'"'].clone(); + assert!( + lw, + "load_clipboard_for_paste should preserve is_linewise when content matches" + ); } #[test] -fn test_gpull_and_gfetch_commands_exist() { - let mut engine = make_sc_engine_with_files(); - // These will fail with a git error since cwd is not a real repo, but - // they should not return EngineAction::Error ("Not an editor command"). - let r1 = engine.execute_command("Gpull"); - let r2 = engine.execute_command("Gfetch"); - assert_ne!(r1, EngineAction::Error); - assert_ne!(r2, EngineAction::Error); +fn test_load_clipboard_for_paste_clears_linewise_for_foreign_content() { + // When clipboard content differs (from another app), is_linewise becomes false. + let mut engine = engine_with_text("foo\nbar\n"); + engine.registers.insert('"', ("foo\n".to_string(), true)); + engine.load_clipboard_for_paste("different text".to_string()); + let (_, lw) = engine.registers[&'"'].clone(); + assert!( + !lw, + "load_clipboard_for_paste should clear is_linewise for external content" + ); } #[test] -fn test_sc_branch_picker_open_close() { - let mut engine = make_sc_engine_with_files(); - engine.sc_has_focus = true; - assert!(!engine.sc_branch_picker_open); - engine.handle_sc_key("b", false, None); - assert!(engine.sc_branch_picker_open); - // Escape closes it - engine.handle_sc_key("Escape", false, None); - assert!(!engine.sc_branch_picker_open); - assert!(engine.sc_branch_picker_query.is_empty()); +fn test_yyp_linewise_via_clipboard_intercept() { + // Simulate the yy → clipboard-intercept-before-p flow that the backends perform. + let mut engine = engine_with_text("foo\nbar\n"); + press_char(&mut engine, 'y'); + press_char(&mut engine, 'y'); + let (content, lw) = engine.registers[&'"'].clone(); + assert!(lw, "yy should set is_linewise=true"); + // Backend intercepts p, reads same text from clipboard, calls load_clipboard_for_paste. + engine.load_clipboard_for_paste(content); + press_char(&mut engine, 'p'); + // Buffer should be "foo\nfoo\nbar\n" — pasted on the line below, not inline. + assert_eq!( + engine.buffer().to_string(), + "foo\nfoo\nbar\n", + "linewise paste via clipboard intercept should insert on next line" + ); + assert_eq!(engine.view().cursor.line, 1, "cursor on pasted line"); } +// ── Editor group tests ──────────────────────────────────────────────────── + #[test] -fn test_sc_branch_picker_typing_filters() { - let mut engine = make_sc_engine_with_files(); - engine.sc_has_focus = true; - engine.handle_sc_key("b", false, None); - assert!(engine.sc_branch_picker_open); - // Type a query character - engine.handle_sc_key("", false, Some('m')); - assert_eq!(engine.sc_branch_picker_query, "m"); - engine.handle_sc_key("", false, Some('a')); - assert_eq!(engine.sc_branch_picker_query, "ma"); - // Backspace removes - engine.handle_sc_key("BackSpace", false, None); - assert_eq!(engine.sc_branch_picker_query, "m"); +fn test_editor_group_split_commands() { + let mut engine = Engine::new(); + assert_eq!(engine.group_layout.leaf_count(), 1); + + // :EditorGroupSplit should create a second group. + let r = engine.execute_command("EditorGroupSplit"); + assert_ne!( + r, + EngineAction::Error, + "EditorGroupSplit should not return error" + ); + assert_eq!( + engine.group_layout.leaf_count(), + 2, + "EditorGroupSplit should create a second group" + ); + + // A third split should now work (no cap at 2). + let r2 = engine.execute_command("egsp"); + assert_ne!(r2, EngineAction::Error, "egsp should not return error"); + assert_eq!( + engine.group_layout.leaf_count(), + 3, + "egsp should create third group" + ); + + // Close back to 2, then close to 1. + engine.execute_command("egc"); + assert_eq!(engine.group_layout.leaf_count(), 2); + engine.execute_command("egc"); + assert_eq!(engine.group_layout.leaf_count(), 1); + + let r3 = engine.execute_command("EditorGroupSplitDown"); + assert_ne!( + r3, + EngineAction::Error, + "EditorGroupSplitDown should not return error" + ); + assert_eq!(engine.group_layout.leaf_count(), 2); + + // :egspd creates a third. + let r4 = engine.execute_command("egspd"); + assert_ne!(r4, EngineAction::Error, "egspd should not return error"); + assert_eq!(engine.group_layout.leaf_count(), 3); } #[test] -fn test_sc_branch_create_mode() { - let mut engine = make_sc_engine_with_files(); - engine.sc_has_focus = true; - engine.handle_sc_key("B", false, None); - assert!(engine.sc_branch_create_mode); - // Type branch name - engine.handle_sc_key("", false, Some('f')); - engine.handle_sc_key("", false, Some('o')); - engine.handle_sc_key("", false, Some('o')); - assert_eq!(engine.sc_branch_create_input, "foo"); - // Escape cancels - engine.handle_sc_key("Escape", false, None); - assert!(!engine.sc_branch_create_mode); - assert!(engine.sc_branch_create_input.is_empty()); +fn test_recursive_split_three_groups() { + let mut engine = Engine::new(); + // Split right + engine.open_editor_group(SplitDirection::Vertical); + assert_eq!(engine.group_layout.leaf_count(), 2); + // Focus group 0 and split down + let first_id = engine.group_layout.group_ids()[0]; + engine.active_group = first_id; + engine.open_editor_group(SplitDirection::Horizontal); + assert_eq!(engine.group_layout.leaf_count(), 3); + // Verify all 3 groups exist in the HashMap + assert_eq!(engine.editor_groups.len(), 3); } #[test] -fn test_sc_help_toggle() { - let mut engine = make_sc_engine_with_files(); - engine.sc_has_focus = true; - assert!(!engine.sc_help_open); - engine.handle_sc_key("?", false, None); - assert!(engine.sc_help_open); - // Any key closes - engine.handle_sc_key("j", false, None); - assert!(!engine.sc_help_open); +fn test_focus_cycling_three_groups() { + let mut engine = Engine::new(); + engine.open_editor_group(SplitDirection::Vertical); + let first_id = engine.group_layout.group_ids()[0]; + engine.active_group = first_id; + engine.open_editor_group(SplitDirection::Horizontal); + let ids = engine.group_layout.group_ids(); + assert_eq!(ids.len(), 3); + // Start at the newly split group (last created) + let start = engine.active_group; + engine.focus_other_group(); + let second = engine.active_group; + assert_ne!(start, second); + engine.focus_other_group(); + let third = engine.active_group; + assert_ne!(second, third); + engine.focus_other_group(); + // Should wrap back + assert_eq!(engine.active_group, start); } #[test] -fn test_sc_help_escape_closes() { - let mut engine = make_sc_engine_with_files(); - engine.sc_has_focus = true; - engine.handle_sc_key("?", false, None); - assert!(engine.sc_help_open); - engine.handle_sc_key("Escape", false, None); - assert!(!engine.sc_help_open); +fn test_close_nested_group() { + let mut engine = Engine::new(); + engine.open_editor_group(SplitDirection::Vertical); + let first_id = engine.group_layout.group_ids()[0]; + engine.active_group = first_id; + engine.open_editor_group(SplitDirection::Horizontal); + assert_eq!(engine.group_layout.leaf_count(), 3); + // Close the active group + engine.close_editor_group(); + assert_eq!(engine.group_layout.leaf_count(), 2); + // Close again + engine.close_editor_group(); + assert_eq!(engine.group_layout.leaf_count(), 1); + assert!(engine.group_layout.is_single_group()); } -// ─── sc_visual_row_to_flat tests (click-math correctness) ───────────────── - -/// make_sc_engine_with_files gives: 1 staged file + 1 unstaged file, -/// sections_expanded = [true, true, false, true], sc_worktrees = [] (no linked worktrees). -/// GTK (no hint) flat layout (row 0=header, 1=commit, 2=buttons, 3+=sections): -/// row 3 → flat 0 (STAGED header) -/// row 4 → flat 1 (a.rs – staged) -/// row 5 → flat 2 (CHANGES header) -/// row 6 → flat 3 (b.rs – unstaged) -/// row 7 → flat 4 (LOG header — WORKTREES hidden, sc_worktrees.len() == 0) -/// row 8 → None (log expanded but empty, no hint in GTK mode) #[test] -fn test_sc_visual_row_to_flat_gtk() { - let engine = make_sc_engine_with_files(); // staged=1, unstaged=1, worktrees=0 - // Rows 0–2 are header / commit input / button row — should return None. - assert_eq!(engine.sc_visual_row_to_flat(0, false), None); - assert_eq!(engine.sc_visual_row_to_flat(1, false), None); - assert_eq!(engine.sc_visual_row_to_flat(2, false), None); - // STAGED header - assert_eq!(engine.sc_visual_row_to_flat(3, false), Some((0, true))); - // staged file a.rs - assert_eq!(engine.sc_visual_row_to_flat(4, false), Some((1, false))); - // CHANGES header - assert_eq!(engine.sc_visual_row_to_flat(5, false), Some((2, true))); - // unstaged file b.rs - assert_eq!(engine.sc_visual_row_to_flat(6, false), Some((3, false))); - // LOG header (WORKTREES hidden, log section is always present) - assert_eq!(engine.sc_visual_row_to_flat(7, false), Some((4, true))); - // row 8: log expanded but sc_log is empty in test, no GTK hint → None - assert_eq!(engine.sc_visual_row_to_flat(8, false), None); +fn test_four_groups_nested_splits() { + // Reproduce: split right, focus left, split down, split down again → 4 groups + let mut engine = Engine::new(); + engine.open_editor_group(SplitDirection::Vertical); + assert_eq!(engine.group_layout.leaf_count(), 2); + let first_id = engine.group_layout.group_ids()[0]; + engine.active_group = first_id; + engine.open_editor_group(SplitDirection::Horizontal); + assert_eq!(engine.group_layout.leaf_count(), 3); + // Second split down on the newly created group + engine.open_editor_group(SplitDirection::Horizontal); + assert_eq!(engine.group_layout.leaf_count(), 4); + + // Simulate TUI rendering: content_bounds includes tab bar row (+1) + let content_bounds = WindowRect::new(0.0, 0.0, 80.0, 25.0); + let (rects, dividers) = engine.calculate_group_window_rects(content_bounds, 1.0); + assert_eq!(rects.len(), 4, "should have 4 window rects"); + + // All rects should have positive width and non-negative height + for (wid, r) in &rects { + assert!(r.width > 0.0, "window {:?} has zero width", wid); + assert!(r.height >= 0.0, "window {:?} has negative height", wid); + assert!(r.y >= 0.0, "window {:?} has negative y", wid); + assert!( + r.y + r.height <= content_bounds.height, + "window {:?} extends beyond content bounds: y={} h={} max={}", + wid, + r.y, + r.height, + content_bounds.height + ); + } + assert!(!dividers.is_empty(), "should have dividers"); } -/// TUI: STAGED is expanded but empty; CHANGES has 1 file. -/// TUI adds a "(no changes)" visual row for the empty STAGED section. -/// sc_worktrees is empty so WORKTREES section is hidden. -/// sc_sections_expanded[3]=false so LOG is collapsed (header only). -/// Visual layout (row 0=header, 1=commit, 2=buttons, 3+=sections): -/// row 3 → flat 0 (STAGED header) -/// row 4 → visual "(no changes)" — NO flat entry -/// row 5 → flat 1 (CHANGES header) -/// row 6 → flat 2 (b.rs – unstaged) -/// row 7 → flat 3 (LOG header — WORKTREES hidden, log collapsed) -/// row 8 → None (log collapsed, no items shown) #[test] -fn test_sc_visual_row_to_flat_tui_empty_staged() { +fn test_group_resize_nested() { let mut engine = Engine::new(); - engine.sc_file_statuses = vec![git::FileStatus { - path: "b.rs".to_string(), - staged: None, - unstaged: Some(git::StatusKind::Modified), - }]; - engine.sc_sections_expanded = [true, true, false, false]; // staged expanded but empty; log collapsed - // Row 3: STAGED header (flat 0) - assert_eq!(engine.sc_visual_row_to_flat(3, true), Some((0, true))); - // Row 4: "(no changes)" hint — None - assert_eq!(engine.sc_visual_row_to_flat(4, true), None); - // Row 5: CHANGES header (flat 1) - assert_eq!(engine.sc_visual_row_to_flat(5, true), Some((1, true))); - // Row 6: b.rs (flat 2) - assert_eq!(engine.sc_visual_row_to_flat(6, true), Some((2, false))); - // Row 7: LOG header (flat 3) — WORKTREES hidden, log section always present - assert_eq!(engine.sc_visual_row_to_flat(7, true), Some((3, true))); - // Row 8: log collapsed → None - assert_eq!(engine.sc_visual_row_to_flat(8, true), None); + engine.open_editor_group(SplitDirection::Vertical); + let first_id = engine.group_layout.group_ids()[0]; + engine.active_group = first_id; + // Resize: should adjust the parent split ratio + engine.group_resize(0.1); + // Verify it changed (default was 0.5, now should be 0.6) + if let GroupLayout::Split { ratio, .. } = &engine.group_layout { + assert!((*ratio - 0.6).abs() < 0.01); + } else { + panic!("Expected split layout"); + } } -/// s on STAGED section header calls sc_unstage_all path (no panic in test context). #[test] -fn test_sc_stage_selected_on_staged_header_is_not_noop() { - let mut engine = make_sc_engine_with_files(); - // flat 0 = STAGED header - engine.sc_selected = 0; - let before_count = engine - .sc_file_statuses - .iter() - .filter(|f| f.staged.is_some()) - .count(); - // sc_stage_selected should detect idx==MAX and call sc_unstage_all - // (which will fail silently since we're not in a real git repo) - engine.sc_stage_selected(); - // The function should not panic and should call sc_refresh (resets to empty). - let _ = before_count; // no panics = pass +fn test_command_cursor_initial_position() { + // Entering command mode should set cursor to 0 (after the ':') + let mut engine = Engine::new(); + engine.handle_key(":", Some(':'), false); + assert_eq!(engine.command_cursor, 0); + assert_eq!(engine.command_buffer, ""); } -/// s on CHANGES section header calls sc_stage_all path (no panic in test context). #[test] -fn test_sc_stage_selected_on_changes_header_is_not_noop() { - let mut engine = make_sc_engine_with_files(); - // flat 2 = CHANGES header (1 staged file shifts CHANGES header to flat 2) - engine.sc_selected = 2; - engine.sc_stage_selected(); // should not panic +fn test_command_cursor_advances_on_type() { + let mut engine = Engine::new(); + engine.handle_key(":", Some(':'), false); + engine.handle_key("", Some('h'), false); + engine.handle_key("", Some('i'), false); + assert_eq!(engine.command_buffer, "hi"); + assert_eq!(engine.command_cursor, 2); } -// ── Multi-cursor tests ──────────────────────────────────────────────────── - -fn engine_with_text(text: &str) -> Engine { +#[test] +fn test_command_cursor_left_right() { let mut engine = Engine::new(); - engine.buffer_mut().insert(0, text); - engine + engine.handle_key(":", Some(':'), false); + engine.handle_key("", Some('a'), false); + engine.handle_key("", Some('b'), false); + engine.handle_key("", Some('c'), false); + assert_eq!(engine.command_cursor, 3); + + engine.handle_key("Left", None, false); + assert_eq!(engine.command_cursor, 2); + engine.handle_key("Left", None, false); + assert_eq!(engine.command_cursor, 1); + // Insert at position 1 + engine.handle_key("", Some('X'), false); + assert_eq!(engine.command_buffer, "aXbc"); + assert_eq!(engine.command_cursor, 2); + + engine.handle_key("Right", None, false); + assert_eq!(engine.command_cursor, 3); } #[test] -fn test_alt_d_adds_extra_cursor() { - // "foo bar foo" — cursor on first "foo", Alt-D should add cursor at second "foo" - let mut engine = engine_with_text("foo bar foo\n"); - // Cursor is at line 0, col 0 (on "foo") - assert_eq!(engine.view().cursor, Cursor { line: 0, col: 0 }); - engine.add_cursor_at_next_match(); - assert_eq!(engine.view().extra_cursors.len(), 1); - assert_eq!(engine.view().extra_cursors[0], Cursor { line: 0, col: 8 }); +fn test_command_cursor_home_end() { + let mut engine = Engine::new(); + engine.handle_key(":", Some(':'), false); + for ch in "hello".chars() { + engine.handle_key("", Some(ch), false); + } + engine.handle_key("Home", None, false); + assert_eq!(engine.command_cursor, 0); + engine.handle_key("End", None, false); + assert_eq!(engine.command_cursor, 5); } #[test] -fn test_alt_d_multiple_presses() { - // "foo foo foo" — two Alt-D presses add two extra cursors in order - let mut engine = engine_with_text("foo foo foo\n"); - engine.add_cursor_at_next_match(); - assert_eq!(engine.view().extra_cursors.len(), 1); - assert_eq!(engine.view().extra_cursors[0].col, 4); - engine.add_cursor_at_next_match(); - assert_eq!(engine.view().extra_cursors.len(), 2); - assert_eq!(engine.view().extra_cursors[1].col, 8); +fn test_command_cursor_delete_key() { + let mut engine = Engine::new(); + engine.handle_key(":", Some(':'), false); + for ch in "abc".chars() { + engine.handle_key("", Some(ch), false); + } + // Move to start, delete first char + engine.handle_key("Home", None, false); + engine.handle_key("Delete", None, false); + assert_eq!(engine.command_buffer, "bc"); + assert_eq!(engine.command_cursor, 0); } #[test] -fn test_alt_d_no_more_matches() { - // Only one "foo" — Alt-D should show a message and not add a cursor - let mut engine = engine_with_text("foo bar baz\n"); - engine.add_cursor_at_next_match(); - assert!(engine.view().extra_cursors.is_empty()); - assert!(engine.message.contains("No more occurrences")); +fn test_command_cursor_backspace_at_cursor() { + let mut engine = Engine::new(); + engine.handle_key(":", Some(':'), false); + for ch in "abc".chars() { + engine.handle_key("", Some(ch), false); + } + engine.handle_key("Left", None, false); // cursor at 2 + engine.handle_key("BackSpace", None, false); // deletes 'b' + assert_eq!(engine.command_buffer, "ac"); + assert_eq!(engine.command_cursor, 1); } #[test] -fn test_multi_cursor_insert_char() { - // "foo bar foo" — add extra cursor at second "foo", then type 'x' in insert mode - let mut engine = engine_with_text("foo bar foo\n"); - engine.add_cursor_at_next_match(); - assert_eq!(engine.view().extra_cursors.len(), 1); - - // Enter insert mode and type 'x' - engine.handle_key("i", Some('i'), false); - assert_eq!(engine.mode, super::Mode::Insert); - engine.handle_key("x", Some('x'), false); - - let buf = engine.buffer().to_string(); - // Both "foo" occurrences should have 'x' prepended: "xfoo bar xfoo\n" - assert_eq!(buf, "xfoo bar xfoo\n"); +fn test_command_insert_str_at_cursor() { + let mut engine = Engine::new(); + engine.handle_key(":", Some(':'), false); + for ch in "ac".chars() { + engine.handle_key("", Some(ch), false); + } + engine.handle_key("Left", None, false); // cursor between 'a' and 'c' + engine.command_insert_str("b"); + assert_eq!(engine.command_buffer, "abc"); + assert_eq!(engine.command_cursor, 2); } #[test] -fn test_multi_cursor_backspace() { - // "foo bar foo" — primary at col 1, extra at col 9 - // BackSpace should remove char before each cursor - let mut engine = engine_with_text("foo bar foo\n"); - // Move primary cursor to col 1 - engine.view_mut().cursor.col = 1; - // Extra cursor at second "foo", col 9 - engine.view_mut().extra_cursors = vec![Cursor { line: 0, col: 9 }]; - - engine.handle_key("i", Some('i'), false); - engine.handle_key("BackSpace", None, false); - engine.handle_key("Escape", None, false); - - let buf = engine.buffer().to_string(); - // Primary deletes char before col 1 (the 'f'), extra deletes char before col 9 (the 'f' of second foo) - assert_eq!(buf, "oo bar oo\n"); +fn test_command_ctrl_a_e_k() { + let mut engine = Engine::new(); + engine.handle_key(":", Some(':'), false); + for ch in "hello".chars() { + engine.handle_key("", Some(ch), false); + } + // Ctrl-A goes to start + engine.handle_key("a", Some('a'), true); + assert_eq!(engine.command_cursor, 0); + // Ctrl-E goes to end + engine.handle_key("e", Some('e'), true); + assert_eq!(engine.command_cursor, 5); + // Move to middle, Ctrl-K kills to end + engine.handle_key("Left", None, false); + engine.handle_key("Left", None, false); + engine.handle_key("k", Some('k'), true); + assert_eq!(engine.command_buffer, "hel"); + assert_eq!(engine.command_cursor, 3); } -#[test] -fn test_multi_cursor_escape_collapses() { - // Escape from insert mode should clear extra cursors - let mut engine = engine_with_text("foo bar foo\n"); - engine.add_cursor_at_next_match(); - assert_eq!(engine.view().extra_cursors.len(), 1); - - engine.handle_key("i", Some('i'), false); - engine.handle_key("Escape", None, false); +// ─── Visual mode Ctrl-D / Ctrl-U tests ───────────────────────────── - assert!( - engine.view().extra_cursors.is_empty(), - "extra cursors should be cleared on Escape" - ); - assert_eq!(engine.mode, super::Mode::Normal); +#[test] +fn test_visual_ctrl_d_extends_selection_down() { + let mut engine = Engine::new(); + // 20 lines so half-page is meaningful + let text = (0..20) + .map(|i| format!("line {i}")) + .collect::>() + .join("\n"); + engine.buffer_mut().insert(0, &text); + engine.view_mut().viewport_lines = 10; + // Enter visual mode on line 0 + press_char(&mut engine, 'v'); + assert!(matches!(engine.mode, Mode::Visual)); + // Ctrl-D should move cursor down by half page (5 lines), extending selection + press_ctrl(&mut engine, 'd'); + assert!(matches!(engine.mode, Mode::Visual)); + assert_eq!(engine.view().cursor.line, 5); + // Buffer should be unchanged (not deleted) + assert_eq!(engine.buffer().len_lines(), 20); } #[test] -fn test_multi_cursor_undo() { - // Type 'x' with 2 cursors, then undo — both insertions should be reverted atomically - let mut engine = engine_with_text("foo bar foo\n"); - engine.add_cursor_at_next_match(); - - engine.handle_key("i", Some('i'), false); - engine.handle_key("x", Some('x'), false); - - let buf_after = engine.buffer().to_string(); - assert_eq!(buf_after, "xfoo bar xfoo\n"); - - engine.handle_key("Escape", None, false); - engine.handle_key("u", Some('u'), false); // undo - - let buf_undone = engine.buffer().to_string(); - assert_eq!( - buf_undone, "foo bar foo\n", - "undo should revert all multi-cursor insertions" - ); +fn test_visual_ctrl_u_extends_selection_up() { + let mut engine = Engine::new(); + let text = (0..20) + .map(|i| format!("line {i}")) + .collect::>() + .join("\n"); + engine.buffer_mut().insert(0, &text); + engine.view_mut().viewport_lines = 10; + // Move to line 10 + engine.view_mut().cursor.line = 10; + // Enter visual mode + press_char(&mut engine, 'v'); + assert!(matches!(engine.mode, Mode::Visual)); + // Ctrl-U should move cursor up by half page (5 lines), extending selection + press_ctrl(&mut engine, 'u'); + assert!(matches!(engine.mode, Mode::Visual)); + assert_eq!(engine.view().cursor.line, 5); + // Buffer should be unchanged (case not toggled) + let content = engine.buffer().to_string(); + assert!(content.starts_with("line 0")); } +// ─── Dialog system tests ───────────────────────────────────────── + #[test] -fn test_add_cursor_keybinding_configurable() { - // Verify that the default binding parses correctly - use crate::core::settings::parse_key_binding; - let default = crate::core::settings::PanelKeys::default(); - assert_eq!(default.add_cursor, ""); - let parsed = parse_key_binding(&default.add_cursor); - assert_eq!(parsed, Some((false, false, true, 'd'))); +fn test_dialog_show_and_escape() { + let mut e = Engine::new(); + e.buffer_mut().insert(0, "hello"); + e.show_dialog( + "test", + "Test Dialog", + vec!["Body line".into()], + vec![DialogButton { + label: "OK".into(), + hotkey: 'o', + action: "ok".into(), + }], + ); + assert!(e.dialog.is_some()); + // Escape dismisses. + e.handle_key("Escape", None, false); + assert!(e.dialog.is_none()); } -// ── select_all_word_occurrences tests ───────────────────────────────────── - #[test] -fn test_select_all_word_occurrences() { - // "foo bar foo foo\n" — cursor on first "foo" → 2 extra cursors - let mut engine = engine_with_text("foo bar foo foo\n"); - // cursor at (0,0) which is on "foo" - engine.select_all_word_occurrences(); - // primary stays on first foo; extra cursors at second and third - assert_eq!(engine.view().extra_cursors.len(), 2); - assert_eq!(engine.view().extra_cursors[0], Cursor { line: 0, col: 8 }); - assert_eq!(engine.view().extra_cursors[1], Cursor { line: 0, col: 12 }); - assert!(engine.message.contains("3 cursors")); - assert!(engine.message.contains("foo")); +fn test_dialog_hotkey() { + let mut e = Engine::new(); + e.show_dialog( + "test", + "Choose", + vec!["Pick one".into()], + vec![ + DialogButton { + label: "Recover".into(), + hotkey: 'r', + action: "recover".into(), + }, + DialogButton { + label: "Delete".into(), + hotkey: 'd', + action: "delete".into(), + }, + ], + ); + // Press 'r' → hotkey should dismiss. + e.handle_key("", Some('r'), false); + assert!(e.dialog.is_none()); } #[test] -fn test_select_all_single_occurrence() { - // Only one "foo" — extra_cursors should be empty, primary stays - // n+1 == 1, message says "1 cursors (all occurrences of 'foo')" - let mut engine = engine_with_text("foo bar baz\n"); - engine.select_all_word_occurrences(); - assert!(engine.view().extra_cursors.is_empty()); - assert!(engine.message.contains("1 cursors")); +fn test_dialog_arrow_nav_and_enter() { + let mut e = Engine::new(); + e.show_dialog( + "test", + "Choose", + vec![], + vec![ + DialogButton { + label: "A".into(), + hotkey: 'a', + action: "a_action".into(), + }, + DialogButton { + label: "B".into(), + hotkey: 'b', + action: "b_action".into(), + }, + DialogButton { + label: "C".into(), + hotkey: 'c', + action: "c_action".into(), + }, + ], + ); + assert_eq!(e.dialog.as_ref().unwrap().selected, 0); + // Move right. + e.handle_key("Right", None, false); + assert_eq!(e.dialog.as_ref().unwrap().selected, 1); + // Move right again. + e.handle_key("Right", None, false); + assert_eq!(e.dialog.as_ref().unwrap().selected, 2); + // Right at end → wraps to 0. + e.handle_key("Right", None, false); + assert_eq!(e.dialog.as_ref().unwrap().selected, 0); + // Move right to 1. + e.handle_key("Right", None, false); + assert_eq!(e.dialog.as_ref().unwrap().selected, 1); + // Enter confirms the selected button. + e.handle_key("Return", None, false); + assert!(e.dialog.is_none()); } -// ── add_cursor_at_pos tests ─────────────────────────────────────────────── - #[test] -fn test_add_cursor_at_pos_basic() { - let mut engine = engine_with_text("hello world\n"); - // primary is at (0,0); add a cursor at col 6 - engine.add_cursor_at_pos(0, 6); - assert_eq!(engine.view().extra_cursors.len(), 1); - assert_eq!(engine.view().extra_cursors[0], Cursor { line: 0, col: 6 }); +fn test_dialog_blocks_normal_keys() { + let mut e = Engine::new(); + e.buffer_mut().insert(0, "hello"); + e.show_dialog( + "test", + "Block", + vec![], + vec![DialogButton { + label: "OK".into(), + hotkey: 'o', + action: "ok".into(), + }], + ); + // Press 'x' which would normally delete a char — dialog should consume it. + e.handle_key("", Some('x'), false); + assert!(e.dialog.is_some()); // Dialog still open. + assert_eq!(e.buffer().to_string(), "hello"); // Buffer unchanged. } #[test] -fn test_add_cursor_at_pos_no_duplicate_primary() { - let mut engine = engine_with_text("hello world\n"); - // primary is at (0,0) — adding there should not create an extra cursor - engine.add_cursor_at_pos(0, 0); - assert!(engine.view().extra_cursors.is_empty()); +fn test_show_error_dialog() { + let mut e = Engine::new(); + e.show_error_dialog("Error", "Something went wrong"); + let dialog = e.dialog.as_ref().unwrap(); + assert_eq!(dialog.tag, "error"); + assert_eq!(dialog.title, "Error"); + assert_eq!(dialog.body, vec!["Something went wrong"]); + assert_eq!(dialog.buttons.len(), 1); + assert_eq!(dialog.buttons[0].hotkey, 'o'); + assert_eq!(dialog.buttons[0].action, "ok"); } -#[test] -fn test_add_cursor_at_pos_no_duplicate_extra() { - let mut engine = engine_with_text("hello world\n"); - engine.add_cursor_at_pos(0, 6); - assert_eq!(engine.view().extra_cursors.len(), 1); - // Adding the same position again should be a no-op - engine.add_cursor_at_pos(0, 6); - assert_eq!(engine.view().extra_cursors.len(), 1); -} +// ─── Extension removal dialog ──────────────────────────────────────── -// ── Regression: Alt+D across blank lines + trailing spaces ─────────────── +/// Create a minimal mock bash extension manifest for tests that don't +/// depend on local disk state (CI has no extensions installed on disk). +fn mock_bash_manifest() -> extensions::ExtensionManifest { + extensions::ExtensionManifest { + name: "bash".to_string(), + display_name: "Bash".to_string(), + description: "Bash language support".to_string(), + version: "1.0.0".to_string(), + ..Default::default() + } +} #[test] -fn test_add_cursor_next_match_blank_lines_trailing_space() { - // Exact text the user reported: 4 "foo" occurrences across blank lines - // and a line with trailing space. - let text = "foo\n\nfoo \n foo foo\n"; - let mut engine = engine_with_text(text); - // cursor at (0,0) - engine.add_cursor_at_next_match(); // → line 2, col 0 - assert_eq!( - engine.view().extra_cursors.len(), - 1, - "after 1st: cursors={:?}", - engine.view().extra_cursors - ); - engine.add_cursor_at_next_match(); // → line 3, col 3 - assert_eq!( - engine.view().extra_cursors.len(), - 2, - "after 2nd: cursors={:?}", - engine.view().extra_cursors - ); - engine.add_cursor_at_next_match(); // → line 3, col 7 - assert_eq!( - engine.view().extra_cursors.len(), - 3, - "after 3rd: cursors={:?}", - engine.view().extra_cursors - ); +fn test_ext_remove_dialog_shows_on_d() { + let mut e = Engine::new(); + e.ext_registry = Some(vec![mock_bash_manifest()]); + // Install a single extension so it's the only item at index 0. + e.extension_state.mark_installed_version("bash", "1.0.0"); + e.ext_sidebar_sections_expanded = [true, true]; + e.ext_sidebar_selected = 0; // First installed item. + e.ext_sidebar_has_focus = true; + e.handle_ext_sidebar_key("d", false, None); + // Dialog should be open with the ext_remove tag. + assert!(e.dialog.is_some()); + assert_eq!(e.dialog.as_ref().unwrap().tag, "ext_remove"); + assert!(e.pending_ext_remove.is_some()); + assert_eq!(e.pending_ext_remove.as_ref().unwrap(), "bash"); } -// ── Regression: yyp pastes on next line ────────────────────────────────── - #[test] -fn test_yyp_pastes_on_next_line() { - let text = "foo\n\nfoo \n foo foo\n"; - let mut engine = engine_with_text(text); - // cursor at line 0 - press_char(&mut engine, 'y'); - press_char(&mut engine, 'y'); - let (reg_content, is_lw) = engine.registers.get(&'"').unwrap().clone(); - assert_eq!(reg_content, "foo\n", "yanked content"); - assert!(is_lw, "should be linewise"); - press_char(&mut engine, 'p'); - // Expected: "foo\nfoo\n\nfoo \n foo foo\n" - let result = engine.buffer().to_string(); - assert!( - result.starts_with("foo\nfoo\n"), - "paste should be on next line; got: {:?}", - result - ); - assert_eq!( - engine.view().cursor.line, - 1, - "cursor should move to pasted line" - ); +fn test_ext_remove_dialog_cancel() { + let mut e = Engine::new(); + e.ext_registry = Some(vec![mock_bash_manifest()]); + e.extension_state.mark_installed_version("bash", "1.0.0"); + e.ext_sidebar_sections_expanded = [true, true]; + e.ext_sidebar_selected = 0; + e.handle_ext_sidebar_key("d", false, None); + assert!(e.dialog.is_some()); + // Press Escape to cancel. + e.handle_key("Escape", None, false); + assert!(e.dialog.is_none()); + // Extension should still be installed. + assert!(e.extension_state.is_installed("bash")); } #[test] -fn test_extra_cursors_cleared_on_normal_mode_paste() { - // Extra cursors become stale after a normal-mode buffer modification. - // Verify they are cleared automatically so the user doesn't see ghost cursors. - let mut engine = engine_with_text("foo bar foo\n"); - // Add an extra cursor at (0, 8) - engine.view_mut().extra_cursors = vec![Cursor { line: 0, col: 8 }]; - // yank the line then paste — this is a normal-mode buffer modification - press_char(&mut engine, 'y'); - press_char(&mut engine, 'y'); - press_char(&mut engine, 'p'); - // After paste, extra_cursors must be empty - assert!( - engine.view().extra_cursors.is_empty(), - "extra_cursors should be cleared after normal-mode paste" - ); +fn test_ext_remove_dialog_confirm_remove() { + let mut e = Engine::new(); + e.ext_registry = Some(vec![mock_bash_manifest()]); + e.extension_state.mark_installed_version("bash", "1.0.0"); + e.ext_sidebar_sections_expanded = [true, true]; + e.ext_sidebar_selected = 0; + e.handle_ext_sidebar_key("d", false, None); + assert!(e.dialog.is_some()); + // Press 'r' for "Remove". + e.handle_key("", Some('r'), false); + assert!(e.dialog.is_none()); + // Extension should be removed. + assert!(!e.extension_state.is_installed("bash")); } +// ─── Spell checking ────────────────────────────────────────────────── + #[test] -fn test_extra_cursors_preserved_on_insert_mode_entry() { - // Pressing 'i' (no buffer modification) must NOT clear extra cursors. - let mut engine = engine_with_text("foo bar\n"); - engine.view_mut().extra_cursors = vec![Cursor { line: 0, col: 4 }]; - engine.handle_key("i", Some('i'), false); - assert!( - !engine.view().extra_cursors.is_empty(), - "extra_cursors should be preserved when entering insert mode with 'i'" - ); +fn test_spell_set_spell_initializes_checker() { + let mut e = Engine::new(); + assert!(e.spell_checker.is_none()); + e.settings.spell = true; + e.ensure_spell_checker(); + assert!(e.spell_checker.is_some()); } #[test] -fn test_add_cursor_at_next_match_shows_count_message() { - let mut engine = engine_with_text("foo bar foo\n"); - engine.add_cursor_at_next_match(); - assert!( - engine.message.contains("2 cursors"), - "message should show cursor count; got: {:?}", - engine.message - ); +fn test_spell_jump_next_when_disabled() { + let mut e = engine_with_text("helo wrld"); + // spell is off by default + e.jump_next_spell_error(); + assert!(e.message.contains("off")); } #[test] -fn test_load_clipboard_for_paste_preserves_linewise() { - // When clipboard content matches the existing '"' register, is_linewise is kept. - let mut engine = engine_with_text("foo\nbar\n"); - engine.registers.insert('"', ("foo\n".to_string(), true)); - engine.load_clipboard_for_paste("foo\n".to_string()); - let (_, lw) = engine.registers[&'"'].clone(); - assert!( - lw, - "load_clipboard_for_paste should preserve is_linewise when content matches" - ); +fn test_spell_jump_next_finds_error() { + let mut e = engine_with_text("the quik brown fox"); + e.settings.spell = true; + e.ensure_spell_checker(); + e.jump_next_spell_error(); + assert_eq!(e.cursor().col, 4); // "quik" starts at col 4 + assert!(e.message.contains("quik")); } #[test] -fn test_load_clipboard_for_paste_clears_linewise_for_foreign_content() { - // When clipboard content differs (from another app), is_linewise becomes false. - let mut engine = engine_with_text("foo\nbar\n"); - engine.registers.insert('"', ("foo\n".to_string(), true)); - engine.load_clipboard_for_paste("different text".to_string()); - let (_, lw) = engine.registers[&'"'].clone(); - assert!( - !lw, - "load_clipboard_for_paste should clear is_linewise for external content" - ); +fn test_spell_jump_prev_finds_error() { + let mut e = engine_with_text("helo world"); + e.settings.spell = true; + e.ensure_spell_checker(); + e.view_mut().cursor.col = 9; + e.jump_prev_spell_error(); + assert_eq!(e.cursor().col, 0); // "helo" at col 0 } #[test] -fn test_yyp_linewise_via_clipboard_intercept() { - // Simulate the yy → clipboard-intercept-before-p flow that the backends perform. - let mut engine = engine_with_text("foo\nbar\n"); - press_char(&mut engine, 'y'); - press_char(&mut engine, 'y'); - let (content, lw) = engine.registers[&'"'].clone(); - assert!(lw, "yy should set is_linewise=true"); - // Backend intercepts p, reads same text from clipboard, calls load_clipboard_for_paste. - engine.load_clipboard_for_paste(content); - press_char(&mut engine, 'p'); - // Buffer should be "foo\nfoo\nbar\n" — pasted on the line below, not inline. - assert_eq!( - engine.buffer().to_string(), - "foo\nfoo\nbar\n", - "linewise paste via clipboard intercept should insert on next line" - ); - assert_eq!(engine.view().cursor.line, 1, "cursor on pasted line"); +fn test_spell_add_good_word() { + let mut e = engine_with_text("vimcode"); + e.settings.spell = true; + e.ensure_spell_checker(); + // Before adding, "vimcode" is misspelled + e.jump_next_spell_error(); + assert_eq!(e.cursor().col, 0); + // Add to user dict + e.spell_add_good_word(); + // Now jump should find no errors + e.view_mut().cursor.col = 0; + e.jump_next_spell_error(); + assert!(e.message.contains("No spelling errors")); + // Clean up + e.spell_mark_wrong(); } -// ── Editor group tests ──────────────────────────────────────────────────── - #[test] -fn test_editor_group_split_commands() { - let mut engine = Engine::new(); - assert_eq!(engine.group_layout.leaf_count(), 1); - - // :EditorGroupSplit should create a second group. - let r = engine.execute_command("EditorGroupSplit"); - assert_ne!( - r, - EngineAction::Error, - "EditorGroupSplit should not return error" - ); - assert_eq!( - engine.group_layout.leaf_count(), - 2, - "EditorGroupSplit should create a second group" - ); - - // A third split should now work (no cap at 2). - let r2 = engine.execute_command("egsp"); - assert_ne!(r2, EngineAction::Error, "egsp should not return error"); - assert_eq!( - engine.group_layout.leaf_count(), - 3, - "egsp should create third group" - ); - - // Close back to 2, then close to 1. - engine.execute_command("egc"); - assert_eq!(engine.group_layout.leaf_count(), 2); - engine.execute_command("egc"); - assert_eq!(engine.group_layout.leaf_count(), 1); +fn test_spell_toggle_via_palette_action() { + let mut e = Engine::new(); + assert!(!e.settings.spell); + // Simulate palette toggle + e.settings.spell = !e.settings.spell; + e.ensure_spell_checker(); + assert!(e.settings.spell); + assert!(e.spell_checker.is_some()); +} - let r3 = engine.execute_command("EditorGroupSplitDown"); - assert_ne!( - r3, - EngineAction::Error, - "EditorGroupSplitDown should not return error" - ); - assert_eq!(engine.group_layout.leaf_count(), 2); +// ── LaTeX text objects and motions ──────────────────────────────────────── - // :egspd creates a third. - let r4 = engine.execute_command("egspd"); - assert_ne!(r4, EngineAction::Error, "egspd should not return error"); - assert_eq!(engine.group_layout.leaf_count(), 3); +fn latex_engine(text: &str) -> Engine { + use crate::core::syntax::{Syntax, SyntaxLanguage}; + let mut e = engine_with_text(text); + e.active_buffer_state_mut().syntax = Some(Syntax::new_for_language(SyntaxLanguage::Latex)); + e } #[test] -fn test_recursive_split_three_groups() { - let mut engine = Engine::new(); - // Split right - engine.open_editor_group(SplitDirection::Vertical); - assert_eq!(engine.group_layout.leaf_count(), 2); - // Focus group 0 and split down - let first_id = engine.group_layout.group_ids()[0]; - engine.active_group = first_id; - engine.open_editor_group(SplitDirection::Horizontal); - assert_eq!(engine.group_layout.leaf_count(), 3); - // Verify all 3 groups exist in the HashMap - assert_eq!(engine.editor_groups.len(), 3); +fn test_latex_environment_object_inner() { + let mut e = latex_engine("\\begin{itemize}\nitem one\nitem two\n\\end{itemize}\n"); + // Move cursor to line 1 (inside the environment) + e.view_mut().cursor.line = 1; + e.view_mut().cursor.col = 0; + press_char(&mut e, 'd'); + press_char(&mut e, 'i'); + press_char(&mut e, 'e'); + let content = e.buffer().to_string(); + assert!(content.contains("\\begin{itemize}")); + assert!(content.contains("\\end{itemize}")); + assert!(!content.contains("item one")); } #[test] -fn test_focus_cycling_three_groups() { - let mut engine = Engine::new(); - engine.open_editor_group(SplitDirection::Vertical); - let first_id = engine.group_layout.group_ids()[0]; - engine.active_group = first_id; - engine.open_editor_group(SplitDirection::Horizontal); - let ids = engine.group_layout.group_ids(); - assert_eq!(ids.len(), 3); - // Start at the newly split group (last created) - let start = engine.active_group; - engine.focus_other_group(); - let second = engine.active_group; - assert_ne!(start, second); - engine.focus_other_group(); - let third = engine.active_group; - assert_ne!(second, third); - engine.focus_other_group(); - // Should wrap back - assert_eq!(engine.active_group, start); +fn test_latex_environment_object_around() { + let mut e = latex_engine("before\n\\begin{itemize}\nitem one\n\\end{itemize}\nafter\n"); + e.view_mut().cursor.line = 2; + e.view_mut().cursor.col = 0; + press_char(&mut e, 'd'); + press_char(&mut e, 'a'); + press_char(&mut e, 'e'); + let content = e.buffer().to_string(); + assert!(!content.contains("\\begin{itemize}")); + assert!(!content.contains("\\end{itemize}")); + assert!(content.contains("before")); + assert!(content.contains("after")); } #[test] -fn test_close_nested_group() { - let mut engine = Engine::new(); - engine.open_editor_group(SplitDirection::Vertical); - let first_id = engine.group_layout.group_ids()[0]; - engine.active_group = first_id; - engine.open_editor_group(SplitDirection::Horizontal); - assert_eq!(engine.group_layout.leaf_count(), 3); - // Close the active group - engine.close_editor_group(); - assert_eq!(engine.group_layout.leaf_count(), 2); - // Close again - engine.close_editor_group(); - assert_eq!(engine.group_layout.leaf_count(), 1); - assert!(engine.group_layout.is_single_group()); +fn test_latex_environment_object_nested() { + let mut e = latex_engine( + "\\begin{enumerate}\n\\begin{itemize}\nhello\n\\end{itemize}\n\\end{enumerate}\n", + ); + // Cursor inside inner environment + e.view_mut().cursor.line = 2; + e.view_mut().cursor.col = 0; + press_char(&mut e, 'd'); + press_char(&mut e, 'i'); + press_char(&mut e, 'e'); + let content = e.buffer().to_string(); + // Inner environment \begin/\end{itemize} should remain + assert!(content.contains("\\begin{itemize}")); + assert!(content.contains("\\end{itemize}")); + assert!(!content.contains("hello")); } #[test] -fn test_four_groups_nested_splits() { - // Reproduce: split right, focus left, split down, split down again → 4 groups - let mut engine = Engine::new(); - engine.open_editor_group(SplitDirection::Vertical); - assert_eq!(engine.group_layout.leaf_count(), 2); - let first_id = engine.group_layout.group_ids()[0]; - engine.active_group = first_id; - engine.open_editor_group(SplitDirection::Horizontal); - assert_eq!(engine.group_layout.leaf_count(), 3); - // Second split down on the newly created group - engine.open_editor_group(SplitDirection::Horizontal); - assert_eq!(engine.group_layout.leaf_count(), 4); +fn test_latex_math_object_inline() { + let mut e = latex_engine("Text $x^2 + y^2$ more\n"); + e.view_mut().cursor.line = 0; + e.view_mut().cursor.col = 7; // inside $...$ + press_char(&mut e, 'd'); + press_char(&mut e, 'i'); + press_char(&mut e, '$'); + let content = e.buffer().to_string(); + assert!(content.contains("$$")); // delimiters remain, content removed + assert!(!content.contains("x^2")); +} - // Simulate TUI rendering: content_bounds includes tab bar row (+1) - let content_bounds = WindowRect::new(0.0, 0.0, 80.0, 25.0); - let (rects, dividers) = engine.calculate_group_window_rects(content_bounds, 1.0); - assert_eq!(rects.len(), 4, "should have 4 window rects"); +#[test] +fn test_latex_math_object_around() { + let mut e = latex_engine("Text $x^2$ more\n"); + e.view_mut().cursor.line = 0; + e.view_mut().cursor.col = 6; // inside $...$ + press_char(&mut e, 'd'); + press_char(&mut e, 'a'); + press_char(&mut e, '$'); + let content = e.buffer().to_string(); + assert!(!content.contains("$")); + assert!(content.contains("Text ")); + assert!(content.contains("more")); +} - // All rects should have positive width and non-negative height - for (wid, r) in &rects { - assert!(r.width > 0.0, "window {:?} has zero width", wid); - assert!(r.height >= 0.0, "window {:?} has negative height", wid); - assert!(r.y >= 0.0, "window {:?} has negative y", wid); - assert!( - r.y + r.height <= content_bounds.height, - "window {:?} extends beyond content bounds: y={} h={} max={}", - wid, - r.y, - r.height, - content_bounds.height - ); - } - assert!(!dividers.is_empty(), "should have dividers"); +#[test] +fn test_latex_math_object_display() { + let mut e = latex_engine("Text \\[a + b\\] more\n"); + e.view_mut().cursor.line = 0; + e.view_mut().cursor.col = 8; // inside \[...\] + press_char(&mut e, 'd'); + press_char(&mut e, 'i'); + press_char(&mut e, '$'); + let content = e.buffer().to_string(); + assert!(content.contains("\\[\\]")); // delimiters remain + assert!(!content.contains("a + b")); } #[test] -fn test_group_resize_nested() { - let mut engine = Engine::new(); - engine.open_editor_group(SplitDirection::Vertical); - let first_id = engine.group_layout.group_ids()[0]; - engine.active_group = first_id; - // Resize: should adjust the parent split ratio - engine.group_resize(0.1); - // Verify it changed (default was 0.5, now should be 0.6) - if let GroupLayout::Split { ratio, .. } = &engine.group_layout { - assert!((*ratio - 0.6).abs() < 0.01); - } else { - panic!("Expected split layout"); - } +fn test_latex_command_object_inner() { + let mut e = latex_engine("\\textbf{hello world}\n"); + e.view_mut().cursor.line = 0; + e.view_mut().cursor.col = 10; // inside braces + press_char(&mut e, 'd'); + press_char(&mut e, 'i'); + press_char(&mut e, 'c'); + let content = e.buffer().to_string(); + assert!(content.contains("\\textbf{}")); + assert!(!content.contains("hello")); } #[test] -fn test_command_cursor_initial_position() { - // Entering command mode should set cursor to 0 (after the ':') - let mut engine = Engine::new(); - engine.handle_key(":", Some(':'), false); - assert_eq!(engine.command_cursor, 0); - assert_eq!(engine.command_buffer, ""); +fn test_latex_command_object_around() { + let mut e = latex_engine("some \\textbf{hello} text\n"); + e.view_mut().cursor.line = 0; + e.view_mut().cursor.col = 14; // inside braces + press_char(&mut e, 'd'); + press_char(&mut e, 'a'); + press_char(&mut e, 'c'); + let content = e.buffer().to_string(); + assert!(!content.contains("\\textbf")); + assert!(content.contains("some ")); + assert!(content.contains(" text")); } #[test] -fn test_command_cursor_advances_on_type() { - let mut engine = Engine::new(); - engine.handle_key(":", Some(':'), false); - engine.handle_key("", Some('h'), false); - engine.handle_key("", Some('i'), false); - assert_eq!(engine.command_buffer, "hi"); - assert_eq!(engine.command_cursor, 2); +fn test_latex_section_jump_forward() { + let mut e = latex_engine("\\section{One}\ntext\n\\subsection{Two}\nmore\n\\section{Three}\n"); + e.view_mut().cursor.line = 0; + e.view_mut().cursor.col = 0; + // ]] jump to next section + press_char(&mut e, ']'); + press_char(&mut e, ']'); + assert_eq!(e.view().cursor.line, 2); + // Again + press_char(&mut e, ']'); + press_char(&mut e, ']'); + assert_eq!(e.view().cursor.line, 4); } #[test] -fn test_command_cursor_left_right() { - let mut engine = Engine::new(); - engine.handle_key(":", Some(':'), false); - engine.handle_key("", Some('a'), false); - engine.handle_key("", Some('b'), false); - engine.handle_key("", Some('c'), false); - assert_eq!(engine.command_cursor, 3); - - engine.handle_key("Left", None, false); - assert_eq!(engine.command_cursor, 2); - engine.handle_key("Left", None, false); - assert_eq!(engine.command_cursor, 1); - // Insert at position 1 - engine.handle_key("", Some('X'), false); - assert_eq!(engine.command_buffer, "aXbc"); - assert_eq!(engine.command_cursor, 2); - - engine.handle_key("Right", None, false); - assert_eq!(engine.command_cursor, 3); +fn test_latex_section_jump_backward() { + let mut e = latex_engine("\\section{One}\ntext\n\\subsection{Two}\nmore\n\\section{Three}\n"); + e.view_mut().cursor.line = 4; + e.view_mut().cursor.col = 0; + // [[ jump to previous section + press_char(&mut e, '['); + press_char(&mut e, '['); + assert_eq!(e.view().cursor.line, 2); + press_char(&mut e, '['); + press_char(&mut e, '['); + assert_eq!(e.view().cursor.line, 0); } #[test] -fn test_command_cursor_home_end() { - let mut engine = Engine::new(); - engine.handle_key(":", Some(':'), false); - for ch in "hello".chars() { - engine.handle_key("", Some(ch), false); - } - engine.handle_key("Home", None, false); - assert_eq!(engine.command_cursor, 0); - engine.handle_key("End", None, false); - assert_eq!(engine.command_cursor, 5); +fn test_latex_env_jump_forward() { + let mut e = latex_engine( + "\\begin{document}\ntext\n\\begin{itemize}\nitem\n\\end{itemize}\n\\end{document}\n", + ); + e.view_mut().cursor.line = 0; + e.view_mut().cursor.col = 0; + // ]m jump to next \begin + press_char(&mut e, ']'); + press_char(&mut e, 'm'); + assert_eq!(e.view().cursor.line, 2); + assert_eq!(e.view().cursor.col, 0); } #[test] -fn test_command_cursor_delete_key() { - let mut engine = Engine::new(); - engine.handle_key(":", Some(':'), false); - for ch in "abc".chars() { - engine.handle_key("", Some(ch), false); - } - // Move to start, delete first char - engine.handle_key("Home", None, false); - engine.handle_key("Delete", None, false); - assert_eq!(engine.command_buffer, "bc"); - assert_eq!(engine.command_cursor, 0); +fn test_latex_env_jump_backward() { + let mut e = latex_engine( + "\\begin{document}\ntext\n\\begin{itemize}\nitem\n\\end{itemize}\n\\end{document}\n", + ); + e.view_mut().cursor.line = 4; + e.view_mut().cursor.col = 0; + // [m jump to previous \begin + press_char(&mut e, '['); + press_char(&mut e, 'm'); + assert_eq!(e.view().cursor.line, 2); + assert_eq!(e.view().cursor.col, 0); } #[test] -fn test_command_cursor_backspace_at_cursor() { - let mut engine = Engine::new(); - engine.handle_key(":", Some(':'), false); - for ch in "abc".chars() { - engine.handle_key("", Some(ch), false); - } - engine.handle_key("Left", None, false); // cursor at 2 - engine.handle_key("BackSpace", None, false); // deletes 'b' - assert_eq!(engine.command_buffer, "ac"); - assert_eq!(engine.command_cursor, 1); +fn test_latex_env_end_jump_forward() { + let mut e = latex_engine( + "\\begin{document}\ntext\n\\begin{itemize}\nitem\n\\end{itemize}\n\\end{document}\n", + ); + e.view_mut().cursor.line = 0; + e.view_mut().cursor.col = 0; + // ]M jump to next \end + press_char(&mut e, ']'); + press_char(&mut e, 'M'); + assert_eq!(e.view().cursor.line, 4); + assert_eq!(e.view().cursor.col, 0); } #[test] -fn test_command_insert_str_at_cursor() { - let mut engine = Engine::new(); - engine.handle_key(":", Some(':'), false); - for ch in "ac".chars() { - engine.handle_key("", Some(ch), false); - } - engine.handle_key("Left", None, false); // cursor between 'a' and 'c' - engine.command_insert_str("b"); - assert_eq!(engine.command_buffer, "abc"); - assert_eq!(engine.command_cursor, 2); +fn test_latex_env_end_jump_backward() { + let mut e = latex_engine( + "\\begin{document}\ntext\n\\begin{itemize}\nitem\n\\end{itemize}\n\\end{document}\n", + ); + e.view_mut().cursor.line = 5; + e.view_mut().cursor.col = 0; + // [M jump to previous \end + press_char(&mut e, '['); + press_char(&mut e, 'M'); + assert_eq!(e.view().cursor.line, 4); } #[test] -fn test_command_ctrl_a_e_k() { - let mut engine = Engine::new(); - engine.handle_key(":", Some(':'), false); - for ch in "hello".chars() { - engine.handle_key("", Some(ch), false); - } - // Ctrl-A goes to start - engine.handle_key("a", Some('a'), true); - assert_eq!(engine.command_cursor, 0); - // Ctrl-E goes to end - engine.handle_key("e", Some('e'), true); - assert_eq!(engine.command_cursor, 5); - // Move to middle, Ctrl-K kills to end - engine.handle_key("Left", None, false); - engine.handle_key("Left", None, false); - engine.handle_key("k", Some('k'), true); - assert_eq!(engine.command_buffer, "hel"); - assert_eq!(engine.command_cursor, 3); +fn test_latex_section_end_jump() { + let mut e = latex_engine("\\section{One}\ntext\n\\end{document}\nmore\n\\end{other}\n"); + e.view_mut().cursor.line = 0; + // ][ jump to next \end + press_char(&mut e, ']'); + press_char(&mut e, '['); + assert_eq!(e.view().cursor.line, 2); } -// ─── Visual mode Ctrl-D / Ctrl-U tests ───────────────────────────── - #[test] -fn test_visual_ctrl_d_extends_selection_down() { - let mut engine = Engine::new(); - // 20 lines so half-page is meaningful - let text = (0..20) - .map(|i| format!("line {i}")) - .collect::>() - .join("\n"); - engine.buffer_mut().insert(0, &text); - engine.view_mut().viewport_lines = 10; - // Enter visual mode on line 0 - press_char(&mut engine, 'v'); - assert!(matches!(engine.mode, Mode::Visual)); - // Ctrl-D should move cursor down by half page (5 lines), extending selection - press_ctrl(&mut engine, 'd'); - assert!(matches!(engine.mode, Mode::Visual)); - assert_eq!(engine.view().cursor.line, 5); - // Buffer should be unchanged (not deleted) - assert_eq!(engine.buffer().len_lines(), 20); +fn test_latex_section_commands_variants() { + let mut e = latex_engine("text\n\\chapter{C}\nmore\n\\paragraph{P}\nend\n"); + e.view_mut().cursor.line = 0; + press_char(&mut e, ']'); + press_char(&mut e, ']'); + assert_eq!(e.view().cursor.line, 1); // \chapter + press_char(&mut e, ']'); + press_char(&mut e, ']'); + assert_eq!(e.view().cursor.line, 3); // \paragraph } #[test] -fn test_visual_ctrl_u_extends_selection_up() { - let mut engine = Engine::new(); - let text = (0..20) - .map(|i| format!("line {i}")) - .collect::>() - .join("\n"); - engine.buffer_mut().insert(0, &text); - engine.view_mut().viewport_lines = 10; - // Move to line 10 - engine.view_mut().cursor.line = 10; - // Enter visual mode - press_char(&mut engine, 'v'); - assert!(matches!(engine.mode, Mode::Visual)); - // Ctrl-U should move cursor up by half page (5 lines), extending selection - press_ctrl(&mut engine, 'u'); - assert!(matches!(engine.mode, Mode::Visual)); - assert_eq!(engine.view().cursor.line, 5); - // Buffer should be unchanged (case not toggled) - let content = engine.buffer().to_string(); - assert!(content.starts_with("line 0")); +fn test_latex_starred_section() { + let mut e = latex_engine("text\n\\section*{Unnumbered}\nmore\n"); + e.view_mut().cursor.line = 0; + press_char(&mut e, ']'); + press_char(&mut e, ']'); + assert_eq!(e.view().cursor.line, 1); // \section* matches } -// ─── Dialog system tests ───────────────────────────────────────── +#[test] +fn test_latex_yank_environment_inner() { + let mut e = latex_engine("\\begin{quote}\nhello\n\\end{quote}\n"); + e.view_mut().cursor.line = 1; + e.view_mut().cursor.col = 0; + press_char(&mut e, 'y'); + press_char(&mut e, 'i'); + press_char(&mut e, 'e'); + // Content should be unchanged + assert!(e.buffer().to_string().contains("hello")); + // Register should contain the inner content + let (text, _) = e.registers.get(&'"').expect("register should be set"); + assert!(text.contains("hello")); + assert!(!text.contains("\\begin")); +} #[test] -fn test_dialog_show_and_escape() { - let mut e = Engine::new(); - e.buffer_mut().insert(0, "hello"); - e.show_dialog( - "test", - "Test Dialog", - vec!["Body line".into()], - vec![DialogButton { - label: "OK".into(), - hotkey: 'o', - action: "ok".into(), - }], - ); - assert!(e.dialog.is_some()); - // Escape dismisses. - e.handle_key("Escape", None, false); - assert!(e.dialog.is_none()); +fn test_latex_env_object_not_in_non_latex() { + // ie/ae should do nothing in non-LaTeX buffers + let mut e = engine_with_text("\\begin{test}\nhello\n\\end{test}\n"); + e.view_mut().cursor.line = 1; + press_char(&mut e, 'd'); + press_char(&mut e, 'i'); + press_char(&mut e, 'e'); + // Buffer unchanged — non-LaTeX buffer + assert!(e.buffer().to_string().contains("hello")); } #[test] -fn test_dialog_hotkey() { - let mut e = Engine::new(); - e.show_dialog( - "test", - "Choose", - vec!["Pick one".into()], - vec![ - DialogButton { - label: "Recover".into(), - hotkey: 'r', - action: "recover".into(), - }, - DialogButton { - label: "Delete".into(), - hotkey: 'd', - action: "delete".into(), - }, - ], - ); - // Press 'r' → hotkey should dismiss. - e.handle_key("", Some('r'), false); - assert!(e.dialog.is_none()); +fn test_latex_double_dollar_math() { + let mut e = latex_engine("Text $$E=mc^2$$ more\n"); + e.view_mut().cursor.line = 0; + e.view_mut().cursor.col = 8; + press_char(&mut e, 'd'); + press_char(&mut e, 'i'); + press_char(&mut e, '$'); + let content = e.buffer().to_string(); + assert!(content.contains("$$$$")); // delimiters remain + assert!(!content.contains("E=mc")); } +// ── Panel hover popup tests ───────────────────────────────────────── + #[test] -fn test_dialog_arrow_nav_and_enter() { - let mut e = Engine::new(); - e.show_dialog( - "test", - "Choose", - vec![], - vec![ - DialogButton { - label: "A".into(), - hotkey: 'a', - action: "a_action".into(), - }, - DialogButton { - label: "B".into(), - hotkey: 'b', - action: "b_action".into(), - }, - DialogButton { - label: "C".into(), - hotkey: 'c', - action: "c_action".into(), - }, - ], - ); - assert_eq!(e.dialog.as_ref().unwrap().selected, 0); - // Move right. - e.handle_key("Right", None, false); - assert_eq!(e.dialog.as_ref().unwrap().selected, 1); - // Move right again. - e.handle_key("Right", None, false); - assert_eq!(e.dialog.as_ref().unwrap().selected, 2); - // Right at end → wraps to 0. - e.handle_key("Right", None, false); - assert_eq!(e.dialog.as_ref().unwrap().selected, 0); - // Move right to 1. - e.handle_key("Right", None, false); - assert_eq!(e.dialog.as_ref().unwrap().selected, 1); - // Enter confirms the selected button. - e.handle_key("Return", None, false); - assert!(e.dialog.is_none()); +fn test_panel_hover_show_and_dismiss() { + let mut e = engine_with_text("hello\n"); + assert!(e.panel_hover.is_none()); + e.show_panel_hover("test_panel", "item_1", 0, "**Bold** text"); + assert!(e.panel_hover.is_some()); + let ph = e.panel_hover.as_ref().unwrap(); + assert_eq!(ph.panel_name, "test_panel"); + assert_eq!(ph.item_id, "item_1"); + assert_eq!(ph.item_index, 0); + assert!(!ph.rendered.lines.is_empty()); + e.dismiss_panel_hover_now(); + assert!(e.panel_hover.is_none()); } #[test] -fn test_dialog_blocks_normal_keys() { - let mut e = Engine::new(); - e.buffer_mut().insert(0, "hello"); - e.show_dialog( - "test", - "Block", - vec![], - vec![DialogButton { - label: "OK".into(), - hotkey: 'o', - action: "ok".into(), - }], +fn test_panel_hover_links_extracted() { + let mut e = engine_with_text("hello\n"); + e.show_panel_hover("p", "i", 0, "See [docs](https://example.com) here"); + let ph = e.panel_hover.as_ref().unwrap(); + // Should have at least one link extracted from the markdown + assert!( + !ph.links.is_empty() || !ph.rendered.lines.is_empty(), + "hover should have rendered content" ); - // Press 'x' which would normally delete a char — dialog should consume it. - e.handle_key("", Some('x'), false); - assert!(e.dialog.is_some()); // Dialog still open. - assert_eq!(e.buffer().to_string(), "hello"); // Buffer unchanged. } #[test] -fn test_show_error_dialog() { - let mut e = Engine::new(); - e.show_error_dialog("Error", "Something went wrong"); - let dialog = e.dialog.as_ref().unwrap(); - assert_eq!(dialog.tag, "error"); - assert_eq!(dialog.title, "Error"); - assert_eq!(dialog.body, vec!["Something went wrong"]); - assert_eq!(dialog.buttons.len(), 1); - assert_eq!(dialog.buttons[0].hotkey, 'o'); - assert_eq!(dialog.buttons[0].action, "ok"); -} - -// ─── Extension removal dialog ──────────────────────────────────────── - -/// Create a minimal mock bash extension manifest for tests that don't -/// depend on local disk state (CI has no extensions installed on disk). -fn mock_bash_manifest() -> extensions::ExtensionManifest { - extensions::ExtensionManifest { - name: "bash".to_string(), - display_name: "Bash".to_string(), - description: "Bash language support".to_string(), - version: "1.0.0".to_string(), - ..Default::default() - } +fn test_panel_hover_mouse_move_dwell_tracking() { + let mut e = engine_with_text("hello\n"); + // First move starts dwell + let changed = e.panel_hover_mouse_move("panel", "item_0", 0); + assert!(changed); + assert!(e.panel_hover_dwell.is_some()); + // Same item should not change + let changed2 = e.panel_hover_mouse_move("panel", "item_0", 0); + assert!(!changed2); + // Different item should change + let changed3 = e.panel_hover_mouse_move("panel", "item_1", 1); + assert!(changed3); } #[test] -fn test_ext_remove_dialog_shows_on_d() { - let mut e = Engine::new(); - e.ext_registry = Some(vec![mock_bash_manifest()]); - // Install a single extension so it's the only item at index 0. - e.extension_state.mark_installed_version("bash", "1.0.0"); - e.ext_sidebar_sections_expanded = [true, true]; - e.ext_sidebar_selected = 0; // First installed item. - e.ext_sidebar_has_focus = true; - e.handle_ext_sidebar_key("d", false, None); - // Dialog should be open with the ext_remove tag. - assert!(e.dialog.is_some()); - assert_eq!(e.dialog.as_ref().unwrap().tag, "ext_remove"); - assert!(e.pending_ext_remove.is_some()); - assert_eq!(e.pending_ext_remove.as_ref().unwrap(), "bash"); +fn test_panel_hover_registry_lookup() { + let mut e = engine_with_text("hello\n"); + // Register hover content for a panel item + e.panel_hover_registry.insert( + ("my_panel".to_string(), "commit_abc".to_string()), + "# Commit abc\n\nSome details".to_string(), + ); + // Start dwell on that item — poll should NOT show yet (need 300ms) + e.panel_hover_mouse_move("my_panel", "commit_abc", 2); + let shown = e.poll_panel_hover(); + assert!(!shown, "should not show before dwell timeout"); } #[test] -fn test_ext_remove_dialog_cancel() { - let mut e = Engine::new(); - e.ext_registry = Some(vec![mock_bash_manifest()]); - e.extension_state.mark_installed_version("bash", "1.0.0"); - e.ext_sidebar_sections_expanded = [true, true]; - e.ext_sidebar_selected = 0; - e.handle_ext_sidebar_key("d", false, None); - assert!(e.dialog.is_some()); - // Press Escape to cancel. - e.handle_key("Escape", None, false); - assert!(e.dialog.is_none()); - // Extension should still be installed. - assert!(e.extension_state.is_installed("bash")); +fn test_panel_hover_dismissed_on_keypress() { + let mut e = engine_with_text("hello\n"); + e.show_panel_hover("p", "i", 0, "test"); + assert!(e.panel_hover.is_some()); + // Any key press should dismiss + e.handle_key("j", None, false); + assert!(e.panel_hover.is_none()); } #[test] -fn test_ext_remove_dialog_confirm_remove() { - let mut e = Engine::new(); - e.ext_registry = Some(vec![mock_bash_manifest()]); - e.extension_state.mark_installed_version("bash", "1.0.0"); - e.ext_sidebar_sections_expanded = [true, true]; - e.ext_sidebar_selected = 0; - e.handle_ext_sidebar_key("d", false, None); - assert!(e.dialog.is_some()); - // Press 'r' for "Remove". - e.handle_key("", Some('r'), false); - assert!(e.dialog.is_none()); - // Extension should be removed. - assert!(!e.extension_state.is_installed("bash")); +fn test_sc_hover_file_generates_markdown() { + let mut e = engine_with_text("hello\n"); + // Populate SC panel with a fake file status + e.sc_file_statuses = vec![crate::core::git::FileStatus { + path: "src/main.rs".to_string(), + staged: None, + unstaged: Some(crate::core::git::StatusKind::Modified), + }]; + e.sc_sections_expanded = [true, true, false, false]; + // flat index: 0=staged header, 1=unstaged header, 2=file item + let md = e.sc_hover_markdown(2); + assert!(md.is_some()); + let md = md.unwrap(); + assert!(md.contains("src/main.rs"), "hover should contain filename"); + assert!(md.contains("Modified"), "hover should contain status"); + assert!( + md.contains("unstaged"), + "hover should indicate staged/unstaged" + ); } -// ─── Spell checking ────────────────────────────────────────────────── - #[test] -fn test_spell_set_spell_initializes_checker() { - let mut e = Engine::new(); - assert!(e.spell_checker.is_none()); - e.settings.spell = true; - e.ensure_spell_checker(); - assert!(e.spell_checker.is_some()); +fn test_sc_hover_section_header_returns_none_for_non_branch() { + let mut e = engine_with_text("hello\n"); + e.sc_file_statuses = vec![]; + e.sc_sections_expanded = [true, true, false, false]; + // flat index 0 = Staged Changes header (section 0 → branch info) + // flat index 1 = Unstaged Changes header (section 1 → None) + let md = e.sc_hover_markdown(1); + assert!(md.is_none(), "non-branch headers should return None"); } #[test] -fn test_spell_jump_next_when_disabled() { - let mut e = engine_with_text("helo wrld"); - // spell is off by default - e.jump_next_spell_error(); - assert!(e.message.contains("off")); +fn test_sc_hover_log_entry_generates_markdown() { + let mut e = engine_with_text("hello\n"); + e.sc_file_statuses = vec![]; + e.sc_log = vec![crate::core::git::GitLogEntry { + hash: "abc1234".to_string(), + message: "feat: add hover popups".to_string(), + }]; + e.sc_sections_expanded = [true, true, false, true]; + // flat indices: 0=staged hdr, 1=unstaged hdr, 2=log hdr, 3=log item + let md = e.sc_hover_markdown(3); + assert!(md.is_some()); + let md = md.unwrap(); + assert!( + md.contains("abc1234") || md.contains("hover popups"), + "log hover should contain hash or message" + ); } #[test] -fn test_spell_jump_next_finds_error() { - let mut e = engine_with_text("the quik brown fox"); - e.settings.spell = true; - e.ensure_spell_checker(); - e.jump_next_spell_error(); - assert_eq!(e.cursor().col, 4); // "quik" starts at col 4 - assert!(e.message.contains("quik")); +fn test_is_safe_url_allows_https() { + assert!(is_safe_url("https://github.com/user/repo")); + assert!(is_safe_url("http://example.com")); + assert!(is_safe_url("HTTPS://EXAMPLE.COM")); } #[test] -fn test_spell_jump_prev_finds_error() { - let mut e = engine_with_text("helo world"); - e.settings.spell = true; - e.ensure_spell_checker(); - e.view_mut().cursor.col = 9; - e.jump_prev_spell_error(); - assert_eq!(e.cursor().col, 0); // "helo" at col 0 +fn test_is_safe_url_rejects_dangerous_schemes() { + assert!(!is_safe_url("javascript:alert(1)")); + assert!(!is_safe_url("file:///etc/passwd")); + assert!(!is_safe_url("data:text/html,

hi

")); + assert!(!is_safe_url("ftp://example.com")); + assert!(!is_safe_url("ssh://evil.com")); + assert!(!is_safe_url("")); } #[test] -fn test_spell_add_good_word() { - let mut e = engine_with_text("vimcode"); - e.settings.spell = true; - e.ensure_spell_checker(); - // Before adding, "vimcode" is misspelled - e.jump_next_spell_error(); - assert_eq!(e.cursor().col, 0); - // Add to user dict - e.spell_add_good_word(); - // Now jump should find no errors - e.view_mut().cursor.col = 0; - e.jump_next_spell_error(); - assert!(e.message.contains("No spelling errors")); - // Clean up - e.spell_mark_wrong(); +fn test_hover_links_filtered_by_safe_url() { + let mut e = engine_with_text("hello\n"); + // Markdown with a safe link and a dangerous link + e.show_panel_hover( + "ext_panel", + "i", + 0, + "Safe: [click](https://example.com) Evil: [hack](javascript:alert(1))", + ); + let ph = e.panel_hover.as_ref().unwrap(); + // Only the https link should be in the links list + for link in &ph.links { + assert!( + is_safe_url(&link.3), + "unsafe URL should have been filtered: {}", + link.3 + ); + } } #[test] -fn test_spell_toggle_via_palette_action() { - let mut e = Engine::new(); - assert!(!e.settings.spell); - // Simulate palette toggle - e.settings.spell = !e.settings.spell; - e.ensure_spell_checker(); - assert!(e.settings.spell); - assert!(e.spell_checker.is_some()); +fn test_hover_native_panel_is_native() { + let mut e = engine_with_text("hello\n"); + e.show_panel_hover("source_control", "", 0, "# test"); + assert!(e.panel_hover.as_ref().unwrap().is_native()); + e.show_panel_hover("my_ext_panel", "", 0, "# test"); + assert!(!e.panel_hover.as_ref().unwrap().is_native()); } -// ── LaTeX text objects and motions ──────────────────────────────────────── - -fn latex_engine(text: &str) -> Engine { - use crate::core::syntax::{Syntax, SyntaxLanguage}; - let mut e = engine_with_text(text); - e.active_buffer_state_mut().syntax = Some(Syntax::new_for_language(SyntaxLanguage::Latex)); - e +#[test] +fn test_hover_selection_extract_single_line() { + let sel = HoverSelection { + anchor_line: 0, + anchor_col: 2, + active_line: 0, + active_col: 7, + }; + let lines = vec!["hello world".to_string()]; + assert_eq!(sel.extract_text(&lines), "llo w"); } #[test] -fn test_latex_environment_object_inner() { - let mut e = latex_engine("\\begin{itemize}\nitem one\nitem two\n\\end{itemize}\n"); - // Move cursor to line 1 (inside the environment) - e.view_mut().cursor.line = 1; - e.view_mut().cursor.col = 0; - press_char(&mut e, 'd'); - press_char(&mut e, 'i'); - press_char(&mut e, 'e'); - let content = e.buffer().to_string(); - assert!(content.contains("\\begin{itemize}")); - assert!(content.contains("\\end{itemize}")); - assert!(!content.contains("item one")); +fn test_hover_selection_extract_multi_line() { + let sel = HoverSelection { + anchor_line: 0, + anchor_col: 3, + active_line: 2, + active_col: 4, + }; + let lines = vec![ + "first line".to_string(), + "second line".to_string(), + "third line".to_string(), + ]; + assert_eq!(sel.extract_text(&lines), "st line\nsecond line\nthir"); } #[test] -fn test_latex_environment_object_around() { - let mut e = latex_engine("before\n\\begin{itemize}\nitem one\n\\end{itemize}\nafter\n"); - e.view_mut().cursor.line = 2; - e.view_mut().cursor.col = 0; - press_char(&mut e, 'd'); - press_char(&mut e, 'a'); - press_char(&mut e, 'e'); - let content = e.buffer().to_string(); - assert!(!content.contains("\\begin{itemize}")); - assert!(!content.contains("\\end{itemize}")); - assert!(content.contains("before")); - assert!(content.contains("after")); +fn test_hover_selection_normalized_order() { + // Forward selection + let sel = HoverSelection { + anchor_line: 1, + anchor_col: 3, + active_line: 2, + active_col: 5, + }; + assert_eq!(sel.normalized(), (1, 3, 2, 5)); + // Backward selection + let sel = HoverSelection { + anchor_line: 2, + anchor_col: 5, + active_line: 1, + active_col: 3, + }; + assert_eq!(sel.normalized(), (1, 3, 2, 5)); } #[test] -fn test_latex_environment_object_nested() { - let mut e = latex_engine( - "\\begin{enumerate}\n\\begin{itemize}\nhello\n\\end{itemize}\n\\end{enumerate}\n", - ); - // Cursor inside inner environment - e.view_mut().cursor.line = 2; - e.view_mut().cursor.col = 0; - press_char(&mut e, 'd'); - press_char(&mut e, 'i'); - press_char(&mut e, 'e'); - let content = e.buffer().to_string(); - // Inner environment \begin/\end{itemize} should remain - assert!(content.contains("\\begin{itemize}")); - assert!(content.contains("\\end{itemize}")); - assert!(!content.contains("hello")); +fn test_hover_selection_start_and_extend() { + let mut e = engine_with_text("hello\n"); + e.editor_hover_content + .insert(0, "line one\nline two".to_string()); + e.trigger_editor_hover_at_cursor(); + e.editor_hover_has_focus = true; + assert!(e.editor_hover.is_some()); + + e.editor_hover_start_selection(0, 2); + let sel = e.editor_hover.as_ref().unwrap().selection.as_ref().unwrap(); + assert_eq!(sel.anchor_line, 0); + assert_eq!(sel.anchor_col, 2); + assert_eq!(sel.active_col, 2); + + e.editor_hover_extend_selection(0, 6); + let sel = e.editor_hover.as_ref().unwrap().selection.as_ref().unwrap(); + assert_eq!(sel.active_col, 6); } #[test] -fn test_latex_math_object_inline() { - let mut e = latex_engine("Text $x^2 + y^2$ more\n"); - e.view_mut().cursor.line = 0; - e.view_mut().cursor.col = 7; // inside $...$ - press_char(&mut e, 'd'); - press_char(&mut e, 'i'); - press_char(&mut e, '$'); - let content = e.buffer().to_string(); - assert!(content.contains("$$")); // delimiters remain, content removed - assert!(!content.contains("x^2")); +fn test_hover_copy_all_text_when_no_selection() { + let mut e = engine_with_text("hello\n"); + e.editor_hover_content.insert(0, "copy me".to_string()); + e.trigger_editor_hover_at_cursor(); + e.editor_hover_has_focus = true; + + let copied = std::sync::Arc::new(std::sync::Mutex::new(String::new())); + let copied_clone = copied.clone(); + e.clipboard_write = Some(Box::new(move |text: &str| { + *copied_clone.lock().unwrap() = text.to_string(); + Ok(()) + })); + + e.copy_hover_selection(); + let result = copied.lock().unwrap().clone(); + assert!(result.contains("copy me")); + assert_eq!(e.message, "Hover text copied"); } #[test] -fn test_latex_math_object_around() { - let mut e = latex_engine("Text $x^2$ more\n"); - e.view_mut().cursor.line = 0; - e.view_mut().cursor.col = 6; // inside $...$ - press_char(&mut e, 'd'); - press_char(&mut e, 'a'); - press_char(&mut e, '$'); - let content = e.buffer().to_string(); - assert!(!content.contains("$")); - assert!(content.contains("Text ")); - assert!(content.contains("more")); +fn test_hover_copy_selected_text() { + let mut e = engine_with_text("hello\n"); + e.editor_hover_content + .insert(0, "select this text".to_string()); + e.trigger_editor_hover_at_cursor(); + e.editor_hover_has_focus = true; + + // Start selection on "this" + e.editor_hover_start_selection(0, 7); + e.editor_hover_extend_selection(0, 11); + + let copied = std::sync::Arc::new(std::sync::Mutex::new(String::new())); + let copied_clone = copied.clone(); + e.clipboard_write = Some(Box::new(move |text: &str| { + *copied_clone.lock().unwrap() = text.to_string(); + Ok(()) + })); + + e.copy_hover_selection(); + let result = copied.lock().unwrap().clone(); + assert_eq!(result, "this"); } #[test] -fn test_latex_math_object_display() { - let mut e = latex_engine("Text \\[a + b\\] more\n"); - e.view_mut().cursor.line = 0; - e.view_mut().cursor.col = 8; // inside \[...\] - press_char(&mut e, 'd'); - press_char(&mut e, 'i'); - press_char(&mut e, '$'); - let content = e.buffer().to_string(); - assert!(content.contains("\\[\\]")); // delimiters remain - assert!(!content.contains("a + b")); -} +fn test_hover_y_key_copies() { + let mut e = engine_with_text("hello\n"); + e.editor_hover_content.insert(0, "y copies".to_string()); + e.trigger_editor_hover_at_cursor(); + e.editor_hover_has_focus = true; + + let copied = std::sync::Arc::new(std::sync::Mutex::new(String::new())); + let copied_clone = copied.clone(); + e.clipboard_write = Some(Box::new(move |text: &str| { + *copied_clone.lock().unwrap() = text.to_string(); + Ok(()) + })); -#[test] -fn test_latex_command_object_inner() { - let mut e = latex_engine("\\textbf{hello world}\n"); - e.view_mut().cursor.line = 0; - e.view_mut().cursor.col = 10; // inside braces - press_char(&mut e, 'd'); - press_char(&mut e, 'i'); - press_char(&mut e, 'c'); - let content = e.buffer().to_string(); - assert!(content.contains("\\textbf{}")); - assert!(!content.contains("hello")); + e.handle_editor_hover_key("y", false); + let result = copied.lock().unwrap().clone(); + assert!(result.contains("y copies")); + // Popup should still be open (y doesn't dismiss) + assert!(e.editor_hover.is_some()); } #[test] -fn test_latex_command_object_around() { - let mut e = latex_engine("some \\textbf{hello} text\n"); - e.view_mut().cursor.line = 0; - e.view_mut().cursor.col = 14; // inside braces - press_char(&mut e, 'd'); - press_char(&mut e, 'a'); - press_char(&mut e, 'c'); - let content = e.buffer().to_string(); - assert!(!content.contains("\\textbf")); - assert!(content.contains("some ")); - assert!(content.contains(" text")); +fn test_percent_decode_basic() { + assert_eq!(Engine::percent_decode("hello"), "hello"); + assert_eq!(Engine::percent_decode("hello%20world"), "hello world"); + assert_eq!(Engine::percent_decode("%3F"), "?"); + assert_eq!(Engine::percent_decode("%2F"), "/"); + assert_eq!(Engine::percent_decode("a%2Fb%2Fc"), "a/b/c"); } #[test] -fn test_latex_section_jump_forward() { - let mut e = latex_engine("\\section{One}\ntext\n\\subsection{Two}\nmore\n\\section{Three}\n"); - e.view_mut().cursor.line = 0; - e.view_mut().cursor.col = 0; - // ]] jump to next section - press_char(&mut e, ']'); - press_char(&mut e, ']'); - assert_eq!(e.view().cursor.line, 2); - // Again - press_char(&mut e, ']'); - press_char(&mut e, ']'); - assert_eq!(e.view().cursor.line, 4); +fn test_percent_decode_edge_cases() { + // Incomplete percent encoding left as-is + assert_eq!(Engine::percent_decode("abc%2"), "abc%2"); + assert_eq!(Engine::percent_decode("abc%"), "abc%"); + // Invalid hex digits left as-is + assert_eq!(Engine::percent_decode("%GG"), "%GG"); + // Empty string + assert_eq!(Engine::percent_decode(""), ""); + // Mixed valid and plain text + assert_eq!( + Engine::percent_decode("key%3Dvalue%26other"), + "key=value&other" + ); } #[test] -fn test_latex_section_jump_backward() { - let mut e = latex_engine("\\section{One}\ntext\n\\subsection{Two}\nmore\n\\section{Three}\n"); - e.view_mut().cursor.line = 4; - e.view_mut().cursor.col = 0; - // [[ jump to previous section - press_char(&mut e, '['); - press_char(&mut e, '['); - assert_eq!(e.view().cursor.line, 2); - press_char(&mut e, '['); - press_char(&mut e, '['); - assert_eq!(e.view().cursor.line, 0); +fn test_execute_command_uri_no_prefix() { + let mut e = engine_with_text("hello\n"); + assert!(!e.execute_command_uri("https://example.com")); + assert!(!e.execute_command_uri("")); + assert!(!e.execute_command_uri("notcommand:foo")); } #[test] -fn test_latex_env_jump_forward() { - let mut e = latex_engine( - "\\begin{document}\ntext\n\\begin{itemize}\nitem\n\\end{itemize}\n\\end{document}\n", - ); - e.view_mut().cursor.line = 0; - e.view_mut().cursor.col = 0; - // ]m jump to next \begin - press_char(&mut e, ']'); - press_char(&mut e, 'm'); - assert_eq!(e.view().cursor.line, 2); - assert_eq!(e.view().cursor.col, 0); +fn test_execute_command_uri_empty_name() { + let mut e = engine_with_text("hello\n"); + assert!(!e.execute_command_uri("command:")); + assert!(!e.execute_command_uri("command:?args")); } #[test] -fn test_latex_env_jump_backward() { - let mut e = latex_engine( - "\\begin{document}\ntext\n\\begin{itemize}\nitem\n\\end{itemize}\n\\end{document}\n", - ); - e.view_mut().cursor.line = 4; - e.view_mut().cursor.col = 0; - // [m jump to previous \begin - press_char(&mut e, '['); - press_char(&mut e, 'm'); - assert_eq!(e.view().cursor.line, 2); - assert_eq!(e.view().cursor.col, 0); +fn test_execute_command_uri_unknown_command() { + let mut e = engine_with_text("hello\n"); + // Unknown plugin commands return false, no panic. + assert!(!e.execute_command_uri("command:NonExistent")); + assert!(!e.execute_command_uri("command:NonExistent?arg1")); } +// ── Tab drag-and-drop tests ────────────────────────────────────────────── + #[test] -fn test_latex_env_end_jump_forward() { - let mut e = latex_engine( - "\\begin{document}\ntext\n\\begin{itemize}\nitem\n\\end{itemize}\n\\end{document}\n", - ); - e.view_mut().cursor.line = 0; - e.view_mut().cursor.col = 0; - // ]M jump to next \end - press_char(&mut e, ']'); - press_char(&mut e, 'M'); - assert_eq!(e.view().cursor.line, 4); - assert_eq!(e.view().cursor.col, 0); +fn test_tab_drag_reorder_same_group() { + use crate::core::window::DropZone; + let mut e = engine_with_text("aaa\n"); + e.new_tab(None); + e.buffer_mut().insert(0, "bbb\n"); + e.new_tab(None); + e.buffer_mut().insert(0, "ccc\n"); + // 3 tabs: [aaa, bbb, ccc] — active is tab 2 (ccc) + assert_eq!(e.active_group().tabs.len(), 3); + assert_eq!(e.active_group().active_tab, 2); + + // Drag tab 2 (ccc) to position 0 + let gid = e.active_group; + e.tab_drag_begin(gid, 2); + assert!(e.tab_drag.is_some()); + e.tab_drag_drop(DropZone::TabReorder(gid, 0)); + assert!(e.tab_drag.is_none()); + + // Now order should be [ccc, aaa, bbb], active tab is 0 + assert_eq!(e.active_group().active_tab, 0); + // Verify ccc is first by switching to it and checking content + e.active_group_mut().active_tab = 0; + assert!(e.buffer().to_string().starts_with("ccc")); + e.active_group_mut().active_tab = 1; + assert!(e.buffer().to_string().starts_with("aaa")); + e.active_group_mut().active_tab = 2; + assert!(e.buffer().to_string().starts_with("bbb")); } #[test] -fn test_latex_env_end_jump_backward() { - let mut e = latex_engine( - "\\begin{document}\ntext\n\\begin{itemize}\nitem\n\\end{itemize}\n\\end{document}\n", - ); - e.view_mut().cursor.line = 5; - e.view_mut().cursor.col = 0; - // [M jump to previous \end - press_char(&mut e, '['); - press_char(&mut e, 'M'); - assert_eq!(e.view().cursor.line, 4); +fn test_tab_drag_to_other_group_center() { + use crate::core::window::DropZone; + let mut e = engine_with_text("aaa\n"); + e.new_tab(None); + e.buffer_mut().insert(0, "bbb\n"); + // Group 1 has [aaa, bbb] + let group1 = e.active_group; + assert_eq!(e.active_group().tabs.len(), 2); + + // Create second group via split + e.open_editor_group(SplitDirection::Vertical); + let group2 = e.active_group; + assert_ne!(group1, group2); + e.buffer_mut().insert(0, "ccc\n"); + + // Drag bbb (tab 1 in group1) to group2 center + e.tab_drag_begin(group1, 1); + e.tab_drag_drop(DropZone::Center(group2)); + + // group1 should have 1 tab (aaa), group2 should have 2 tabs + assert_eq!(e.editor_groups.get(&group1).unwrap().tabs.len(), 1); + assert_eq!(e.editor_groups.get(&group2).unwrap().tabs.len(), 2); + // Active group should be group2 + assert_eq!(e.active_group, group2); } #[test] -fn test_latex_section_end_jump() { - let mut e = latex_engine("\\section{One}\ntext\n\\end{document}\nmore\n\\end{other}\n"); - e.view_mut().cursor.line = 0; - // ][ jump to next \end - press_char(&mut e, ']'); - press_char(&mut e, '['); - assert_eq!(e.view().cursor.line, 2); +fn test_tab_drag_to_new_split() { + use crate::core::window::DropZone; + let mut e = engine_with_text("aaa\n"); + e.new_tab(None); + e.buffer_mut().insert(0, "bbb\n"); + let gid = e.active_group; + assert_eq!(e.active_group().tabs.len(), 2); + assert!(e.group_layout.is_single_group()); + + // Drag tab 0 (aaa) to create a new split + e.tab_drag_begin(gid, 0); + e.tab_drag_drop(DropZone::Split(gid, SplitDirection::Vertical, false)); + + // Should now have 2 groups + assert!(!e.group_layout.is_single_group()); + assert_eq!(e.editor_groups.len(), 2); } #[test] -fn test_latex_section_commands_variants() { - let mut e = latex_engine("text\n\\chapter{C}\nmore\n\\paragraph{P}\nend\n"); - e.view_mut().cursor.line = 0; - press_char(&mut e, ']'); - press_char(&mut e, ']'); - assert_eq!(e.view().cursor.line, 1); // \chapter - press_char(&mut e, ']'); - press_char(&mut e, ']'); - assert_eq!(e.view().cursor.line, 3); // \paragraph +fn test_tab_drag_cancel() { + let mut e = engine_with_text("aaa\n"); + e.new_tab(None); + e.buffer_mut().insert(0, "bbb\n"); + let gid = e.active_group; + let tabs_before = e.active_group().tabs.len(); + + e.tab_drag_begin(gid, 0); + assert!(e.tab_drag.is_some()); + e.tab_drag_cancel(); + assert!(e.tab_drag.is_none()); + assert_eq!(e.tab_drag_mouse, None); + assert_eq!(e.tab_drop_zone, DropZone::None); + // No state changed + assert_eq!(e.active_group().tabs.len(), tabs_before); } #[test] -fn test_latex_starred_section() { - let mut e = latex_engine("text\n\\section*{Unnumbered}\nmore\n"); - e.view_mut().cursor.line = 0; - press_char(&mut e, ']'); - press_char(&mut e, ']'); - assert_eq!(e.view().cursor.line, 1); // \section* matches +fn test_tab_drag_last_tab_closes_group() { + use crate::core::window::DropZone; + let mut e = engine_with_text("aaa\n"); + // Create second group with split + e.open_editor_group(SplitDirection::Vertical); + let group2 = e.active_group; + e.buffer_mut().insert(0, "bbb\n"); + + // Find the other group + let group1 = *e.editor_groups.keys().find(|g| **g != group2).unwrap(); + assert_eq!(e.editor_groups.len(), 2); + + // Drag the only tab from group1 to group2 + e.tab_drag_begin(group1, 0); + e.tab_drag_drop(DropZone::Center(group2)); + + // group1 should be closed, only group2 remains + assert_eq!(e.editor_groups.len(), 1); + assert!(e.editor_groups.contains_key(&group2)); + assert!(e.group_layout.is_single_group()); } #[test] -fn test_latex_yank_environment_inner() { - let mut e = latex_engine("\\begin{quote}\nhello\n\\end{quote}\n"); - e.view_mut().cursor.line = 1; - e.view_mut().cursor.col = 0; - press_char(&mut e, 'y'); - press_char(&mut e, 'i'); - press_char(&mut e, 'e'); - // Content should be unchanged - assert!(e.buffer().to_string().contains("hello")); - // Register should contain the inner content - let (text, _) = e.registers.get(&'"').expect("register should be set"); - assert!(text.contains("hello")); - assert!(!text.contains("\\begin")); -} +fn test_tab_drag_drop_none_is_noop() { + use crate::core::window::DropZone; + let mut e = engine_with_text("aaa\n"); + e.new_tab(None); + e.buffer_mut().insert(0, "bbb\n"); + let gid = e.active_group; + let tabs_before = e.active_group().tabs.len(); + let active_before = e.active_group().active_tab; -#[test] -fn test_latex_env_object_not_in_non_latex() { - // ie/ae should do nothing in non-LaTeX buffers - let mut e = engine_with_text("\\begin{test}\nhello\n\\end{test}\n"); - e.view_mut().cursor.line = 1; - press_char(&mut e, 'd'); - press_char(&mut e, 'i'); - press_char(&mut e, 'e'); - // Buffer unchanged — non-LaTeX buffer - assert!(e.buffer().to_string().contains("hello")); + e.tab_drag_begin(gid, 0); + e.tab_drag_drop(DropZone::None); + + // Nothing changed + assert_eq!(e.active_group().tabs.len(), tabs_before); + assert_eq!(e.active_group().active_tab, active_before); } #[test] -fn test_latex_double_dollar_math() { - let mut e = latex_engine("Text $$E=mc^2$$ more\n"); - e.view_mut().cursor.line = 0; - e.view_mut().cursor.col = 8; - press_char(&mut e, 'd'); - press_char(&mut e, 'i'); - press_char(&mut e, '$'); - let content = e.buffer().to_string(); - assert!(content.contains("$$$$")); // delimiters remain - assert!(!content.contains("E=mc")); -} +fn test_tab_drag_reorder_to_other_group_at_index() { + use crate::core::window::DropZone; + let mut e = engine_with_text("aaa\n"); + e.new_tab(None); + e.buffer_mut().insert(0, "bbb\n"); + let group1 = e.active_group; -// ── Panel hover popup tests ───────────────────────────────────────── + // Create second group with 2 tabs + e.open_editor_group(SplitDirection::Vertical); + let group2 = e.active_group; + e.buffer_mut().insert(0, "ccc\n"); + e.new_tab(None); + e.buffer_mut().insert(0, "ddd\n"); + assert_eq!(e.editor_groups.get(&group2).unwrap().tabs.len(), 2); -#[test] -fn test_panel_hover_show_and_dismiss() { - let mut e = engine_with_text("hello\n"); - assert!(e.panel_hover.is_none()); - e.show_panel_hover("test_panel", "item_1", 0, "**Bold** text"); - assert!(e.panel_hover.is_some()); - let ph = e.panel_hover.as_ref().unwrap(); - assert_eq!(ph.panel_name, "test_panel"); - assert_eq!(ph.item_id, "item_1"); - assert_eq!(ph.item_index, 0); - assert!(!ph.rendered.lines.is_empty()); - e.dismiss_panel_hover_now(); - assert!(e.panel_hover.is_none()); -} + // Drag aaa (tab 0 in group1) to group2 at index 1 + e.tab_drag_begin(group1, 0); + e.tab_drag_drop(DropZone::TabReorder(group2, 1)); -#[test] -fn test_panel_hover_links_extracted() { - let mut e = engine_with_text("hello\n"); - e.show_panel_hover("p", "i", 0, "See [docs](https://example.com) here"); - let ph = e.panel_hover.as_ref().unwrap(); - // Should have at least one link extracted from the markdown - assert!( - !ph.links.is_empty() || !ph.rendered.lines.is_empty(), - "hover should have rendered content" - ); + // group1: [bbb], group2: [ccc, aaa, ddd] + assert_eq!(e.editor_groups.get(&group1).unwrap().tabs.len(), 1); + assert_eq!(e.editor_groups.get(&group2).unwrap().tabs.len(), 3); + // Active group should be group2, active tab at insertion index + assert_eq!(e.active_group, group2); + assert_eq!(e.active_group().active_tab, 1); + // Verify the inserted tab has "aaa" content + assert!(e.buffer().to_string().starts_with("aaa")); } #[test] -fn test_panel_hover_mouse_move_dwell_tracking() { - let mut e = engine_with_text("hello\n"); - // First move starts dwell - let changed = e.panel_hover_mouse_move("panel", "item_0", 0); - assert!(changed); - assert!(e.panel_hover_dwell.is_some()); - // Same item should not change - let changed2 = e.panel_hover_mouse_move("panel", "item_0", 0); - assert!(!changed2); - // Different item should change - let changed3 = e.panel_hover_mouse_move("panel", "item_1", 1); - assert!(changed3); +fn test_has_code_actions_on_line_empty() { + let e = engine_with_text("hello\nworld\n"); + // No code actions cached → should return false + assert!(!e.has_code_actions_on_line(0)); + assert!(!e.has_code_actions_on_line(1)); } #[test] -fn test_panel_hover_registry_lookup() { - let mut e = engine_with_text("hello\n"); - // Register hover content for a panel item - e.panel_hover_registry.insert( - ("my_panel".to_string(), "commit_abc".to_string()), - "# Commit abc\n\nSome details".to_string(), +fn test_has_code_actions_on_line_with_actions() { + let mut e = engine_with_text("hello\nworld\n"); + let path = std::path::PathBuf::from("/tmp/test_code_action.rs"); + e.buffer_manager + .get_mut(e.active_window().buffer_id) + .unwrap() + .file_path = Some(path.clone()); + + let mut line_map = HashMap::new(); + line_map.insert( + 0, + vec![lsp::CodeAction { + title: "Extract function".to_string(), + kind: Some("refactor.extract".to_string()), + edit: None, + }], ); - // Start dwell on that item — poll should NOT show yet (need 300ms) - e.panel_hover_mouse_move("my_panel", "commit_abc", 2); - let shown = e.poll_panel_hover(); - assert!(!shown, "should not show before dwell timeout"); + e.lsp_code_actions.insert(path, line_map); + + assert!(e.has_code_actions_on_line(0)); + assert!(!e.has_code_actions_on_line(1)); } #[test] -fn test_panel_hover_dismissed_on_keypress() { +fn test_has_code_actions_empty_vec_returns_false() { let mut e = engine_with_text("hello\n"); - e.show_panel_hover("p", "i", 0, "test"); - assert!(e.panel_hover.is_some()); - // Any key press should dismiss - e.handle_key("j", None, false); - assert!(e.panel_hover.is_none()); + let path = std::path::PathBuf::from("/tmp/test_code_action2.rs"); + e.buffer_manager + .get_mut(e.active_window().buffer_id) + .unwrap() + .file_path = Some(path.clone()); + + let mut line_map = HashMap::new(); + line_map.insert(0, vec![]); + e.lsp_code_actions.insert(path, line_map); + + // Empty vec should not count as having actions + assert!(!e.has_code_actions_on_line(0)); } #[test] -fn test_sc_hover_file_generates_markdown() { +fn test_show_code_actions_popup_no_actions() { let mut e = engine_with_text("hello\n"); - // Populate SC panel with a fake file status - e.sc_file_statuses = vec![crate::core::git::FileStatus { - path: "src/main.rs".to_string(), - staged: None, - unstaged: Some(crate::core::git::StatusKind::Modified), - }]; - e.sc_sections_expanded = [true, true, false, false]; - // flat index: 0=staged header, 1=unstaged header, 2=file item - let md = e.sc_hover_markdown(2); - assert!(md.is_some()); - let md = md.unwrap(); - assert!(md.contains("src/main.rs"), "hover should contain filename"); - assert!(md.contains("Modified"), "hover should contain status"); - assert!( - md.contains("unstaged"), - "hover should indicate staged/unstaged" - ); + let path = std::path::PathBuf::from("/tmp/test_no_actions.rs"); + e.buffer_manager + .get_mut(e.active_window().buffer_id) + .unwrap() + .file_path = Some(path); + e.show_code_actions_popup(); + assert_eq!(e.message, "No code actions available"); } #[test] -fn test_sc_hover_section_header_returns_none_for_non_branch() { - let mut e = engine_with_text("hello\n"); - e.sc_file_statuses = vec![]; - e.sc_sections_expanded = [true, true, false, false]; - // flat index 0 = Staged Changes header (section 0 → branch info) - // flat index 1 = Unstaged Changes header (section 1 → None) - let md = e.sc_hover_markdown(1); - assert!(md.is_none(), "non-branch headers should return None"); +fn test_show_code_actions_hover_opens_dialog() { + let mut e = engine_with_text("hello\nworld\n"); + let actions = vec![ + lsp::CodeAction { + title: "Quick fix".to_string(), + kind: Some("quickfix".to_string()), + edit: None, + }, + lsp::CodeAction { + title: "Extract method".to_string(), + kind: None, + edit: None, + }, + ]; + e.show_code_actions_hover(0, actions); + assert!(e.dialog.is_some()); + assert_ne!(e.message, "No code actions available"); } #[test] -fn test_sc_hover_log_entry_generates_markdown() { +fn test_code_action_cache_cleared_on_edit() { let mut e = engine_with_text("hello\n"); - e.sc_file_statuses = vec![]; - e.sc_log = vec![crate::core::git::GitLogEntry { - hash: "abc1234".to_string(), - message: "feat: add hover popups".to_string(), - }]; - e.sc_sections_expanded = [true, true, false, true]; - // flat indices: 0=staged hdr, 1=unstaged hdr, 2=log hdr, 3=log item - let md = e.sc_hover_markdown(3); - assert!(md.is_some()); - let md = md.unwrap(); - assert!( - md.contains("abc1234") || md.contains("hover popups"), - "log hover should contain hash or message" + let path = std::path::PathBuf::from("/tmp/test_cache_clear.rs"); + e.buffer_manager + .get_mut(e.active_window().buffer_id) + .unwrap() + .file_path = Some(path.clone()); + + let mut line_map = HashMap::new(); + line_map.insert( + 0, + vec![lsp::CodeAction { + title: "Fix".to_string(), + kind: None, + edit: None, + }], ); -} + e.lsp_code_actions.insert(path.clone(), line_map); + assert!(e.has_code_actions_on_line(0)); -#[test] -fn test_is_safe_url_allows_https() { - assert!(is_safe_url("https://github.com/user/repo")); - assert!(is_safe_url("http://example.com")); - assert!(is_safe_url("HTTPS://EXAMPLE.COM")); + // Simulate removing cache (as happens on didChange) + e.lsp_code_actions.remove(&path); + assert!(!e.has_code_actions_on_line(0)); } +// ── Explorer indicators tests ────────────────────────────────────────── + #[test] -fn test_is_safe_url_rejects_dangerous_schemes() { - assert!(!is_safe_url("javascript:alert(1)")); - assert!(!is_safe_url("file:///etc/passwd")); - assert!(!is_safe_url("data:text/html,

hi

")); - assert!(!is_safe_url("ftp://example.com")); - assert!(!is_safe_url("ssh://evil.com")); - assert!(!is_safe_url("")); +fn test_explorer_indicators_empty() { + let e = Engine::new(); + let (git_st, diags) = e.explorer_indicators(); + assert!(git_st.is_empty()); + assert!(diags.is_empty()); } #[test] -fn test_hover_links_filtered_by_safe_url() { - let mut e = engine_with_text("hello\n"); - // Markdown with a safe link and a dangerous link - e.show_panel_hover( - "ext_panel", - "i", - 0, - "Safe: [click](https://example.com) Evil: [hack](javascript:alert(1))", - ); - let ph = e.panel_hover.as_ref().unwrap(); - // Only the https link should be in the links list - for link in &ph.links { - assert!( - is_safe_url(&link.3), - "unsafe URL should have been filtered: {}", - link.3 - ); - } +fn test_explorer_indicators_git_status() { + let mut e = Engine::new(); + // Simulate git status with modified and untracked files + e.cwd = PathBuf::from("/tmp/vimcode_test_git_ind"); + e.sc_file_statuses = vec![ + git::FileStatus { + path: "src/main.rs".to_string(), + staged: None, + unstaged: Some(git::StatusKind::Modified), + }, + git::FileStatus { + path: "new_file.txt".to_string(), + staged: None, + unstaged: Some(git::StatusKind::Untracked), + }, + ]; + let (git_st, _) = e.explorer_indicators(); + // Git status uses repo root which we can't easily mock, so just + // verify the function doesn't panic and returns reasonable results + // (actual path matching depends on find_repo_root returning our cwd) + assert!(git_st.len() <= 2); } #[test] -fn test_hover_native_panel_is_native() { - let mut e = engine_with_text("hello\n"); - e.show_panel_hover("source_control", "", 0, "# test"); - assert!(e.panel_hover.as_ref().unwrap().is_native()); - e.show_panel_hover("my_ext_panel", "", 0, "# test"); - assert!(!e.panel_hover.as_ref().unwrap().is_native()); +fn test_explorer_indicators_diagnostics() { + let mut e = Engine::new(); + let path = PathBuf::from("/tmp/vimcode_test_diag_indicator.rs"); + e.lsp_diagnostics.insert( + path.clone(), + vec![ + lsp::Diagnostic { + range: lsp::LspRange { + start: lsp::LspPosition { + line: 0, + character: 0, + }, + end: lsp::LspPosition { + line: 0, + character: 5, + }, + }, + severity: lsp::DiagnosticSeverity::Error, + message: "error".to_string(), + source: None, + code: None, + }, + lsp::Diagnostic { + range: lsp::LspRange { + start: lsp::LspPosition { + line: 1, + character: 0, + }, + end: lsp::LspPosition { + line: 1, + character: 5, + }, + }, + severity: lsp::DiagnosticSeverity::Warning, + message: "warning".to_string(), + source: None, + code: None, + }, + lsp::Diagnostic { + range: lsp::LspRange { + start: lsp::LspPosition { + line: 2, + character: 0, + }, + end: lsp::LspPosition { + line: 2, + character: 5, + }, + }, + severity: lsp::DiagnosticSeverity::Error, + message: "another error".to_string(), + source: None, + code: None, + }, + lsp::Diagnostic { + range: lsp::LspRange { + start: lsp::LspPosition { + line: 3, + character: 0, + }, + end: lsp::LspPosition { + line: 3, + character: 5, + }, + }, + severity: lsp::DiagnosticSeverity::Information, + message: "info".to_string(), + source: None, + code: None, + }, + ], + ); + let (_, diags) = e.explorer_indicators(); + let counts = diags.get(&path).expect("should have diag entry"); + assert_eq!(counts.0, 2, "expected 2 errors"); + assert_eq!(counts.1, 1, "expected 1 warning"); } +// ── hide_single_tab setting tests ────────────────────────────────────────── + #[test] -fn test_hover_selection_extract_single_line() { - let sel = HoverSelection { - anchor_line: 0, - anchor_col: 2, - active_line: 0, - active_col: 7, - }; - let lines = vec!["hello world".to_string()]; - assert_eq!(sel.extract_text(&lines), "llo w"); +fn test_hide_single_tab_default_off() { + let engine = Engine::new(); + assert!(!engine.settings.hide_single_tab); + assert!(!engine.is_tab_bar_hidden(engine.active_group)); } #[test] -fn test_hover_selection_extract_multi_line() { - let sel = HoverSelection { - anchor_line: 0, - anchor_col: 3, - active_line: 2, - active_col: 4, - }; - let lines = vec![ - "first line".to_string(), - "second line".to_string(), - "third line".to_string(), - ]; - assert_eq!(sel.extract_text(&lines), "st line\nsecond line\nthir"); +fn test_hide_single_tab_one_tab() { + let mut engine = Engine::new(); + engine.settings.hide_single_tab = true; + // Single tab → tab bar should be hidden + assert!(engine.is_tab_bar_hidden(engine.active_group)); } #[test] -fn test_hover_selection_normalized_order() { - // Forward selection - let sel = HoverSelection { - anchor_line: 1, - anchor_col: 3, - active_line: 2, - active_col: 5, - }; - assert_eq!(sel.normalized(), (1, 3, 2, 5)); - // Backward selection - let sel = HoverSelection { - anchor_line: 2, - anchor_col: 5, - active_line: 1, - active_col: 3, - }; - assert_eq!(sel.normalized(), (1, 3, 2, 5)); +fn test_hide_single_tab_two_tabs() { + let mut engine = Engine::new(); + engine.settings.hide_single_tab = true; + // Open a second tab + let dir = std::env::temp_dir().join("vimcode_test_hst_twotabs"); + let _ = std::fs::create_dir_all(&dir); + let f = dir.join("a.txt"); + std::fs::write(&f, "hello").unwrap(); + engine.open_file_in_tab(&f); + assert!( + engine.active_group().tabs.len() >= 2, + "should have at least 2 tabs" + ); + // Two tabs → tab bar should NOT be hidden + assert!(!engine.is_tab_bar_hidden(engine.active_group)); + let _ = std::fs::remove_dir_all(&dir); } #[test] -fn test_hover_selection_start_and_extend() { - let mut e = engine_with_text("hello\n"); - e.editor_hover_content - .insert(0, "line one\nline two".to_string()); - e.trigger_editor_hover_at_cursor(); - e.editor_hover_has_focus = true; - assert!(e.editor_hover.is_some()); +fn test_hide_single_tab_window_rects_reclaim_space() { + let mut engine = Engine::new(); + engine.settings.breadcrumbs = false; + let content = WindowRect::new(0.0, 0.0, 80.0, 24.0); + let tab_bar_h = 1.0; - e.editor_hover_start_selection(0, 2); - let sel = e.editor_hover.as_ref().unwrap().selection.as_ref().unwrap(); - assert_eq!(sel.anchor_line, 0); - assert_eq!(sel.anchor_col, 2); - assert_eq!(sel.active_col, 2); + // Without hide: content starts at y=1 (tab bar takes 1 row) + engine.settings.hide_single_tab = false; + let (rects_show, _) = engine.calculate_group_window_rects(content, tab_bar_h); + assert!(!rects_show.is_empty()); + let y_show = rects_show[0].1.y; + let h_show = rects_show[0].1.height; - e.editor_hover_extend_selection(0, 6); - let sel = e.editor_hover.as_ref().unwrap().selection.as_ref().unwrap(); - assert_eq!(sel.active_col, 6); -} + // With hide: content should start 1 row earlier and be 1 row taller + engine.settings.hide_single_tab = true; + let (rects_hide, _) = engine.calculate_group_window_rects(content, tab_bar_h); + assert!(!rects_hide.is_empty()); + let y_hide = rects_hide[0].1.y; + let h_hide = rects_hide[0].1.height; -#[test] -fn test_hover_copy_all_text_when_no_selection() { - let mut e = engine_with_text("hello\n"); - e.editor_hover_content.insert(0, "copy me".to_string()); - e.trigger_editor_hover_at_cursor(); - e.editor_hover_has_focus = true; + assert!( + y_hide < y_show, + "hidden tab bar should start higher: {} vs {}", + y_hide, + y_show + ); + assert!( + h_hide > h_show, + "hidden tab bar should give more height: {} vs {}", + h_hide, + h_show + ); + assert!( + (y_show - y_hide - 1.0).abs() < 0.01, + "should gain exactly 1 row: y_show={}, y_hide={}, diff={}", + y_show, + y_hide, + y_show - y_hide + ); +} - let copied = std::sync::Arc::new(std::sync::Mutex::new(String::new())); - let copied_clone = copied.clone(); - e.clipboard_write = Some(Box::new(move |text: &str| { - *copied_clone.lock().unwrap() = text.to_string(); - Ok(()) - })); +#[test] +fn test_hide_single_tab_with_breadcrumbs() { + let mut engine = Engine::new(); + engine.settings.breadcrumbs = true; + engine.settings.hide_single_tab = true; + let content = WindowRect::new(0.0, 0.0, 80.0, 24.0); + let tab_bar_h = 2.0; // tab + breadcrumb - e.copy_hover_selection(); - let result = copied.lock().unwrap().clone(); - assert!(result.contains("copy me")); - assert_eq!(e.message, "Hover text copied"); + let (rects, _) = engine.calculate_group_window_rects(content, tab_bar_h); + assert!(!rects.is_empty()); + // Should reclaim 1 row (tab only), breadcrumb stays → y = 1.0 + let y = rects[0].1.y; + assert!( + (y - 1.0).abs() < 0.01, + "with breadcrumbs, content should start at y=1 (breadcrumb row): got {}", + y + ); } #[test] -fn test_hover_copy_selected_text() { - let mut e = engine_with_text("hello\n"); - e.editor_hover_content - .insert(0, "select this text".to_string()); - e.trigger_editor_hover_at_cursor(); - e.editor_hover_has_focus = true; +fn test_hide_single_tab_multi_group() { + let mut engine = Engine::new(); + engine.settings.hide_single_tab = true; - // Start selection on "this" - e.editor_hover_start_selection(0, 7); - e.editor_hover_extend_selection(0, 11); + // Create a second group (split) + engine.open_editor_group(SplitDirection::Vertical); + let groups = engine.group_layout.group_ids(); + assert_eq!(groups.len(), 2); - let copied = std::sync::Arc::new(std::sync::Mutex::new(String::new())); - let copied_clone = copied.clone(); - e.clipboard_write = Some(Box::new(move |text: &str| { - *copied_clone.lock().unwrap() = text.to_string(); - Ok(()) - })); + // Multi-group mode: tab bars always visible so users can distinguish groups + for &gid in &groups { + assert!( + !engine.is_tab_bar_hidden(gid), + "multi-group should always show tab bars" + ); + } - e.copy_hover_selection(); - let result = copied.lock().unwrap().clone(); - assert_eq!(result, "this"); + // Even after opening a second tab, still visible (multi-group) + let dir = std::env::temp_dir().join("vimcode_test_hst_multigroup"); + let _ = std::fs::create_dir_all(&dir); + let f = dir.join("b.txt"); + std::fs::write(&f, "world").unwrap(); + engine.open_file_in_tab(&f); + for &gid in &groups { + assert!( + !engine.is_tab_bar_hidden(gid), + "multi-group should always show tab bars" + ); + } + let _ = std::fs::remove_dir_all(&dir); } #[test] -fn test_hover_y_key_copies() { - let mut e = engine_with_text("hello\n"); - e.editor_hover_content.insert(0, "y copies".to_string()); - e.trigger_editor_hover_at_cursor(); - e.editor_hover_has_focus = true; - - let copied = std::sync::Arc::new(std::sync::Mutex::new(String::new())); - let copied_clone = copied.clone(); - e.clipboard_write = Some(Box::new(move |text: &str| { - *copied_clone.lock().unwrap() = text.to_string(); - Ok(()) - })); +fn test_hide_single_tab_transition_one_to_two() { + let mut engine = Engine::new(); + engine.settings.breadcrumbs = false; + engine.settings.hide_single_tab = true; + let content = WindowRect::new(0.0, 0.0, 80.0, 24.0); + let tab_bar_h = 1.0; - e.handle_editor_hover_key("y", false); - let result = copied.lock().unwrap().clone(); - assert!(result.contains("y copies")); - // Popup should still be open (y doesn't dismiss) - assert!(e.editor_hover.is_some()); -} + // 1 tab: window starts at y=0 (tab bar hidden, space reclaimed) + let (rects1, _) = engine.calculate_group_window_rects(content, tab_bar_h); + assert_eq!(rects1[0].1.y, 0.0, "1 tab: window should start at y=0"); + assert_eq!( + rects1[0].1.height, 24.0, + "1 tab: window should use full height" + ); -#[test] -fn test_percent_decode_basic() { - assert_eq!(Engine::percent_decode("hello"), "hello"); - assert_eq!(Engine::percent_decode("hello%20world"), "hello world"); - assert_eq!(Engine::percent_decode("%3F"), "?"); - assert_eq!(Engine::percent_decode("%2F"), "/"); - assert_eq!(Engine::percent_decode("a%2Fb%2Fc"), "a/b/c"); -} + // Open a second tab + let dir = std::env::temp_dir().join("vimcode_test_hst_transition"); + let _ = std::fs::create_dir_all(&dir); + let f = dir.join("x.txt"); + std::fs::write(&f, "test").unwrap(); + engine.open_file_in_tab(&f); + assert!(engine.active_group().tabs.len() >= 2); -#[test] -fn test_percent_decode_edge_cases() { - // Incomplete percent encoding left as-is - assert_eq!(Engine::percent_decode("abc%2"), "abc%2"); - assert_eq!(Engine::percent_decode("abc%"), "abc%"); - // Invalid hex digits left as-is - assert_eq!(Engine::percent_decode("%GG"), "%GG"); - // Empty string - assert_eq!(Engine::percent_decode(""), ""); - // Mixed valid and plain text + // 2 tabs: window starts at y=1 (tab bar visible, takes 1 row) + let (rects2, _) = engine.calculate_group_window_rects(content, tab_bar_h); + assert_eq!(rects2[0].1.y, 1.0, "2 tabs: window should start at y=1"); assert_eq!( - Engine::percent_decode("key%3Dvalue%26other"), - "key=value&other" + rects2[0].1.height, 23.0, + "2 tabs: window should leave room for tab bar" + ); + assert!( + !engine.is_tab_bar_hidden(engine.active_group), + "2 tabs: tab bar should be visible" ); + let _ = std::fs::remove_dir_all(&dir); } #[test] -fn test_execute_command_uri_no_prefix() { - let mut e = engine_with_text("hello\n"); - assert!(!e.execute_command_uri("https://example.com")); - assert!(!e.execute_command_uri("")); - assert!(!e.execute_command_uri("notcommand:foo")); +fn test_set_hide_single_tab() { + let mut engine = Engine::new(); + assert!(!engine.settings.hide_single_tab); + engine.settings.parse_set_option("hidesingletab").unwrap(); + assert!(engine.settings.hide_single_tab); + engine.settings.parse_set_option("nohidesingletab").unwrap(); + assert!(!engine.settings.hide_single_tab); + engine.settings.parse_set_option("hst").unwrap(); + assert!(engine.settings.hide_single_tab); } +// ========================================================================== +// Sidebar focus consolidation tests +// ========================================================================== + #[test] -fn test_execute_command_uri_empty_name() { - let mut e = engine_with_text("hello\n"); - assert!(!e.execute_command_uri("command:")); - assert!(!e.execute_command_uri("command:?args")); +fn test_sidebar_has_focus_initially_false() { + let engine = Engine::new(); + assert!(!engine.explorer_has_focus); + assert!(!engine.search_has_focus); + assert!(!engine.sidebar_has_focus()); } #[test] -fn test_execute_command_uri_unknown_command() { - let mut e = engine_with_text("hello\n"); - // Unknown plugin commands return false, no panic. - assert!(!e.execute_command_uri("command:NonExistent")); - assert!(!e.execute_command_uri("command:NonExistent?arg1")); +fn test_sidebar_has_focus_explorer() { + let mut engine = Engine::new(); + engine.explorer_has_focus = true; + assert!(engine.sidebar_has_focus()); } -// ── Tab drag-and-drop tests ────────────────────────────────────────────── - #[test] -fn test_tab_drag_reorder_same_group() { - use crate::core::window::DropZone; - let mut e = engine_with_text("aaa\n"); - e.new_tab(None); - e.buffer_mut().insert(0, "bbb\n"); - e.new_tab(None); - e.buffer_mut().insert(0, "ccc\n"); - // 3 tabs: [aaa, bbb, ccc] — active is tab 2 (ccc) - assert_eq!(e.active_group().tabs.len(), 3); - assert_eq!(e.active_group().active_tab, 2); - - // Drag tab 2 (ccc) to position 0 - let gid = e.active_group; - e.tab_drag_begin(gid, 2); - assert!(e.tab_drag.is_some()); - e.tab_drag_drop(DropZone::TabReorder(gid, 0)); - assert!(e.tab_drag.is_none()); - - // Now order should be [ccc, aaa, bbb], active tab is 0 - assert_eq!(e.active_group().active_tab, 0); - // Verify ccc is first by switching to it and checking content - e.active_group_mut().active_tab = 0; - assert!(e.buffer().to_string().starts_with("ccc")); - e.active_group_mut().active_tab = 1; - assert!(e.buffer().to_string().starts_with("aaa")); - e.active_group_mut().active_tab = 2; - assert!(e.buffer().to_string().starts_with("bbb")); +fn test_sidebar_has_focus_search() { + let mut engine = Engine::new(); + engine.search_has_focus = true; + assert!(engine.sidebar_has_focus()); } #[test] -fn test_tab_drag_to_other_group_center() { - use crate::core::window::DropZone; - let mut e = engine_with_text("aaa\n"); - e.new_tab(None); - e.buffer_mut().insert(0, "bbb\n"); - // Group 1 has [aaa, bbb] - let group1 = e.active_group; - assert_eq!(e.active_group().tabs.len(), 2); +fn test_sidebar_has_focus_aggregates_all_panels() { + let mut engine = Engine::new(); + assert!(!engine.sidebar_has_focus()); - // Create second group via split - e.open_editor_group(SplitDirection::Vertical); - let group2 = e.active_group; - assert_ne!(group1, group2); - e.buffer_mut().insert(0, "ccc\n"); + engine.sc_has_focus = true; + assert!(engine.sidebar_has_focus()); + engine.sc_has_focus = false; - // Drag bbb (tab 1 in group1) to group2 center - e.tab_drag_begin(group1, 1); - e.tab_drag_drop(DropZone::Center(group2)); + engine.dap_sidebar_has_focus = true; + assert!(engine.sidebar_has_focus()); + engine.dap_sidebar_has_focus = false; - // group1 should have 1 tab (aaa), group2 should have 2 tabs - assert_eq!(e.editor_groups.get(&group1).unwrap().tabs.len(), 1); - assert_eq!(e.editor_groups.get(&group2).unwrap().tabs.len(), 2); - // Active group should be group2 - assert_eq!(e.active_group, group2); -} + engine.ext_sidebar_has_focus = true; + assert!(engine.sidebar_has_focus()); + engine.ext_sidebar_has_focus = false; -#[test] -fn test_tab_drag_to_new_split() { - use crate::core::window::DropZone; - let mut e = engine_with_text("aaa\n"); - e.new_tab(None); - e.buffer_mut().insert(0, "bbb\n"); - let gid = e.active_group; - assert_eq!(e.active_group().tabs.len(), 2); - assert!(e.group_layout.is_single_group()); + engine.ai_has_focus = true; + assert!(engine.sidebar_has_focus()); + engine.ai_has_focus = false; - // Drag tab 0 (aaa) to create a new split - e.tab_drag_begin(gid, 0); - e.tab_drag_drop(DropZone::Split(gid, SplitDirection::Vertical, false)); + engine.settings_has_focus = true; + assert!(engine.sidebar_has_focus()); + engine.settings_has_focus = false; - // Should now have 2 groups - assert!(!e.group_layout.is_single_group()); - assert_eq!(e.editor_groups.len(), 2); + engine.ext_panel_has_focus = true; + assert!(engine.sidebar_has_focus()); + engine.ext_panel_has_focus = false; + + assert!(!engine.sidebar_has_focus()); } #[test] -fn test_tab_drag_cancel() { - let mut e = engine_with_text("aaa\n"); - e.new_tab(None); - e.buffer_mut().insert(0, "bbb\n"); - let gid = e.active_group; - let tabs_before = e.active_group().tabs.len(); +fn test_clear_sidebar_focus() { + let mut engine = Engine::new(); + engine.explorer_has_focus = true; + engine.search_has_focus = true; + engine.sc_has_focus = true; + engine.dap_sidebar_has_focus = true; + engine.ext_sidebar_has_focus = true; + engine.ai_has_focus = true; + engine.settings_has_focus = true; + engine.ext_panel_has_focus = true; + assert!(engine.sidebar_has_focus()); - e.tab_drag_begin(gid, 0); - assert!(e.tab_drag.is_some()); - e.tab_drag_cancel(); - assert!(e.tab_drag.is_none()); - assert_eq!(e.tab_drag_mouse, None); - assert_eq!(e.tab_drop_zone, DropZone::None); - // No state changed - assert_eq!(e.active_group().tabs.len(), tabs_before); + engine.clear_sidebar_focus(); + assert!(!engine.sidebar_has_focus()); + assert!(!engine.explorer_has_focus); + assert!(!engine.search_has_focus); + assert!(!engine.sc_has_focus); + assert!(!engine.dap_sidebar_has_focus); + assert!(!engine.ext_sidebar_has_focus); + assert!(!engine.ai_has_focus); + assert!(!engine.settings_has_focus); + assert!(!engine.ext_panel_has_focus); } #[test] -fn test_tab_drag_last_tab_closes_group() { - use crate::core::window::DropZone; - let mut e = engine_with_text("aaa\n"); - // Create second group with split - e.open_editor_group(SplitDirection::Vertical); - let group2 = e.active_group; - e.buffer_mut().insert(0, "bbb\n"); - - // Find the other group - let group1 = *e.editor_groups.keys().find(|g| **g != group2).unwrap(); - assert_eq!(e.editor_groups.len(), 2); - - // Drag the only tab from group1 to group2 - e.tab_drag_begin(group1, 0); - e.tab_drag_drop(DropZone::Center(group2)); +fn test_explorer_focus_blocks_normal_keys() { + let mut engine = engine_with_text("hello world\n"); + engine.explorer_has_focus = true; - // group1 should be closed, only group2 remains - assert_eq!(e.editor_groups.len(), 1); - assert!(e.editor_groups.contains_key(&group2)); - assert!(e.group_layout.is_single_group()); + // 'x' should NOT delete when explorer has focus + engine.handle_key("x", Some('x'), false); + assert_eq!(engine.buffer().to_string(), "hello world\n"); + assert_eq!(engine.cursor().col, 0); } #[test] -fn test_tab_drag_drop_none_is_noop() { - use crate::core::window::DropZone; - let mut e = engine_with_text("aaa\n"); - e.new_tab(None); - e.buffer_mut().insert(0, "bbb\n"); - let gid = e.active_group; - let tabs_before = e.active_group().tabs.len(); - let active_before = e.active_group().active_tab; - - e.tab_drag_begin(gid, 0); - e.tab_drag_drop(DropZone::None); +fn test_search_focus_blocks_normal_keys() { + let mut engine = engine_with_text("hello world\n"); + engine.search_has_focus = true; - // Nothing changed - assert_eq!(e.active_group().tabs.len(), tabs_before); - assert_eq!(e.active_group().active_tab, active_before); + // 'x' should NOT delete when search has focus + engine.handle_key("x", Some('x'), false); + assert_eq!(engine.buffer().to_string(), "hello world\n"); } #[test] -fn test_tab_drag_reorder_to_other_group_at_index() { - use crate::core::window::DropZone; - let mut e = engine_with_text("aaa\n"); - e.new_tab(None); - e.buffer_mut().insert(0, "bbb\n"); - let group1 = e.active_group; +fn test_unfocused_sidebar_allows_normal_keys() { + let mut engine = engine_with_text("hello world\n"); + assert!(!engine.explorer_has_focus); + assert!(!engine.search_has_focus); - // Create second group with 2 tabs - e.open_editor_group(SplitDirection::Vertical); - let group2 = e.active_group; - e.buffer_mut().insert(0, "ccc\n"); - e.new_tab(None); - e.buffer_mut().insert(0, "ddd\n"); - assert_eq!(e.editor_groups.get(&group2).unwrap().tabs.len(), 2); + // 'x' should delete a character when sidebar is not focused + engine.handle_key("x", Some('x'), false); + assert_eq!(engine.buffer().to_string(), "ello world\n"); +} - // Drag aaa (tab 0 in group1) to group2 at index 1 - e.tab_drag_begin(group1, 0); - e.tab_drag_drop(DropZone::TabReorder(group2, 1)); +// ─── Tab scroll offset tests ────────────────────────────────────────────── - // group1: [bbb], group2: [ccc, aaa, ddd] - assert_eq!(e.editor_groups.get(&group1).unwrap().tabs.len(), 1); - assert_eq!(e.editor_groups.get(&group2).unwrap().tabs.len(), 3); - // Active group should be group2, active tab at insertion index - assert_eq!(e.active_group, group2); - assert_eq!(e.active_group().active_tab, 1); - // Verify the inserted tab has "aaa" content - assert!(e.buffer().to_string().starts_with("aaa")); +#[test] +fn test_tab_scroll_offset_initial() { + let mut engine = Engine::new(); + assert_eq!(engine.active_group().tab_scroll_offset, 0); + engine.new_tab(None); + // After opening a new tab, the offset should still allow the active tab to be visible. + // With only 2 tabs, offset should be 0 (no scrolling needed). + assert_eq!(engine.active_group().tab_scroll_offset, 0); } #[test] -fn test_has_code_actions_on_line_empty() { - let e = engine_with_text("hello\nworld\n"); - // No code actions cached → should return false - assert!(!e.has_code_actions_on_line(0)); - assert!(!e.has_code_actions_on_line(1)); +fn test_tab_scroll_offset_ensure_active_visible() { + let mut engine = Engine::new(); + // Open 10 tabs. + for _ in 0..9 { + engine.new_tab(None); + } + assert_eq!(engine.active_group().tabs.len(), 10); + assert_eq!(engine.active_group().active_tab, 9); + // Manually set scroll offset way past the active tab. + engine.active_group_mut().tab_scroll_offset = 15; + engine.ensure_active_tab_visible(); + // Offset should be clamped to valid range (at most total - 1 = 9). + assert!(engine.active_group().tab_scroll_offset <= 9); } #[test] -fn test_has_code_actions_on_line_with_actions() { - let mut e = engine_with_text("hello\nworld\n"); - let path = std::path::PathBuf::from("/tmp/test_code_action.rs"); - e.buffer_manager - .get_mut(e.active_window().buffer_id) - .unwrap() - .file_path = Some(path.clone()); +fn test_tab_scroll_offset_scrolls_back_on_prev() { + let mut engine = Engine::new(); + for _ in 0..5 { + engine.new_tab(None); + } + // Manually set offset to 3 (simulating many tabs offscreen). + engine.active_group_mut().tab_scroll_offset = 3; + // Active tab is 5 (last one opened). + assert_eq!(engine.active_group().active_tab, 5); + // Go to tab 0 — ensure offset scrolls back to show tab 0. + engine.goto_tab(0); + assert_eq!(engine.active_group().active_tab, 0); + assert_eq!( + engine.active_group().tab_scroll_offset, + 0, + "Offset should scroll back to 0 when navigating to first tab" + ); +} - let mut line_map = HashMap::new(); - line_map.insert( +#[test] +fn test_tab_scroll_offset_adjusted_on_close() { + let mut engine = Engine::new(); + for _ in 0..5 { + engine.new_tab(None); + } + assert_eq!(engine.active_group().tabs.len(), 6); + // Set a scroll offset. + engine.active_group_mut().tab_scroll_offset = 3; + engine.goto_tab(5); + // Close tabs until offset becomes too large. + engine.close_tab(); + // After close, offset should still be valid. + assert!(engine.active_group().tab_scroll_offset < engine.active_group().tabs.len()); +} + +#[test] +fn test_tab_scroll_offset_next_prev_cycle() { + let mut engine = Engine::new(); + for _ in 0..3 { + engine.new_tab(None); + } + // Set offset to 2. + engine.active_group_mut().tab_scroll_offset = 2; + engine.goto_tab(3); + // Cycle with next_tab (wraps to 0). + engine.next_tab(); + assert_eq!(engine.active_group().active_tab, 0); + assert_eq!( + engine.active_group().tab_scroll_offset, 0, - vec![lsp::CodeAction { - title: "Extract function".to_string(), - kind: Some("refactor.extract".to_string()), - edit: None, - }], + "Offset should scroll to 0 when wrapping to first tab" ); - e.lsp_code_actions.insert(path, line_map); +} - assert!(e.has_code_actions_on_line(0)); - assert!(!e.has_code_actions_on_line(1)); +#[test] +fn test_tab_scroll_offset_new_tab_always_visible() { + let mut engine = Engine::new(); + for _ in 0..10 { + engine.new_tab(None); + } + // New tab is always the last one and is always active. + assert_eq!(engine.active_group().active_tab, 10); + // The scroll offset should not be past the active tab. + assert!(engine.active_group().tab_scroll_offset <= engine.active_group().active_tab); +} + +#[test] +fn test_tab_nav_history_basic() { + let mut engine = Engine::new(); + // History seeded with initial tab — arrows greyed out. + assert!(!engine.tab_nav_can_go_back()); + assert!(!engine.tab_nav_can_go_forward()); + engine.new_tab(None); // tab 1 + engine.new_tab(None); // tab 2 + // new_tab does NOT push nav history — arrows still greyed out. + assert!(!engine.tab_nav_can_go_back()); + // First explicit navigation: seed entry (tab 0) + new entry (tab 1). + engine.goto_tab(1); // push: tab 1 (seed tab 0 already in history) + assert!(engine.tab_nav_can_go_back()); + engine.goto_tab(2); // push: tab 2 + assert!(engine.tab_nav_can_go_back()); + assert!(!engine.tab_nav_can_go_forward()); + // Go back to tab 1 + engine.tab_nav_back(); + assert_eq!(engine.active_group().active_tab, 1); + assert!(engine.tab_nav_can_go_forward()); + // Go back to tab 0 (the seed entry) + engine.tab_nav_back(); + assert_eq!(engine.active_group().active_tab, 0); + // Go forward + engine.tab_nav_forward(); + assert_eq!(engine.active_group().active_tab, 1); + engine.tab_nav_forward(); + assert_eq!(engine.active_group().active_tab, 2); } #[test] -fn test_has_code_actions_empty_vec_returns_false() { - let mut e = engine_with_text("hello\n"); - let path = std::path::PathBuf::from("/tmp/test_code_action2.rs"); - e.buffer_manager - .get_mut(e.active_window().buffer_id) - .unwrap() - .file_path = Some(path.clone()); +fn test_tab_nav_history_dedup() { + let mut engine = Engine::new(); + engine.new_tab(None); // tab 1 + // Switch to tab 0 multiple times via goto_tab + engine.goto_tab(0); + engine.goto_tab(0); // consecutive duplicate + // Consecutive dedup: switching to same tab shouldn't add extra entries + let hist_len = engine.tab_nav_history.len(); + engine.goto_tab(0); // same tab again + assert_eq!(engine.tab_nav_history.len(), hist_len); +} - let mut line_map = HashMap::new(); - line_map.insert(0, vec![]); - e.lsp_code_actions.insert(path, line_map); +#[test] +fn test_tab_nav_forward_truncation() { + let mut engine = Engine::new(); + engine.new_tab(None); // tab 1 + engine.new_tab(None); // tab 2 + // Build history via explicit navigation. + engine.goto_tab(0); + engine.goto_tab(1); + engine.goto_tab(2); + // Go back twice + engine.tab_nav_back(); + engine.tab_nav_back(); + // Should be able to go forward + assert!(engine.tab_nav_can_go_forward()); + // Navigate to a different tab — forward history should be truncated + engine.goto_tab(2); + assert!(!engine.tab_nav_can_go_forward()); +} - // Empty vec should not count as having actions - assert!(!e.has_code_actions_on_line(0)); +#[test] +fn test_tab_nav_close_cleanup() { + let mut engine = Engine::new(); + engine.new_tab(None); // tab 1 + engine.new_tab(None); // tab 2 + // Build history via navigation. + engine.goto_tab(0); + engine.goto_tab(1); + engine.goto_tab(2); + // Go back + engine.tab_nav_back(); + assert_eq!(engine.active_group().active_tab, 1); + // Close current tab — history entries for this tab should be removed + engine.close_tab(); + // Should still be able to navigate without crashing + if engine.tab_nav_can_go_back() { + engine.tab_nav_back(); + } } #[test] -fn test_show_code_actions_popup_no_actions() { - let mut e = engine_with_text("hello\n"); - let path = std::path::PathBuf::from("/tmp/test_no_actions.rs"); - e.buffer_manager - .get_mut(e.active_window().buffer_id) - .unwrap() - .file_path = Some(path); - e.show_code_actions_popup(); - assert_eq!(e.message, "No code actions available"); +fn test_tab_nav_bounded() { + let mut engine = Engine::new(); + // Create many tabs and navigate to each one to build history. + for _ in 0..120 { + engine.new_tab(None); + } + // Navigate to each tab to build up history. + for i in 0..120 { + engine.goto_tab(i); + } + assert!(engine.tab_nav_history.len() <= 100); } #[test] -fn test_show_code_actions_hover_opens_dialog() { - let mut e = engine_with_text("hello\nworld\n"); - let actions = vec![ - lsp::CodeAction { - title: "Quick fix".to_string(), - kind: Some("quickfix".to_string()), - edit: None, - }, - lsp::CodeAction { - title: "Extract method".to_string(), - kind: None, - edit: None, - }, - ]; - e.show_code_actions_hover(0, actions); - assert!(e.dialog.is_some()); - assert_ne!(e.message, "No code actions available"); +fn test_tab_nav_cross_group() { + use crate::core::window::SplitDirection; + let mut engine = Engine::new(); + // History seeded with initial tab — arrows greyed out. + assert!(!engine.tab_nav_can_go_back()); + assert!(!engine.tab_nav_can_go_forward()); + + // Create two tabs in group 1. + engine.new_tab(None); + let group1 = engine.active_group; + // Navigate between tabs to build history (next_tab pushes to nav history). + engine.next_tab(); // switches to tab 0 — first nav seeds origin + engine.next_tab(); // switches to tab 1 + + // Open a second editor group (switches active_group). + engine.open_editor_group(SplitDirection::Vertical); + let group2 = engine.active_group; + assert_ne!(group1, group2); + // Create a tab in group 2 and navigate to build cross-group history. + engine.new_tab(None); + engine.next_tab(); // navigates within group 2 — pushes history + + // Now we have history entries from both groups. + assert!(engine.tab_nav_can_go_back()); + // Go back — should eventually reach group 1. + let mut reached_group1 = false; + for _ in 0..10 { + if !engine.tab_nav_can_go_back() { + break; + } + engine.tab_nav_back(); + if engine.active_group == group1 { + reached_group1 = true; + break; + } + } + assert!(reached_group1, "back nav should cross groups"); + // Go forward — should return to group 2. + let mut reached_group2 = false; + for _ in 0..10 { + if !engine.tab_nav_can_go_forward() { + break; + } + engine.tab_nav_forward(); + if engine.active_group == group2 { + reached_group2 = true; + break; + } + } + assert!(reached_group2, "forward nav should cross groups"); } -#[test] -fn test_code_action_cache_cleared_on_edit() { - let mut e = engine_with_text("hello\n"); - let path = std::path::PathBuf::from("/tmp/test_cache_clear.rs"); - e.buffer_manager - .get_mut(e.active_window().buffer_id) - .unwrap() - .file_path = Some(path.clone()); - - let mut line_map = HashMap::new(); - line_map.insert( - 0, - vec![lsp::CodeAction { - title: "Fix".to_string(), - kind: None, - edit: None, - }], - ); - e.lsp_code_actions.insert(path.clone(), line_map); - assert!(e.has_code_actions_on_line(0)); +// ── Git branch picker tests ───────────────────────────────────────────────── - // Simulate removing cache (as happens on didChange) - e.lsp_code_actions.remove(&path); - assert!(!e.has_code_actions_on_line(0)); +#[test] +fn test_git_branch_picker_opens() { + let mut engine = engine_with_text("hello"); + engine.open_picker(PickerSource::GitBranches); + assert!(engine.picker_open); + assert_eq!(engine.picker_source, PickerSource::GitBranches); + assert_eq!(engine.picker_title, "Switch Branch"); } -// ── Explorer indicators tests ────────────────────────────────────────── - #[test] -fn test_explorer_indicators_empty() { - let e = Engine::new(); - let (git_st, diags) = e.explorer_indicators(); - assert!(git_st.is_empty()); - assert!(diags.is_empty()); +fn test_git_branch_picker_escape_closes() { + let mut engine = engine_with_text("hello"); + engine.open_picker(PickerSource::GitBranches); + assert!(engine.picker_open); + press_special(&mut engine, "Escape"); + assert!(!engine.picker_open); } #[test] -fn test_explorer_indicators_git_status() { - let mut e = Engine::new(); - // Simulate git status with modified and untracked files - e.cwd = PathBuf::from("/tmp/vimcode_test_git_ind"); - e.sc_file_statuses = vec![ - git::FileStatus { - path: "src/main.rs".to_string(), - staged: None, - unstaged: Some(git::StatusKind::Modified), - }, - git::FileStatus { - path: "new_file.txt".to_string(), - staged: None, - unstaged: Some(git::StatusKind::Untracked), - }, - ]; - let (git_st, _) = e.explorer_indicators(); - // Git status uses repo root which we can't easily mock, so just - // verify the function doesn't panic and returns reasonable results - // (actual path matching depends on find_repo_root returning our cwd) - assert!(git_st.len() <= 2); +fn test_git_branch_picker_populates_items() { + // In a git repo, should show at least one branch + let dir = std::env::temp_dir().join("vimcode_test_branch_picker_pop"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + // Init a git repo with user config (needed in CI where no global git config exists) + let _ = std::process::Command::new("git") + .args(["init", "-b", "main"]) + .current_dir(&dir) + .output(); + let _ = std::process::Command::new("git") + .args(["config", "user.email", "test@test.com"]) + .current_dir(&dir) + .output(); + let _ = std::process::Command::new("git") + .args(["config", "user.name", "Test"]) + .current_dir(&dir) + .output(); + let _ = std::process::Command::new("git") + .args(["commit", "--allow-empty", "-m", "init"]) + .current_dir(&dir) + .output(); + let mut engine = engine_with_text("hello"); + engine.cwd = dir.clone(); + engine.open_picker(PickerSource::GitBranches); + assert!(engine.picker_open); + assert!( + !engine.picker_all_items.is_empty(), + "should have at least one branch" + ); + // Check that the main branch is listed + assert!( + engine + .picker_all_items + .iter() + .any(|i| i.filter_text == "main"), + "should contain 'main' branch" + ); + let _ = std::fs::remove_dir_all(&dir); } #[test] -fn test_explorer_indicators_diagnostics() { - let mut e = Engine::new(); - let path = PathBuf::from("/tmp/vimcode_test_diag_indicator.rs"); - e.lsp_diagnostics.insert( - path.clone(), - vec![ - lsp::Diagnostic { - range: lsp::LspRange { - start: lsp::LspPosition { - line: 0, - character: 0, - }, - end: lsp::LspPosition { - line: 0, - character: 5, - }, - }, - severity: lsp::DiagnosticSeverity::Error, - message: "error".to_string(), - source: None, - code: None, - }, - lsp::Diagnostic { - range: lsp::LspRange { - start: lsp::LspPosition { - line: 1, - character: 0, - }, - end: lsp::LspPosition { - line: 1, - character: 5, - }, - }, - severity: lsp::DiagnosticSeverity::Warning, - message: "warning".to_string(), - source: None, - code: None, - }, - lsp::Diagnostic { - range: lsp::LspRange { - start: lsp::LspPosition { - line: 2, - character: 0, - }, - end: lsp::LspPosition { - line: 2, - character: 5, - }, - }, - severity: lsp::DiagnosticSeverity::Error, - message: "another error".to_string(), - source: None, - code: None, - }, - lsp::Diagnostic { - range: lsp::LspRange { - start: lsp::LspPosition { - line: 3, - character: 0, - }, - end: lsp::LspPosition { - line: 3, - character: 5, - }, - }, - severity: lsp::DiagnosticSeverity::Information, - message: "info".to_string(), - source: None, - code: None, - }, - ], +fn test_git_branch_picker_checkout_action() { + let dir = std::env::temp_dir().join("vimcode_test_branch_checkout"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let _ = std::process::Command::new("git") + .args(["init", "-b", "main"]) + .current_dir(&dir) + .output(); + let _ = std::process::Command::new("git") + .args(["config", "user.email", "test@test.com"]) + .current_dir(&dir) + .output(); + let _ = std::process::Command::new("git") + .args(["config", "user.name", "Test"]) + .current_dir(&dir) + .output(); + let _ = std::process::Command::new("git") + .args(["commit", "--allow-empty", "-m", "init"]) + .current_dir(&dir) + .output(); + // Create a second branch + let _ = std::process::Command::new("git") + .args(["branch", "feature-test"]) + .current_dir(&dir) + .output(); + let mut engine = engine_with_text("hello"); + engine.cwd = dir.clone(); + engine.open_picker(PickerSource::GitBranches); + // Find and select the feature-test branch + engine.picker_query = "feature-test".to_string(); + engine.picker_filter(); + assert!(!engine.picker_items.is_empty(), "should match feature-test"); + // Confirm selection (Enter) + press_special(&mut engine, "Return"); + assert!(!engine.picker_open); + assert!( + engine.message.contains("Switched to feature-test"), + "message should confirm branch switch, got: {}", + engine.message ); - let (_, diags) = e.explorer_indicators(); - let counts = diags.get(&path).expect("should have diag entry"); - assert_eq!(counts.0, 2, "expected 2 errors"); - assert_eq!(counts.1, 1, "expected 1 warning"); + let _ = std::fs::remove_dir_all(&dir); } -// ── hide_single_tab setting tests ────────────────────────────────────────── +#[test] +fn test_gbranches_command_opens_picker() { + let mut engine = engine_with_text("hello"); + engine.execute_command("Gbranches"); + assert!(engine.picker_open); + assert_eq!(engine.picker_source, PickerSource::GitBranches); + assert_eq!(engine.picker_title, "Switch Branch"); +} #[test] -fn test_hide_single_tab_default_off() { - let engine = Engine::new(); - assert!(!engine.settings.hide_single_tab); - assert!(!engine.is_tab_bar_hidden(engine.active_group)); +fn test_git_branch_picker_filter_typing() { + let dir = std::env::temp_dir().join("vimcode_test_branch_filter"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let _ = std::process::Command::new("git") + .args(["init", "-b", "main"]) + .current_dir(&dir) + .output(); + let _ = std::process::Command::new("git") + .args(["config", "user.email", "test@test.com"]) + .current_dir(&dir) + .output(); + let _ = std::process::Command::new("git") + .args(["config", "user.name", "Test"]) + .current_dir(&dir) + .output(); + let _ = std::process::Command::new("git") + .args(["commit", "--allow-empty", "-m", "init"]) + .current_dir(&dir) + .output(); + let _ = std::process::Command::new("git") + .args(["branch", "feature-abc"]) + .current_dir(&dir) + .output(); + let _ = std::process::Command::new("git") + .args(["branch", "bugfix-xyz"]) + .current_dir(&dir) + .output(); + let mut engine = engine_with_text("hello"); + engine.cwd = dir.clone(); + engine.open_picker(PickerSource::GitBranches); + let all_count = engine.picker_all_items.len(); + assert!(all_count >= 3, "should have at least 3 branches"); + // Type "feat" to filter + press_char(&mut engine, 'f'); + press_char(&mut engine, 'e'); + press_char(&mut engine, 'a'); + press_char(&mut engine, 't'); + assert_eq!(engine.picker_query, "feat"); + assert!( + engine.picker_items.len() < all_count, + "filtered items should be fewer" + ); + assert!( + engine + .picker_items + .iter() + .any(|i| i.filter_text.contains("feature")), + "should match feature branch" + ); + let _ = std::fs::remove_dir_all(&dir); } +// ── Inline new file/folder in explorer ────────────────────────────────────── + #[test] -fn test_hide_single_tab_one_tab() { +fn test_explorer_new_file_start() { let mut engine = Engine::new(); - engine.settings.hide_single_tab = true; - // Single tab → tab bar should be hidden - assert!(engine.is_tab_bar_hidden(engine.active_group)); + let dir = std::env::temp_dir().join("vimcode_test_new_file_start"); + let _ = std::fs::create_dir_all(&dir); + engine.start_explorer_new_file(dir.clone()); + assert!(engine.explorer_new_entry.is_some()); + let entry = engine.explorer_new_entry.as_ref().unwrap(); + assert_eq!(entry.parent_dir, dir); + assert!(!entry.is_folder); + assert!(entry.input.is_empty()); + assert_eq!(entry.cursor, 0); + let _ = std::fs::remove_dir_all(&dir); } #[test] -fn test_hide_single_tab_two_tabs() { +fn test_explorer_new_folder_start() { let mut engine = Engine::new(); - engine.settings.hide_single_tab = true; - // Open a second tab - let dir = std::env::temp_dir().join("vimcode_test_hst_twotabs"); + let dir = std::env::temp_dir().join("vimcode_test_new_folder_start"); let _ = std::fs::create_dir_all(&dir); - let f = dir.join("a.txt"); - std::fs::write(&f, "hello").unwrap(); - engine.open_file_in_tab(&f); - assert!( - engine.active_group().tabs.len() >= 2, - "should have at least 2 tabs" - ); - // Two tabs → tab bar should NOT be hidden - assert!(!engine.is_tab_bar_hidden(engine.active_group)); + engine.start_explorer_new_folder(dir.clone()); + assert!(engine.explorer_new_entry.is_some()); + let entry = engine.explorer_new_entry.as_ref().unwrap(); + assert_eq!(entry.parent_dir, dir); + assert!(entry.is_folder); let _ = std::fs::remove_dir_all(&dir); } #[test] -fn test_hide_single_tab_window_rects_reclaim_space() { +fn test_explorer_new_entry_typing() { let mut engine = Engine::new(); - engine.settings.breadcrumbs = false; - let content = WindowRect::new(0.0, 0.0, 80.0, 24.0); - let tab_bar_h = 1.0; - - // Without hide: content starts at y=1 (tab bar takes 1 row) - engine.settings.hide_single_tab = false; - let (rects_show, _) = engine.calculate_group_window_rects(content, tab_bar_h); - assert!(!rects_show.is_empty()); - let y_show = rects_show[0].1.y; - let h_show = rects_show[0].1.height; - - // With hide: content should start 1 row earlier and be 1 row taller - engine.settings.hide_single_tab = true; - let (rects_hide, _) = engine.calculate_group_window_rects(content, tab_bar_h); - assert!(!rects_hide.is_empty()); - let y_hide = rects_hide[0].1.y; - let h_hide = rects_hide[0].1.height; - - assert!( - y_hide < y_show, - "hidden tab bar should start higher: {} vs {}", - y_hide, - y_show - ); - assert!( - h_hide > h_show, - "hidden tab bar should give more height: {} vs {}", - h_hide, - h_show - ); - assert!( - (y_show - y_hide - 1.0).abs() < 0.01, - "should gain exactly 1 row: y_show={}, y_hide={}, diff={}", - y_show, - y_hide, - y_show - y_hide - ); + let dir = std::env::temp_dir().join("vimcode_test_new_entry_typing"); + let _ = std::fs::create_dir_all(&dir); + engine.start_explorer_new_file(dir.clone()); + + // Type "hello.rs" + for ch in "hello.rs".chars() { + engine.handle_explorer_new_entry_key(&ch.to_string(), Some(ch), false); + } + let entry = engine.explorer_new_entry.as_ref().unwrap(); + assert_eq!(entry.input, "hello.rs"); + assert_eq!(entry.cursor, "hello.rs".len()); + let _ = std::fs::remove_dir_all(&dir); } #[test] -fn test_hide_single_tab_with_breadcrumbs() { +fn test_explorer_new_entry_escape_cancels() { let mut engine = Engine::new(); - engine.settings.breadcrumbs = true; - engine.settings.hide_single_tab = true; - let content = WindowRect::new(0.0, 0.0, 80.0, 24.0); - let tab_bar_h = 2.0; // tab + breadcrumb - - let (rects, _) = engine.calculate_group_window_rects(content, tab_bar_h); - assert!(!rects.is_empty()); - // Should reclaim 1 row (tab only), breadcrumb stays → y = 1.0 - let y = rects[0].1.y; - assert!( - (y - 1.0).abs() < 0.01, - "with breadcrumbs, content should start at y=1 (breadcrumb row): got {}", - y - ); + let dir = std::env::temp_dir().join("vimcode_test_new_entry_escape"); + let _ = std::fs::create_dir_all(&dir); + engine.start_explorer_new_file(dir.clone()); + engine.handle_explorer_new_entry_key("a", Some('a'), false); + engine.handle_explorer_new_entry_key("Escape", None, false); + assert!(engine.explorer_new_entry.is_none()); + assert!(!engine.explorer_needs_refresh); + let _ = std::fs::remove_dir_all(&dir); } #[test] -fn test_hide_single_tab_multi_group() { +fn test_explorer_new_file_enter_creates() { let mut engine = Engine::new(); - engine.settings.hide_single_tab = true; - - // Create a second group (split) - engine.open_editor_group(SplitDirection::Vertical); - let groups = engine.group_layout.group_ids(); - assert_eq!(groups.len(), 2); + let dir = std::env::temp_dir().join("vimcode_test_new_file_enter"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + engine.start_explorer_new_file(dir.clone()); - // Multi-group mode: tab bars always visible so users can distinguish groups - for &gid in &groups { - assert!( - !engine.is_tab_bar_hidden(gid), - "multi-group should always show tab bars" - ); + for ch in "newfile.txt".chars() { + engine.handle_explorer_new_entry_key(&ch.to_string(), Some(ch), false); } + engine.handle_explorer_new_entry_key("Return", None, false); - // Even after opening a second tab, still visible (multi-group) - let dir = std::env::temp_dir().join("vimcode_test_hst_multigroup"); - let _ = std::fs::create_dir_all(&dir); - let f = dir.join("b.txt"); - std::fs::write(&f, "world").unwrap(); - engine.open_file_in_tab(&f); - for &gid in &groups { - assert!( - !engine.is_tab_bar_hidden(gid), - "multi-group should always show tab bars" - ); - } + assert!(engine.explorer_new_entry.is_none()); + assert!(engine.explorer_needs_refresh); + assert!(dir.join("newfile.txt").exists()); let _ = std::fs::remove_dir_all(&dir); } #[test] -fn test_hide_single_tab_transition_one_to_two() { +fn test_explorer_new_folder_enter_creates() { let mut engine = Engine::new(); - engine.settings.breadcrumbs = false; - engine.settings.hide_single_tab = true; - let content = WindowRect::new(0.0, 0.0, 80.0, 24.0); - let tab_bar_h = 1.0; - - // 1 tab: window starts at y=0 (tab bar hidden, space reclaimed) - let (rects1, _) = engine.calculate_group_window_rects(content, tab_bar_h); - assert_eq!(rects1[0].1.y, 0.0, "1 tab: window should start at y=0"); - assert_eq!( - rects1[0].1.height, 24.0, - "1 tab: window should use full height" - ); + let dir = std::env::temp_dir().join("vimcode_test_new_folder_enter"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + engine.start_explorer_new_folder(dir.clone()); - // Open a second tab - let dir = std::env::temp_dir().join("vimcode_test_hst_transition"); - let _ = std::fs::create_dir_all(&dir); - let f = dir.join("x.txt"); - std::fs::write(&f, "test").unwrap(); - engine.open_file_in_tab(&f); - assert!(engine.active_group().tabs.len() >= 2); + for ch in "subdir".chars() { + engine.handle_explorer_new_entry_key(&ch.to_string(), Some(ch), false); + } + engine.handle_explorer_new_entry_key("Return", None, false); - // 2 tabs: window starts at y=1 (tab bar visible, takes 1 row) - let (rects2, _) = engine.calculate_group_window_rects(content, tab_bar_h); - assert_eq!(rects2[0].1.y, 1.0, "2 tabs: window should start at y=1"); - assert_eq!( - rects2[0].1.height, 23.0, - "2 tabs: window should leave room for tab bar" - ); - assert!( - !engine.is_tab_bar_hidden(engine.active_group), - "2 tabs: tab bar should be visible" - ); + assert!(engine.explorer_new_entry.is_none()); + assert!(engine.explorer_needs_refresh); + assert!(dir.join("subdir").is_dir()); + assert!(engine.message.contains("Created folder")); let _ = std::fs::remove_dir_all(&dir); } #[test] -fn test_set_hide_single_tab() { +fn test_explorer_new_entry_empty_name_silent_cancel() { let mut engine = Engine::new(); - assert!(!engine.settings.hide_single_tab); - engine.settings.parse_set_option("hidesingletab").unwrap(); - assert!(engine.settings.hide_single_tab); - engine.settings.parse_set_option("nohidesingletab").unwrap(); - assert!(!engine.settings.hide_single_tab); - engine.settings.parse_set_option("hst").unwrap(); - assert!(engine.settings.hide_single_tab); + let dir = std::env::temp_dir().join("vimcode_test_new_entry_empty"); + let _ = std::fs::create_dir_all(&dir); + engine.start_explorer_new_file(dir.clone()); + // Press Enter with empty input + engine.handle_explorer_new_entry_key("Return", None, false); + assert!(engine.explorer_new_entry.is_none()); + assert!(!engine.explorer_needs_refresh); + let _ = std::fs::remove_dir_all(&dir); } -// ========================================================================== -// Sidebar focus consolidation tests -// ========================================================================== - #[test] -fn test_sidebar_has_focus_initially_false() { - let engine = Engine::new(); - assert!(!engine.explorer_has_focus); - assert!(!engine.search_has_focus); - assert!(!engine.sidebar_has_focus()); +fn test_explorer_new_entry_duplicate_shows_error() { + let mut engine = Engine::new(); + let dir = std::env::temp_dir().join("vimcode_test_new_entry_dup"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + // Create existing file + std::fs::write(dir.join("exists.txt"), "").unwrap(); + + engine.start_explorer_new_file(dir.clone()); + for ch in "exists.txt".chars() { + engine.handle_explorer_new_entry_key(&ch.to_string(), Some(ch), false); + } + engine.handle_explorer_new_entry_key("Return", None, false); + + assert!(engine.explorer_new_entry.is_none()); + assert!(!engine.explorer_needs_refresh); + assert!(engine.message.contains("already exists")); + let _ = std::fs::remove_dir_all(&dir); } #[test] -fn test_sidebar_has_focus_explorer() { +fn test_explorer_new_entry_backspace() { let mut engine = Engine::new(); - engine.explorer_has_focus = true; - assert!(engine.sidebar_has_focus()); + let dir = std::env::temp_dir().join("vimcode_test_new_entry_bs"); + let _ = std::fs::create_dir_all(&dir); + engine.start_explorer_new_file(dir.clone()); + + for ch in "abc".chars() { + engine.handle_explorer_new_entry_key(&ch.to_string(), Some(ch), false); + } + engine.handle_explorer_new_entry_key("BackSpace", None, false); + let entry = engine.explorer_new_entry.as_ref().unwrap(); + assert_eq!(entry.input, "ab"); + assert_eq!(entry.cursor, 2); + let _ = std::fs::remove_dir_all(&dir); } #[test] -fn test_sidebar_has_focus_search() { +fn test_explorer_new_entry_cursor_movement() { let mut engine = Engine::new(); - engine.search_has_focus = true; - assert!(engine.sidebar_has_focus()); + let dir = std::env::temp_dir().join("vimcode_test_new_entry_cursor"); + let _ = std::fs::create_dir_all(&dir); + engine.start_explorer_new_file(dir.clone()); + + for ch in "test".chars() { + engine.handle_explorer_new_entry_key(&ch.to_string(), Some(ch), false); + } + // Cursor at end (4) + assert_eq!(engine.explorer_new_entry.as_ref().unwrap().cursor, 4); + + // Move left + engine.handle_explorer_new_entry_key("Left", None, false); + assert_eq!(engine.explorer_new_entry.as_ref().unwrap().cursor, 3); + + // Home + engine.handle_explorer_new_entry_key("Home", None, false); + assert_eq!(engine.explorer_new_entry.as_ref().unwrap().cursor, 0); + + // End + engine.handle_explorer_new_entry_key("End", None, false); + assert_eq!(engine.explorer_new_entry.as_ref().unwrap().cursor, 4); + + // Right at end stays + engine.handle_explorer_new_entry_key("Right", None, false); + assert_eq!(engine.explorer_new_entry.as_ref().unwrap().cursor, 4); + + let _ = std::fs::remove_dir_all(&dir); } +// ─── Dialog-based delete confirmation tests ────────────────────────────────── + #[test] -fn test_sidebar_has_focus_aggregates_all_panels() { - let mut engine = Engine::new(); - assert!(!engine.sidebar_has_focus()); +fn test_confirm_delete_file_shows_dialog() { + let dir = std::env::temp_dir().join("vimcode_test_confirm_delete_dialog"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let file = dir.join("target.txt"); + std::fs::write(&file, "content").unwrap(); - engine.sc_has_focus = true; - assert!(engine.sidebar_has_focus()); - engine.sc_has_focus = false; + let mut e = Engine::new(); + e.confirm_delete_file(&file); - engine.dap_sidebar_has_focus = true; - assert!(engine.sidebar_has_focus()); - engine.dap_sidebar_has_focus = false; + assert!(e.dialog.is_some()); + let dlg = e.dialog.as_ref().unwrap(); + assert_eq!(dlg.tag, "confirm_delete"); + assert!(dlg.body[0].contains("target.txt")); + assert_eq!(dlg.buttons.len(), 2); + assert_eq!(dlg.buttons[0].action, "delete"); + assert!(e.pending_delete.is_some()); - engine.ext_sidebar_has_focus = true; - assert!(engine.sidebar_has_focus()); - engine.ext_sidebar_has_focus = false; + let _ = std::fs::remove_dir_all(&dir); +} - engine.ai_has_focus = true; - assert!(engine.sidebar_has_focus()); - engine.ai_has_focus = false; +#[test] +fn test_confirm_delete_file_cancel() { + let dir = std::env::temp_dir().join("vimcode_test_confirm_delete_cancel"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let file = dir.join("keep.txt"); + std::fs::write(&file, "keep me").unwrap(); - engine.settings_has_focus = true; - assert!(engine.sidebar_has_focus()); - engine.settings_has_focus = false; + let mut e = Engine::new(); + e.confirm_delete_file(&file); + assert!(e.dialog.is_some()); - engine.ext_panel_has_focus = true; - assert!(engine.sidebar_has_focus()); - engine.ext_panel_has_focus = false; + // Cancel via Escape + e.handle_key("Escape", None, false); + assert!(e.dialog.is_none()); + assert!(e.pending_delete.is_none()); + // File still exists + assert!(file.exists()); - assert!(!engine.sidebar_has_focus()); + let _ = std::fs::remove_dir_all(&dir); } #[test] -fn test_clear_sidebar_focus() { - let mut engine = Engine::new(); - engine.explorer_has_focus = true; - engine.search_has_focus = true; - engine.sc_has_focus = true; - engine.dap_sidebar_has_focus = true; - engine.ext_sidebar_has_focus = true; - engine.ai_has_focus = true; - engine.settings_has_focus = true; - engine.ext_panel_has_focus = true; - assert!(engine.sidebar_has_focus()); +fn test_confirm_delete_file_confirm() { + let dir = std::env::temp_dir().join("vimcode_test_confirm_delete_confirm"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let file = dir.join("deleteme.txt"); + std::fs::write(&file, "gone").unwrap(); - engine.clear_sidebar_focus(); - assert!(!engine.sidebar_has_focus()); - assert!(!engine.explorer_has_focus); - assert!(!engine.search_has_focus); - assert!(!engine.sc_has_focus); - assert!(!engine.dap_sidebar_has_focus); - assert!(!engine.ext_sidebar_has_focus); - assert!(!engine.ai_has_focus); - assert!(!engine.settings_has_focus); - assert!(!engine.ext_panel_has_focus); + let mut e = Engine::new(); + e.confirm_delete_file(&file); + assert!(e.dialog.is_some()); + + // Press 'd' hotkey to confirm delete + e.handle_key("", Some('d'), false); + assert!(e.dialog.is_none()); + assert!(e.pending_delete.is_none()); + // File should be deleted + assert!(!file.exists()); + assert!(e.message.contains("Deleted")); + + let _ = std::fs::remove_dir_all(&dir); } #[test] -fn test_explorer_focus_blocks_normal_keys() { - let mut engine = engine_with_text("hello world\n"); - engine.explorer_has_focus = true; +fn test_confirm_delete_folder() { + let dir = std::env::temp_dir().join("vimcode_test_confirm_delete_folder"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let subdir = dir.join("mydir"); + std::fs::create_dir_all(&subdir).unwrap(); + std::fs::write(subdir.join("file.txt"), "inner").unwrap(); - // 'x' should NOT delete when explorer has focus - engine.handle_key("x", Some('x'), false); - assert_eq!(engine.buffer().to_string(), "hello world\n"); - assert_eq!(engine.cursor().col, 0); + let mut e = Engine::new(); + e.confirm_delete_file(&subdir); + + let dlg = e.dialog.as_ref().unwrap(); + assert!(dlg.body[0].contains("folder")); + + // Confirm delete + e.handle_key("", Some('d'), false); + assert!(!subdir.exists()); + assert!(e.message.contains("Deleted folder")); + + let _ = std::fs::remove_dir_all(&dir); } #[test] -fn test_search_focus_blocks_normal_keys() { - let mut engine = engine_with_text("hello world\n"); - engine.search_has_focus = true; +fn test_move_file_dialog_shows_with_input() { + let dir = std::env::temp_dir().join("vimcode_test_move_dialog"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let file = dir.join("source.txt"); + std::fs::write(&file, "data").unwrap(); - // 'x' should NOT delete when search has focus - engine.handle_key("x", Some('x'), false); - assert_eq!(engine.buffer().to_string(), "hello world\n"); + let mut e = Engine::new(); + e.start_move_file_dialog(&file, &dir); + + assert!(e.dialog.is_some()); + let dlg = e.dialog.as_ref().unwrap(); + assert_eq!(dlg.tag, "move_file_input"); + assert!(dlg.input.is_some()); + let input = dlg.input.as_ref().unwrap(); + assert_eq!(input.value, "source.txt"); + assert!(!input.is_password); + assert_eq!(dlg.buttons[0].action, "move"); + + let _ = std::fs::remove_dir_all(&dir); } #[test] -fn test_unfocused_sidebar_allows_normal_keys() { - let mut engine = engine_with_text("hello world\n"); - assert!(!engine.explorer_has_focus); - assert!(!engine.search_has_focus); +fn test_move_file_dialog_cancel() { + let dir = std::env::temp_dir().join("vimcode_test_move_dialog_cancel"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let file = dir.join("stay.txt"); + std::fs::write(&file, "stay").unwrap(); - // 'x' should delete a character when sidebar is not focused - engine.handle_key("x", Some('x'), false); - assert_eq!(engine.buffer().to_string(), "ello world\n"); + let mut e = Engine::new(); + e.start_move_file_dialog(&file, &dir); + + // Escape cancels + e.handle_key("Escape", None, false); + assert!(e.dialog.is_none()); + assert!(e.pending_move.is_none()); + assert!(file.exists()); + + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn test_move_file_dialog_confirm() { + let dir = std::env::temp_dir().join("vimcode_test_move_dialog_confirm"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let dest_dir = dir.join("dest"); + std::fs::create_dir_all(&dest_dir).unwrap(); + let file = dir.join("moveme.txt"); + std::fs::write(&file, "moving").unwrap(); + + let mut e = Engine::new(); + e.cwd = dir.clone(); + e.start_move_file_dialog(&file, &dir); + + // Clear the input and type new destination + if let Some(ref mut dlg) = e.dialog { + dlg.input.as_mut().unwrap().value = "dest".to_string(); + } + + // Press Enter to confirm (selected button is "Move") + e.handle_key("Return", None, false); + assert!(e.dialog.is_none()); + assert!(!file.exists()); + assert!(dest_dir.join("moveme.txt").exists()); + assert!(e.message.contains("Moved")); + + let _ = std::fs::remove_dir_all(&dir); } diff --git a/src/core/engine/visual.rs b/src/core/engine/visual.rs index 02cecd9a..6dfbb595 100644 --- a/src/core/engine/visual.rs +++ b/src/core/engine/visual.rs @@ -127,6 +127,17 @@ impl Engine { } } + // Vim behavior: move cursor to start of selection after yank + if let Some((start, _end)) = self.get_visual_selection_range() { + let is_linewise = matches!(self.mode, Mode::VisualLine); + self.view_mut().cursor.line = start.line; + if is_linewise { + self.view_mut().cursor.col = 0; + } else { + self.view_mut().cursor.col = start.col; + } + } + // Exit visual mode self.mode = Mode::Normal; self.visual_anchor = None; diff --git a/src/core/engine/windows.rs b/src/core/engine/windows.rs index fa02f9ca..e1866206 100644 --- a/src/core/engine/windows.rs +++ b/src/core/engine/windows.rs @@ -136,12 +136,26 @@ impl Engine { } } } + // After removing both diff windows, the tab may have no + // valid windows left. Close the tab to avoid a broken state. + let tab_empty = self + .active_tab() + .layout + .window_ids() + .iter() + .all(|wid| !self.windows.contains_key(wid)); + if tab_empty { + self.close_tab(); + return true; + } } else { // Not our window — restore the pair. self.diff_window_pair = Some((a, b)); } } + // Ensure the active window is still valid after removal. + self.repair_active_window(); true } @@ -297,6 +311,7 @@ impl Engine { self.active_group_mut().tabs.push(tab); self.active_group_mut().active_tab = self.active_group().tabs.len() - 1; self.tab_mru_touch(); + self.ensure_active_tab_visible(); if file_path.is_some() { self.message = String::new(); @@ -342,8 +357,16 @@ impl Engine { } let closed_group = self.active_group; + let closed_tab_id = self.active_group().tabs[active_tab_idx].id; self.active_group_mut().tabs.remove(active_tab_idx); + // Remove the closed tab from nav history. + self.tab_nav_history + .retain(|&(g, t)| !(g == closed_group && t == closed_tab_id)); + if self.tab_nav_index >= self.tab_nav_history.len() { + self.tab_nav_index = self.tab_nav_history.len().saturating_sub(1); + } + // Remove the closed tab from MRU and adjust indices self.tab_mru .retain(|&(g, idx)| !(g == closed_group && idx == active_tab_idx)); @@ -359,6 +382,9 @@ impl Engine { self.active_group_mut().active_tab = tabs_len - 1; } self.tab_mru_touch(); + // Ensure the new active tab's window state is consistent. + self.repair_active_window(); + self.ensure_active_tab_visible(); // Remove any buffers that are no longer referenced by any window. // This prevents orphaned dirty buffers from falsely triggering `:qa` @@ -426,6 +452,7 @@ impl Engine { // Ensure the originally active tab (now the only one) is selected. self.active_group_mut().active_tab = 0; self.tab_mru_touch(); + self.repair_active_window(); } /// Close all tabs to the right of the active tab. @@ -442,6 +469,7 @@ impl Engine { } self.active_group_mut().active_tab = active_tab_idx; self.tab_mru_touch(); + self.repair_active_window(); } /// Close all tabs to the left of the active tab. @@ -458,6 +486,7 @@ impl Engine { } self.active_group_mut().active_tab = 0; self.tab_mru_touch(); + self.repair_active_window(); } /// Close all non-dirty tabs except the active one. @@ -496,6 +525,7 @@ impl Engine { self.active_group_mut().active_tab = remaining.saturating_sub(1); } self.tab_mru_touch(); + self.repair_active_window(); } /// Get the file path of a tab's primary window buffer. @@ -1228,6 +1258,94 @@ impl Engine { self.tab_mru.insert(0, entry); } + /// Record the current tab in the back/forward navigation history. + /// Only call this on explicit user navigation actions (goto_tab, next/prev tab, + /// tab switcher confirm, open file). NOT called on new_tab, close_tab, or session restore. + /// Skipped when navigating via back/forward to avoid polluting the stack. + pub(crate) fn tab_nav_push(&mut self) { + if self.tab_nav_navigating { + return; + } + let tab_id = self.active_tab().id; + let entry = (self.active_group, tab_id); + + // Consecutive duplicate suppression. + if self.tab_nav_history.last() == Some(&entry) + && self.tab_nav_index == self.tab_nav_history.len().saturating_sub(1) + { + return; + } + + // Truncate forward history when navigating to a new tab. + if self.tab_nav_index + 1 < self.tab_nav_history.len() { + self.tab_nav_history.truncate(self.tab_nav_index + 1); + } + + self.tab_nav_history.push(entry); + + // Bound at 100 entries. + if self.tab_nav_history.len() > 100 { + self.tab_nav_history.remove(0); + } + + self.tab_nav_index = self.tab_nav_history.len().saturating_sub(1); + } + + /// Navigate backward in tab history (across all editor groups). + pub fn tab_nav_back(&mut self) { + if self.tab_nav_index == 0 { + return; + } + self.tab_nav_index -= 1; + let (group_id, tab_id) = self.tab_nav_history[self.tab_nav_index]; + self.tab_nav_switch_to(group_id, tab_id); + } + + /// Navigate forward in tab history (across all editor groups). + pub fn tab_nav_forward(&mut self) { + if self.tab_nav_index + 1 >= self.tab_nav_history.len() { + return; + } + self.tab_nav_index += 1; + let (group_id, tab_id) = self.tab_nav_history[self.tab_nav_index]; + self.tab_nav_switch_to(group_id, tab_id); + } + + /// Switch to a specific group+tab by TabId, used by back/forward nav. + fn tab_nav_switch_to(&mut self, group_id: GroupId, tab_id: TabId) { + // Find the group and tab index for this TabId. + if let Some(group) = self.editor_groups.get(&group_id) { + if let Some(idx) = group.tabs.iter().position(|t| t.id == tab_id) { + self.tab_nav_navigating = true; + self.active_group = group_id; + self.active_group_mut().active_tab = idx; + self.line_annotations.clear(); + self.blame_annotations_active = false; + self.tab_mru_touch(); // update MRU but skip nav push (navigating=true) + self.lsp_ensure_active_buffer(); + self.ensure_active_tab_visible(); + self.tab_nav_navigating = false; + return; + } + } + // Tab or group no longer exists — remove stale entries. + self.tab_nav_history + .retain(|&(g, t)| !(g == group_id && t == tab_id)); + if self.tab_nav_index >= self.tab_nav_history.len() { + self.tab_nav_index = self.tab_nav_history.len().saturating_sub(1); + } + } + + /// Whether back navigation is available (across all editor groups). + pub fn tab_nav_can_go_back(&self) -> bool { + self.tab_nav_index > 0 + } + + /// Whether forward navigation is available (across all editor groups). + pub fn tab_nav_can_go_forward(&self) -> bool { + self.tab_nav_index + 1 < self.tab_nav_history.len() + } + /// Switch to the next tab. pub fn next_tab(&mut self) { let tabs_len = self.active_group().tabs.len(); @@ -1236,7 +1354,9 @@ impl Engine { self.line_annotations.clear(); self.blame_annotations_active = false; self.tab_mru_touch(); + self.tab_nav_push(); self.lsp_ensure_active_buffer(); + self.ensure_active_tab_visible(); } } @@ -1249,7 +1369,9 @@ impl Engine { self.line_annotations.clear(); self.blame_annotations_active = false; self.tab_mru_touch(); + self.tab_nav_push(); self.lsp_ensure_active_buffer(); + self.ensure_active_tab_visible(); } } @@ -1294,9 +1416,11 @@ impl Engine { self.active_group = group_id; self.active_group_mut().active_tab = tab_idx; self.tab_mru_touch(); + self.tab_nav_push(); self.line_annotations.clear(); self.blame_annotations_active = false; self.lsp_ensure_active_buffer(); + self.ensure_active_tab_visible(); } } self.tab_switcher_open = false; @@ -1330,7 +1454,53 @@ impl Engine { self.line_annotations.clear(); self.blame_annotations_active = false; self.tab_mru_touch(); + self.tab_nav_push(); self.lsp_ensure_active_buffer(); + self.ensure_active_tab_visible(); + } + } + + /// Adjust `tab_scroll_offset` on the active group so that the active tab + /// is visible in the tab bar. + /// + /// Uses `tab_visible_count` (set by the renderer each frame) to know how + /// many tabs actually fit. Falls back to a conservative default of 6. + pub(crate) fn ensure_active_tab_visible(&mut self) { + let group = match self.editor_groups.get_mut(&self.active_group) { + Some(g) => g, + None => return, + }; + let active = group.active_tab; + let total = group.tabs.len(); + // Use visible count minus 1 as a safety margin — tab widths vary, so + // the count from the previous frame may overestimate how many fit. + let visible = group.tab_visible_count.max(1); + let safe_visible = if visible > 2 { visible - 1 } else { visible }; + + // Clamp offset to valid range first. + if group.tab_scroll_offset >= total { + group.tab_scroll_offset = total.saturating_sub(1); + } + + // If active tab is before the scroll offset, scroll back. + if active < group.tab_scroll_offset { + group.tab_scroll_offset = active; + } + // If active tab is past the visible window, scroll forward. + // Use safe_visible to avoid the last tab being just off-screen. + if active >= group.tab_scroll_offset + safe_visible { + group.tab_scroll_offset = active.saturating_sub(safe_visible.saturating_sub(1)); + } + } + + /// Called by the renderer to report how many tabs were actually drawn + /// for a given group. This lets `ensure_active_tab_visible` know the + /// real visible count for the next tab switch. + pub fn set_tab_visible_count(&mut self, group_id: GroupId, count: usize) { + if let Some(g) = self.editor_groups.get_mut(&group_id) { + if count > 0 { + g.tab_visible_count = count; + } } } @@ -1378,6 +1548,8 @@ impl Engine { self.editor_groups.remove(&closing); self.group_layout.remove(closing); self.active_group = self.group_layout.group_ids()[0]; + // Ensure the new active group's window state is consistent. + self.repair_active_window(); } /// Move focus to the next editor group (wraps around). @@ -1683,6 +1855,8 @@ impl Engine { if let Some(tab_idx) = found { self.active_group_mut().active_tab = tab_idx; self.tab_mru_touch(); + self.tab_nav_push(); + self.ensure_active_tab_visible(); self.refresh_git_diff(buffer_id); self.message = format!("\"{}\"", path.display()); self.lsp_did_open(buffer_id); @@ -1699,6 +1873,8 @@ impl Engine { self.active_group_mut().tabs.push(tab); self.active_group_mut().active_tab = self.active_group().tabs.len() - 1; self.tab_mru_touch(); + self.tab_nav_push(); + self.ensure_active_tab_visible(); // Restore saved cursor/scroll position. let view = self.restore_file_position(buffer_id); @@ -1752,6 +1928,7 @@ impl Engine { .map(|(idx, _)| idx); if let Some(tab_idx) = found { self.active_group_mut().active_tab = tab_idx; + self.ensure_active_tab_visible(); self.refresh_git_diff(buffer_id); self.message = format!("\"{}\"", path.display()); self.lsp_did_open(buffer_id); @@ -1809,6 +1986,7 @@ impl Engine { } } + self.ensure_active_tab_visible(); self.refresh_git_diff(buffer_id); self.message = format!("\"{}\"", path.display()); self.lsp_did_open(buffer_id); @@ -2094,6 +2272,14 @@ impl Engine { } } + // Seed nav history with just the active tab — arrows are greyed out + // (can't go back from index 0) but the first manual switch will record + // destination while this entry serves as the origin. + self.tab_nav_history.clear(); + let seed_tab_id = self.active_tab().id; + self.tab_nav_history.push((self.active_group, seed_tab_id)); + self.tab_nav_index = 0; + // Check all restored buffers for stale swap files. self.swap_check_all_buffers(); } @@ -2157,6 +2343,13 @@ impl Engine { self.lsp_did_open(active_bid); } + // Seed nav history with just the active tab — arrows greyed out until + // the user manually switches tabs (matches VSCode behavior). + self.tab_nav_history.clear(); + let seed_tab_id = self.active_tab().id; + self.tab_nav_history.push((self.active_group, seed_tab_id)); + self.tab_nav_index = 0; + // Check all restored buffers for stale swap files. self.swap_check_all_buffers(); } diff --git a/src/core/lsp.rs b/src/core/lsp.rs index 9cebf5bf..6bfd2b8a 100644 --- a/src/core/lsp.rs +++ b/src/core/lsp.rs @@ -109,6 +109,156 @@ pub enum LspEvent { /// Raw delta-encoded u32 data from the server. raw_data: Vec, }, + /// Document symbol response (textDocument/documentSymbol). + DocumentSymbolResponse { + server_id: LspServerId, + request_id: i64, + symbols: Vec, + }, + /// Workspace symbol response (workspace/symbol). + WorkspaceSymbolResponse { + server_id: LspServerId, + request_id: i64, + symbols: Vec, + }, +} + +/// A symbol returned by documentSymbol or workspace/symbol. +#[derive(Debug, Clone)] +#[allow(dead_code)] +pub struct SymbolInfo { + pub name: String, + pub kind: SymbolKind, + pub detail: Option, + /// Container name (e.g. the struct a method belongs to). + pub container: Option, + /// File path (always set for workspace symbols; for document symbols, same as request file). + pub path: Option, + /// 0-indexed line. + pub line: u32, + /// 0-indexed character (UTF-16). + pub character: u32, +} + +/// LSP SymbolKind (subset of the spec). +#[derive(Debug, Clone, Copy, PartialEq)] +#[allow(dead_code)] +pub enum SymbolKind { + File, + Module, + Namespace, + Package, + Class, + Method, + Property, + Field, + Constructor, + Enum, + Interface, + Function, + Variable, + Constant, + String, + Number, + Boolean, + Array, + Object, + Key, + Null, + EnumMember, + Struct, + Event, + Operator, + TypeParameter, + Unknown, +} + +impl SymbolKind { + pub fn from_number(n: u64) -> Self { + match n { + 1 => Self::File, + 2 => Self::Module, + 3 => Self::Namespace, + 4 => Self::Package, + 5 => Self::Class, + 6 => Self::Method, + 7 => Self::Property, + 8 => Self::Field, + 9 => Self::Constructor, + 10 => Self::Enum, + 11 => Self::Interface, + 12 => Self::Function, + 13 => Self::Variable, + 14 => Self::Constant, + 15 => Self::String, + 16 => Self::Number, + 17 => Self::Boolean, + 18 => Self::Array, + 19 => Self::Object, + 20 => Self::Key, + 21 => Self::Null, + 22 => Self::EnumMember, + 23 => Self::Struct, + 24 => Self::Event, + 25 => Self::Operator, + 26 => Self::TypeParameter, + _ => Self::Unknown, + } + } + + pub fn icon(&self) -> &'static str { + match self { + Self::File => "󰈔", + Self::Module | Self::Namespace | Self::Package => "󰏗", + Self::Class | Self::Struct => "", + Self::Method | Self::Constructor => "󰊕", + Self::Function => "󰊕", + Self::Property | Self::Field => "", + Self::Enum | Self::EnumMember => "", + Self::Interface => "", + Self::Variable => "", + Self::Constant => "", + Self::String | Self::Number | Self::Boolean | Self::Null => "󰎠", + Self::Array | Self::Object => "󰅪", + Self::Key => "", + Self::Event => "", + Self::Operator => "󰆕", + Self::TypeParameter => "", + Self::Unknown => "?", + } + } + + pub fn label(&self) -> &'static str { + match self { + Self::File => "file", + Self::Module => "module", + Self::Namespace => "namespace", + Self::Package => "package", + Self::Class => "class", + Self::Method => "method", + Self::Property => "property", + Self::Field => "field", + Self::Constructor => "constructor", + Self::Enum => "enum", + Self::Interface => "interface", + Self::Function => "function", + Self::Variable => "variable", + Self::Constant => "constant", + Self::String => "string", + Self::Number => "number", + Self::Boolean => "boolean", + Self::Array => "array", + Self::Object => "object", + Self::Key => "key", + Self::Null => "null", + Self::EnumMember => "enum member", + Self::Struct => "struct", + Self::Event => "event", + Self::Operator => "operator", + Self::TypeParameter => "type param", + Self::Unknown => "symbol", + } + } } /// A single semantic token with absolute (decoded) positions. @@ -1075,6 +1225,26 @@ impl LspServer { ) } + /// Request document symbols (outline) for a file. + pub fn request_document_symbols(&mut self, uri: &str) -> i64 { + self.send_request( + "textDocument/documentSymbol", + serde_json::json!({ + "textDocument": { "uri": uri } + }), + ) + } + + /// Request workspace symbols matching a query. + pub fn request_workspace_symbols(&mut self, query: &str) -> i64 { + self.send_request( + "workspace/symbol", + serde_json::json!({ + "query": query + }), + ) + } + /// Send shutdown request and exit notification. pub fn shutdown(&mut self) { self.send_request("shutdown", serde_json::json!(null)); @@ -1389,6 +1559,22 @@ fn reader_thread( actions, }); } + Some("textDocument/documentSymbol") => { + let symbols = result.map(parse_document_symbols).unwrap_or_default(); + let _ = tx.send(LspEvent::DocumentSymbolResponse { + server_id, + request_id: id, + symbols, + }); + } + Some("workspace/symbol") => { + let symbols = result.map(parse_workspace_symbols).unwrap_or_default(); + let _ = tx.send(LspEvent::WorkspaceSymbolResponse { + server_id, + request_id: id, + symbols, + }); + } _ => { // Unknown or shutdown response — ignore } @@ -1570,6 +1756,118 @@ fn parse_locations_response(result: &serde_json::Value) -> Option> Some(locations) } +/// Parse a `textDocument/documentSymbol` response. +/// Handles both `DocumentSymbol[]` (hierarchical) and `SymbolInformation[]` (flat). +fn parse_document_symbols(result: &serde_json::Value) -> Vec { + let mut symbols = Vec::new(); + if let Some(arr) = result.as_array() { + for item in arr { + // Check if this is a DocumentSymbol (has `selectionRange`) or SymbolInformation (has `location`). + if item.get("selectionRange").is_some() { + flatten_document_symbol(item, None, &mut symbols); + } else if item.get("location").is_some() { + if let Some(sym) = parse_symbol_information(item) { + symbols.push(sym); + } + } + } + } + symbols +} + +/// Recursively flatten a hierarchical `DocumentSymbol` into a flat list. +fn flatten_document_symbol( + item: &serde_json::Value, + container: Option<&str>, + out: &mut Vec, +) { + let name = item + .get("name") + .and_then(|n| n.as_str()) + .unwrap_or("") + .to_string(); + let kind = item + .get("kind") + .and_then(|k| k.as_u64()) + .map(SymbolKind::from_number) + .unwrap_or(SymbolKind::Unknown); + let detail = item + .get("detail") + .and_then(|d| d.as_str()) + .map(|s| s.to_string()); + let range = item.get("selectionRange").or_else(|| item.get("range")); + let (line, character) = range + .and_then(|r| { + let start = r.get("start")?; + Some(( + start.get("line")?.as_u64()? as u32, + start.get("character")?.as_u64()? as u32, + )) + }) + .unwrap_or((0, 0)); + + out.push(SymbolInfo { + name: name.clone(), + kind, + detail, + container: container.map(|s| s.to_string()), + path: None, // Filled in by the caller (same as request file). + line, + character, + }); + + // Recurse into children. + if let Some(children) = item.get("children").and_then(|c| c.as_array()) { + for child in children { + flatten_document_symbol(child, Some(&name), out); + } + } +} + +/// Parse a `SymbolInformation` object (flat format, used by workspace/symbol too). +fn parse_symbol_information(item: &serde_json::Value) -> Option { + let name = item.get("name")?.as_str()?.to_string(); + let kind = item + .get("kind") + .and_then(|k| k.as_u64()) + .map(SymbolKind::from_number) + .unwrap_or(SymbolKind::Unknown); + let container = item + .get("containerName") + .and_then(|c| c.as_str()) + .map(|s| s.to_string()); + let location = item.get("location")?; + let uri = location.get("uri")?.as_str()?; + let path = uri_to_path(uri); + let range = location.get("range")?; + let start = range.get("start")?; + let line = start.get("line")?.as_u64()? as u32; + let character = start.get("character")?.as_u64()? as u32; + + Some(SymbolInfo { + name, + kind, + detail: None, + container, + path, + line, + character, + }) +} + +/// Parse a `workspace/symbol` response (always `SymbolInformation[]`). +fn parse_workspace_symbols(result: &serde_json::Value) -> Vec { + let mut symbols = Vec::new(); + if let Some(arr) = result.as_array() { + for item in arr { + if let Some(sym) = parse_symbol_information(item) { + symbols.push(sym); + } + } + } + symbols +} + fn try_parse_signature_help_response( server_id: LspServerId, request_id: i64, diff --git a/src/core/lsp_manager.rs b/src/core/lsp_manager.rs index 42d0f52c..52e73826 100644 --- a/src/core/lsp_manager.rs +++ b/src/core/lsp_manager.rs @@ -710,6 +710,18 @@ impl LspManager { Some(self.servers[sid].request_type_definition(&uri, line, character)) } + /// Request document symbols (outline) from the appropriate server. + pub fn request_document_symbols(&mut self, path: &Path) -> Option { + let (sid, uri) = self.server_and_uri(path)?; + Some(self.servers[sid].request_document_symbols(&uri)) + } + + /// Request workspace symbols matching a query from the appropriate server. + pub fn request_workspace_symbols(&mut self, path: &Path, query: &str) -> Option { + let (sid, _uri) = self.server_and_uri(path)?; + Some(self.servers[sid].request_workspace_symbols(query)) + } + /// Request signature help from the appropriate server. pub fn request_signature_help( &mut self, diff --git a/src/core/settings.rs b/src/core/settings.rs index 7125fda1..0386a341 100644 --- a/src/core/settings.rs +++ b/src/core/settings.rs @@ -516,6 +516,19 @@ pub struct PanelKeys { /// Example: `""` to bind Ctrl+_. #[serde(default)] pub split_editor_down: String, + /// Navigate back in tab history. Default: `` + #[serde(default = "pk_nav_back")] + pub nav_back: String, + /// Navigate forward in tab history. Default: `` + #[serde(default = "pk_nav_forward")] + pub nav_forward: String, +} + +fn pk_nav_back() -> String { + "".to_string() +} +fn pk_nav_forward() -> String { + "".to_string() } impl Default for PanelKeys { @@ -532,6 +545,8 @@ impl Default for PanelKeys { select_all_matches: pk_select_all_matches(), split_editor_right: String::new(), split_editor_down: String::new(), + nav_back: pk_nav_back(), + nav_forward: pk_nav_forward(), } } } diff --git a/src/core/swap.rs b/src/core/swap.rs index d0cae8c9..bd9ed84e 100644 --- a/src/core/swap.rs +++ b/src/core/swap.rs @@ -8,6 +8,48 @@ use std::fs; use std::io::Write; use std::path::{Path, PathBuf}; +use std::sync::Mutex; + +/// Wrapper around a raw pointer to Engine, made Send+Sync so it can +/// live in a global static. Only used for last-resort panic recovery. +struct EnginePtr(*const crate::core::Engine); +// SAFETY: The pointer is set once on the main thread at startup and +// only read during panic recovery (also on the main thread for both +// GTK and TUI). The Engine it points to lives for the entire process. +unsafe impl Send for EnginePtr {} +unsafe impl Sync for EnginePtr {} + +/// Raw pointer to the active Engine, used by the panic hook to flush +/// swap files as a last resort before the process exits. +static EMERGENCY_ENGINE: Mutex> = Mutex::new(None); + +/// Register the engine pointer for emergency swap flush on panic. +/// Called once at startup by the active backend. +/// +/// # Safety +/// The caller must ensure `engine` lives for the rest of the process. +pub unsafe fn register_emergency_engine(engine: *const crate::core::Engine) { + if let Ok(mut guard) = EMERGENCY_ENGINE.lock() { + *guard = Some(EnginePtr(engine)); + } +} + +/// Flush swap files for all dirty buffers via the registered engine. +/// Called from the panic hook. Silently does nothing if no engine +/// is registered or the mutex is poisoned (double-panic). +pub fn run_emergency_flush() { + // Use try_lock to avoid deadlocking if the panic occurred while + // the mutex was held. + if let Ok(guard) = EMERGENCY_ENGINE.try_lock() { + if let Some(ref ep) = *guard { + // SAFETY: The engine pointer was registered at startup and + // the engine lives for the entire process. We only read + // buffer content (no mutation) to write swap files. + let engine = unsafe { &*ep.0 }; + engine.emergency_swap_flush(); + } + } +} /// Parsed swap-file header. #[derive(Debug, Clone)] diff --git a/src/core/syntax.rs b/src/core/syntax.rs index 70f86349..8b7d620f 100644 --- a/src/core/syntax.rs +++ b/src/core/syntax.rs @@ -737,7 +737,10 @@ impl Syntax { // Skip incremental parsing for Markdown: tree-sitter-md's external // scanner can corrupt the parser's logger struct when reusing an old // tree, causing a SIGSEGV in ts_parser__log. - let old_tree = if self.language == SyntaxLanguage::Markdown { + let old_tree = if matches!( + self.language, + SyntaxLanguage::Markdown | SyntaxLanguage::Yaml + ) { None } else { self.last_tree.as_ref() diff --git a/src/gtk/css.rs b/src/gtk/css.rs index 94e89739..e1e33443 100644 --- a/src/gtk/css.rs +++ b/src/gtk/css.rs @@ -128,6 +128,16 @@ pub(super) fn make_theme_css(theme: &Theme) -> String { color: {dim_fg}; }} + /* Inline editing entry (rename / new file/folder) */ + treeview entry {{ + border: 1px solid {accent}; + border-radius: 2px; + padding: 2px 4px; + background-color: {editor_bg}; + color: {text_fg}; + min-height: 20px; + }} + /* Search results */ .search-results-list {{ background-color: {bar_bg}; diff --git a/src/gtk/draw.rs b/src/gtk/draw.rs index 55d55461..d970541b 100644 --- a/src/gtk/draw.rs +++ b/src/gtk/draw.rs @@ -22,6 +22,7 @@ pub(super) fn draw_editor( editor_hover_rect_out: &Rc>>, editor_hover_link_rects_out: &Rc>>, mouse_pos: (f64, f64), + tab_visible_counts_out: &Rc>>, ) { let theme = Theme::from_name(&engine.settings.colorscheme); @@ -181,7 +182,7 @@ pub(super) fn draw_editor( None } }); - let (positions, dbp, sbp) = draw_tab_bar( + let (positions, dbp, sbp, vis_count) = draw_tab_bar( cr, &layout, &theme, @@ -192,6 +193,7 @@ pub(super) fn draw_editor( show_split, hover_idx, gtb.diff_toolbar.as_ref(), + gtb.tab_scroll_offset, ); tab_slot_positions_out .borrow_mut() @@ -202,6 +204,9 @@ pub(super) fn draw_editor( if let Some(sp) = sbp { split_btn_map_out.borrow_mut().insert(gtb.group_id.0, sp); } + tab_visible_counts_out + .borrow_mut() + .push((gtb.group_id, vis_count)); cr.restore().ok(); // Active-group indicator: bright bottom border. if is_active { @@ -216,7 +221,7 @@ pub(super) fn draw_editor( } else if !engine.is_tab_bar_hidden(engine.active_group) { // Single group: draw tab bar at full width with split buttons. let hover_idx = tab_close_hover.map(|(_gid, tidx)| tidx); - let (positions, dbp, sbp) = draw_tab_bar( + let (positions, dbp, sbp, vis_count) = draw_tab_bar( cr, &layout, &theme, @@ -227,6 +232,7 @@ pub(super) fn draw_editor( true, hover_idx, screen.diff_toolbar.as_ref(), + screen.tab_scroll_offset, ); // Use group_id 0 for single-group mode tab_slot_positions_out @@ -242,6 +248,9 @@ pub(super) fn draw_editor( .borrow_mut() .insert(engine.active_group.0, sp); } + tab_visible_counts_out + .borrow_mut() + .push((engine.active_group, vis_count)); } // 4b. Draw breadcrumb bar(s) below tab bar(s) @@ -676,6 +685,7 @@ pub(super) fn draw_tab_bar( show_split_btn: bool, hovered_close_tab: Option, diff_toolbar: Option<&render::DiffToolbarData>, + tab_scroll_offset: usize, ) -> TabBarDrawResult { // Tab bar background let (r, g, b) = theme.tab_bar_bg.to_cairo(); @@ -683,6 +693,9 @@ pub(super) fn draw_tab_bar( cr.rectangle(0.0, y_offset, width, line_height); cr.fill().ok(); + // Clear any leftover Pango attributes (e.g. syntax highlighting from draw_window). + layout.set_attributes(None); + // Use sans-serif UI font for tabs (like VSCode) let saved_font = layout.font_description().unwrap_or_default(); let ui_font_desc = FontDescription::from_string(UI_FONT); @@ -730,6 +743,7 @@ pub(super) fn draw_tab_bar( (0.0, 0.0) }; let diff_total_px = diff_btns_px + diff_label_px; + let tab_area_width = width - both_btns_px - diff_total_px; // Measure the close button (×) once for use in every tab. @@ -741,9 +755,17 @@ pub(super) fn draw_tab_bar( let tab_inner_gap = 4.0; // space between name and × let tab_outer_gap = 4.0; // space between tabs - let mut x = 0.0; + let mut x = 0.0_f64; + let effective_tab_area = tab_area_width; + let mut slot_positions: Vec<(f64, f64)> = Vec::with_capacity(tabs.len()); - for (tab_idx, tab) in tabs.iter().enumerate() { + // Fill slots for hidden tabs (before scroll offset) with zero-width entries + // so that slot_positions indices match tab indices. + for _ in 0..tab_scroll_offset.min(tabs.len()) { + slot_positions.push((0.0, 0.0)); + } + let mut last_rendered_tab = tabs.len(); + for (tab_idx, tab) in tabs.iter().enumerate().skip(tab_scroll_offset) { // Use italic font for preview tabs if tab.preview { layout.set_font_description(Some(&italic_font)); @@ -757,8 +779,9 @@ pub(super) fn draw_tab_bar( // Total per-tab slot: name + gap + × + outer_gap let slot_w = tab_w + tab_inner_gap + close_w + tab_outer_gap; - // Stop drawing tabs if they would overrun the area reserved for the split button. - if x + slot_w > tab_area_width { + // Stop drawing tabs if they would overrun the available area. + if x + slot_w > effective_tab_area { + last_rendered_tab = tab_idx; break; } slot_positions.push((x, x + slot_w)); @@ -940,7 +963,8 @@ pub(super) fn draw_tab_bar( // Restore original editor font for subsequent rendering layout.set_font_description(Some(&saved_font)); - (slot_positions, diff_btn_pos, split_btn_info) + let visible_count = last_rendered_tab.saturating_sub(tab_scroll_offset); + (slot_positions, diff_btn_pos, split_btn_info, visible_count) } pub(super) fn draw_breadcrumb_bar( @@ -3678,6 +3702,8 @@ pub(super) fn draw_command_line( } } +/// Returns `(back_x, back_end, fwd_x, fwd_end, unit_end)` — pixel hit rects for nav arrows +/// and the right edge of the entire interactive area (arrows + search box). pub(super) fn draw_menu_bar( cr: &Context, data: &render::MenuBarData, @@ -3686,7 +3712,7 @@ pub(super) fn draw_menu_bar( y: f64, width: f64, height: f64, -) { +) -> (f64, f64, f64, f64, f64) { // Title bar background: use tab_bar_bg (adapts to light/dark themes). let (tbr, tbg, tbb) = theme.tab_bar_bg.to_cairo(); cr.set_source_rgb(tbr, tbg, tbb); @@ -3720,19 +3746,132 @@ pub(super) fn draw_menu_bar( cursor_x += name.len() as f64 * 7.0 + 10.0; } - // Title centered in remaining space (dimmed) - if !data.title.is_empty() { - let (dr, dg, db) = theme.line_number_fg.to_cairo(); - cr.set_source_rgb(dr, dg, db); - layout.set_text(&data.title); - let (title_w, title_h) = layout.pixel_size(); - let title_x = (x + width - title_w as f64) / 2.0 + x / 2.0; - cr.move_to( - title_x.max(cursor_x + 8.0), - y + (height - title_h as f64) / 2.0, + // Centered nav arrows + search box (like VSCode Command Center). + // The entire unit is centered between the menu labels and the right edge. + let menu_end_x = cursor_x; + + // Measure arrow widths. + layout.set_text("\u{25C0}"); // ◀ + let (back_w, _) = layout.pixel_size(); + layout.set_text("\u{25B6}"); // ▶ + let (fwd_w, _) = layout.pixel_size(); + let arrow_gap = 6.0; + let arrows_w = back_w as f64 + arrow_gap + fwd_w as f64; + + // Measure search box text. + let display = if data.title.is_empty() { + String::new() + } else { + format!("\u{1f50d} {}", data.title) + }; + let box_pad = 12.0; + let (box_text_w, _) = if !display.is_empty() { + layout.set_text(&display); + layout.pixel_size() + } else { + (0, 0) + }; + let box_w = if !display.is_empty() { + box_text_w as f64 + box_pad * 2.0 + } else { + 0.0 + }; + let gap_between = if box_w > 0.0 { 10.0 } else { 0.0 }; + let total_unit_w = arrows_w + gap_between + box_w; + + // Center the unit between menu_end_x and right edge. + let available = x + width - menu_end_x; + let unit_x = (menu_end_x + (available - total_unit_w) / 2.0).max(menu_end_x + 8.0); + + // Draw back arrow. + let dim_fg = theme.line_number_fg; + let back_color = if data.nav_back_enabled { + theme.foreground + } else { + dim_fg + }; + let (br2, bg2, bb2) = back_color.to_cairo(); + cr.set_source_rgb(br2, bg2, bb2); + layout.set_text("\u{25C0}"); + let (_, bh) = layout.pixel_size(); + cr.move_to(unit_x, y + (height - bh as f64) / 2.0); + pangocairo::show_layout(cr, &layout); + + // Draw forward arrow. + let fwd_color = if data.nav_forward_enabled { + theme.foreground + } else { + dim_fg + }; + let (fr2, fg2, fb2) = fwd_color.to_cairo(); + cr.set_source_rgb(fr2, fg2, fb2); + layout.set_text("\u{25B6}"); + let (_, fh) = layout.pixel_size(); + cr.move_to( + unit_x + back_w as f64 + arrow_gap, + y + (height - fh as f64) / 2.0, + ); + pangocairo::show_layout(cr, &layout); + + // Draw search box. + if !display.is_empty() { + let bx = unit_x + arrows_w + gap_between; + let by = y + 3.0; + let bh_box = height - 6.0; + let radius = 4.0; + // Border + let (sr, sg, sb) = theme.separator.to_cairo(); + cr.set_source_rgb(sr, sg, sb); + cr.new_path(); + cr.arc( + bx + box_w - radius, + by + radius, + radius, + -std::f64::consts::FRAC_PI_2, + 0.0, + ); + cr.arc( + bx + box_w - radius, + by + bh_box - radius, + radius, + 0.0, + std::f64::consts::FRAC_PI_2, + ); + cr.arc( + bx + radius, + by + bh_box - radius, + radius, + std::f64::consts::FRAC_PI_2, + std::f64::consts::PI, ); + cr.arc( + bx + radius, + by + radius, + radius, + std::f64::consts::PI, + 3.0 * std::f64::consts::FRAC_PI_2, + ); + cr.close_path(); + cr.set_line_width(1.0); + let _ = cr.stroke(); + // Text inside box — same color as menu labels (foreground) + cr.set_source_rgb(fr, fg, fb); + layout.set_text(&display); + let (_, th) = layout.pixel_size(); + cr.move_to(bx + box_pad, y + (height - th as f64) / 2.0); pangocairo::show_layout(cr, &layout); } + + // Return pixel hit rects for back and forward arrows + interactive area end. + let fwd_x = unit_x + back_w as f64 + arrow_gap; + let unit_end = unit_x + total_unit_w; + ( + unit_x, + unit_x + back_w as f64, + fwd_x, + fwd_x + fwd_w as f64, + unit_end, + ) } #[allow(clippy::too_many_arguments)] diff --git a/src/gtk/mod.rs b/src/gtk/mod.rs index 6d02f60e..d2b094c7 100644 --- a/src/gtk/mod.rs +++ b/src/gtk/mod.rs @@ -70,11 +70,12 @@ type SplitBtnMap = HashMap; /// Cached dialog button hit rects: Vec<(x, y, w, h)> populated by draw_dialog_popup. type DialogBtnRects = Vec<(f64, f64, f64, f64)>; -/// Return type of draw_tab_bar: (tab_slot_positions, diff_btn_positions, split_btn_widths). +/// Return type of draw_tab_bar: (tab_slot_positions, diff_btn_positions, split_btn_widths, visible_tab_count). type TabBarDrawResult = ( Vec<(f64, f64)>, Option<(f64, f64, f64, f64, f64, f64)>, Option<(f64, f64)>, + usize, ); struct App { @@ -87,6 +88,8 @@ struct App { tree_store: Option, tree_has_focus: bool, file_tree_view: Rc>>, + /// Cell renderer for filenames in the explorer tree (for triggering inline editing). + name_cell: Rc>>, drawing_area: Rc>>, menu_bar_da: Rc>>, debug_sidebar_da_ref: Rc>>, @@ -172,6 +175,11 @@ struct App { /// Cached diff toolbar button pixel positions, populated during draw_tab_bar. diff_btn_map: Rc>, split_btn_map: Rc>, + /// Cached nav arrow pixel hit rects from draw_menu_bar: (back_x, back_end, fwd_x, fwd_end, unit_end). + #[allow(dead_code, clippy::type_complexity)] + nav_arrow_rects: Rc>, + /// Tab visible counts reported by draw callback, applied to engine in tick handler. + tab_visible_counts: Rc>>, /// True while the user is dragging the terminal panel's scrollbar thumb. terminal_sb_dragging: bool, /// True while the user drags the terminal header row to resize the panel. @@ -332,10 +340,15 @@ enum Msg { CreateFile(PathBuf, String), /// Create a new folder: (parent_dir, name). CreateFolder(PathBuf, String), + /// Start inline new-file creation in the explorer tree under the given dir. + StartInlineNewFile(PathBuf), + /// Start inline new-folder creation in the explorer tree under the given dir. + StartInlineNewFolder(PathBuf), + /// Explorer CRUD action triggered by keyboard shortcut (the char string). + ExplorerAction(String), + ExplorerActivateSelected, /// Show confirmation dialog before deleting. ConfirmDeletePath(PathBuf), - /// Delete a file or folder at the given path (after confirmation). - DeletePath(PathBuf), /// Refresh the file tree from current working directory. RefreshFileTree, /// Focus the explorer panel (Ctrl-Shift-E). @@ -379,9 +392,15 @@ enum Msg { /// Close find dialog. CloseFindDialog, /// Window size changed. - WindowResized { width: i32, height: i32 }, + WindowResized { + width: i32, + height: i32, + }, /// Window closing (save session state). - WindowClosing { width: i32, height: i32 }, + WindowClosing { + width: i32, + height: i32, + }, /// Sidebar was resized via drag handle — save new width. SidebarResized, /// Project search input text changed (query update, no search yet). @@ -403,7 +422,10 @@ enum Msg { /// User clicked "Replace All" button — run replace across files. ProjectReplaceAll, /// Mouse scroll wheel on editor drawing area. - MouseScroll { delta_x: f64, delta_y: f64 }, + MouseScroll { + delta_x: f64, + delta_y: f64, + }, /// Ctrl+Click — plant a secondary cursor at the clicked buffer position. CtrlMouseClick { x: f64, @@ -440,7 +462,9 @@ enum Msg { /// Open a vsplit diff: current file is right side, stored path is left. DiffWithSelected(PathBuf), /// GDK clipboard text arrived for pasting into command/search/insert input. - ClipboardPasteToInput { text: String }, + ClipboardPasteToInput { + text: String, + }, /// Toggle the integrated terminal panel open/closed. ToggleTerminal, /// Open a new terminal tab at a specific directory. @@ -464,9 +488,15 @@ enum Msg { /// Paste from system clipboard into the terminal PTY. TerminalPasteClipboard, /// Mouse pressed at terminal cell (row, col). - TerminalMouseDown { row: u16, col: u16 }, + TerminalMouseDown { + row: u16, + col: u16, + }, /// Mouse dragged to terminal cell (row, col). - TerminalMouseDrag { row: u16, col: u16 }, + TerminalMouseDrag { + row: u16, + col: u16, + }, /// Mouse released over terminal. TerminalMouseUp, /// Open the terminal inline find bar. @@ -487,6 +517,12 @@ enum Msg { OpenMenu(usize), /// Close the open menu dropdown. CloseMenu, + /// Navigate back in MRU tab history. + MruNavBack, + /// Navigate forward in MRU tab history. + MruNavForward, + /// Open the Command Center picker (search box click). + OpenCommandCenter, /// Activate a menu item: (menu_idx, item_idx, action_str). MenuActivateItem(usize, usize, String), /// Highlight a menu dropdown item by index (mouse hover). @@ -498,7 +534,7 @@ enum Msg { /// Scroll in the debug sidebar DrawingArea (dy value from EventControllerScroll). DebugSidebarScroll(f64), /// Click in the Source Control sidebar DrawingArea (x, y coordinates in pixels). - ScSidebarClick(f64, f64), + ScSidebarClick(f64, f64, i32), /// Mouse motion in the Source Control sidebar DrawingArea (x, y). ScSidebarMotion(f64, f64), /// Key press in the Source Control sidebar DrawingArea. @@ -548,9 +584,14 @@ enum Msg { /// User clicked ✕ on a tab with unsaved changes — ask what to do. ShowCloseTabConfirm, /// User responded to the close-tab unsaved-changes dialog. - CloseTabConfirmed { save: bool }, + CloseTabConfirmed { + save: bool, + }, /// A setting was changed via the Settings sidebar form widget. - SettingChanged { key: String, value: String }, + SettingChanged { + key: String, + value: String, + }, /// Open a buffer editor for the named setting key (e.g. "keymaps", "extension_registries"). OpenBufferEditor(String), /// Alt key released — confirm tab switcher if open. @@ -563,7 +604,10 @@ enum Msg { y: f64, }, /// Right-click on the editor area (buffer text). - EditorRightClick { x: f64, y: f64 }, + EditorRightClick { + x: f64, + y: f64, + }, } #[relm4::component] @@ -807,10 +851,7 @@ impl SimpleComponent for App { set_height_request: 32, connect_clicked[sender, file_tree_view] => move |_| { let parent_dir = selected_parent_dir(&file_tree_view); - show_name_prompt_dialog("New File", "", None, { - let s = sender.clone(); - move |name| s.input(Msg::CreateFile(parent_dir.clone(), name)) - }); + sender.input(Msg::StartInlineNewFile(parent_dir)); } }, @@ -821,10 +862,7 @@ impl SimpleComponent for App { set_height_request: 32, connect_clicked[sender, file_tree_view] => move |_| { let parent_dir = selected_parent_dir(&file_tree_view); - show_name_prompt_dialog("New Folder", "", None, { - let s = sender.clone(); - move |name| s.input(Msg::CreateFolder(parent_dir.clone(), name)) - }); + sender.input(Msg::StartInlineNewFolder(parent_dir)); } }, @@ -889,11 +927,34 @@ impl SimpleComponent for App { sender.input(Msg::ToggleFocusSearch); return gtk4::glib::Propagation::Stop; } - // Arrow keys for navigation - let TreeView handle them - if matches!(key_name.as_str(), "Up" | "Down" | "Left" | "Right" | "Return" | "space") { + // Arrow keys + Enter: let TreeView handle natively + // (Enter fires row_activated which handles dirs+files) + if matches!(key_name.as_str(), "Up" | "Down" | "Left" | "Right" | "Return" | "KP_Enter" | "space") { return gtk4::glib::Propagation::Proceed; } + // Explorer CRUD keys (a/A/D/r/M etc.) + if !modifier.contains(gtk4::gdk::ModifierType::CONTROL_MASK) { + if let Some(ch) = key.to_unicode() { + let ch_str = ch.to_string(); + // Resolve action with a short-lived borrow + let is_explorer_key = { + let ek = &engine.borrow().settings.explorer_keys; + ch_str == ek.new_file || ch_str == ek.new_folder + || ch_str == ek.delete || ch_str == ek.rename + || ch_str == ek.move_file + }; + if is_explorer_key { + // Defer via idle to avoid any borrow conflicts + let s = sender.clone(); + gtk4::glib::idle_add_local_once(move || { + s.input(Msg::ExplorerAction(ch_str)); + }); + return gtk4::glib::Propagation::Stop; + } + } + } + // Stop all other keys from triggering TreeView search gtk4::glib::Propagation::Stop } @@ -1491,6 +1552,15 @@ impl SimpleComponent for App { return gtk4::glib::Propagation::Stop; } + if matches_gtk_key(&pk.nav_back, key, modifier) { + engine.borrow_mut().tab_nav_back(); + return gtk4::glib::Propagation::Stop; + } + if matches_gtk_key(&pk.nav_forward, key, modifier) { + engine.borrow_mut().tab_nav_forward(); + return gtk4::glib::Propagation::Stop; + } + // Shift+F5 → stop, Shift+F11 → stepout (debug shortcuts) if shift && !ctrl && !alt { match key_name.as_str() { @@ -1688,6 +1758,7 @@ impl SimpleComponent for App { // Using take() clears the flag atomically so it fires once per request. if model.draw_needed.take() { drawing_area.queue_draw(); + menu_bar_da.queue_draw(); } // Return static classes — no even/odd alternation — so GTK // skips CSS re-resolution when classes haven't changed. @@ -1893,6 +1964,15 @@ impl SimpleComponent for App { let engine = Rc::new(RefCell::new(engine)); + // Register engine pointer for emergency swap flush from the panic hook. + // SAFETY: The Rc> lives for the GTK app's lifetime. + // The pointer is only dereferenced during panic recovery on the main thread. + unsafe { + crate::core::swap::register_emergency_engine( + engine.as_ptr() as *const crate::core::Engine + ); + } + // Create TreeStore with 6 columns: Icon, Name, FullPath, FgColor, Indicator, IndicatorColor let tree_store = gtk4::TreeStore::new(&[ gtk4::glib::Type::STRING, // 0: Icon @@ -1904,6 +1984,8 @@ impl SimpleComponent for App { ]); let file_tree_view_ref = Rc::new(RefCell::new(None)); + let name_cell_ref: Rc>> = + Rc::new(RefCell::new(None)); let active_ctx_popover_ref: Rc>> = Rc::new(RefCell::new(None)); let drawing_area_ref = Rc::new(RefCell::new(None)); @@ -1947,6 +2029,11 @@ impl SimpleComponent for App { Rc::new(RefCell::new(HashMap::new())); let diff_btn_map_cell: Rc> = Rc::new(RefCell::new(HashMap::new())); let split_btn_map_cell: Rc> = Rc::new(RefCell::new(HashMap::new())); + let tab_visible_counts_cell: Rc>> = + Rc::new(RefCell::new(Vec::new())); + #[allow(clippy::type_complexity)] + let nav_arrow_rects_cell: Rc> = + Rc::new(RefCell::new((0.0, 0.0, 0.0, 0.0, 0.0))); let sidebar_inner_sw_ref: Rc>> = Rc::new(RefCell::new(None)); let sidebar_revealer_ref: Rc>> = Rc::new(RefCell::new(None)); @@ -2009,6 +2096,7 @@ impl SimpleComponent for App { tree_store: Some(tree_store.clone()), tree_has_focus: false, file_tree_view: file_tree_view_ref.clone(), + name_cell: name_cell_ref.clone(), drawing_area: drawing_area_ref.clone(), menu_bar_da: menu_bar_da_ref.clone(), debug_sidebar_da_ref: debug_sidebar_da_ref.clone(), @@ -2058,6 +2146,8 @@ impl SimpleComponent for App { tab_slot_positions: tab_slot_positions_cell.clone(), diff_btn_map: diff_btn_map_cell.clone(), split_btn_map: split_btn_map_cell.clone(), + nav_arrow_rects: nav_arrow_rects_cell.clone(), + tab_visible_counts: tab_visible_counts_cell.clone(), terminal_sb_dragging: false, terminal_resize_dragging: false, terminal_split_dragging: false, @@ -2272,8 +2362,10 @@ impl SimpleComponent for App { } } let title = engine - .active_buffer_name() - .map(|n| format!("VimCode \u{2014} {}", n)) + .cwd + .file_name() + .and_then(|n| n.to_str()) + .map(|n| n.to_string()) .unwrap_or_else(|| "VimCode".to_string()); let data = render::MenuBarData { open_menu_idx: engine.menu_open_idx, @@ -2283,6 +2375,8 @@ impl SimpleComponent for App { title, show_window_controls: true, is_vscode_mode: engine.is_vscode_mode(), + nav_back_enabled: engine.tab_nav_can_go_back(), + nav_forward_enabled: engine.tab_nav_can_go_forward(), }; let line_height = lh.get(); // Draw the dropdown at window-level coordinates. @@ -2508,6 +2602,7 @@ impl SimpleComponent for App { // Draw function: renders menu labels using the same Cairo helper. { let engine = engine.clone(); + let nav_rects = nav_arrow_rects_cell.clone(); widgets.menu_bar_da.set_draw_func(move |da, cr, _w, _h| { let engine = engine.borrow(); // Menu bar is always visible in GTK (acts as the window title bar). @@ -2533,8 +2628,10 @@ impl SimpleComponent for App { 0 }; let title = engine - .active_buffer_name() - .map(|n| format!("VimCode \u{2014} {}", n)) + .cwd + .file_name() + .and_then(|n| n.to_str()) + .map(|n| n.to_string()) .unwrap_or_else(|| "VimCode".to_string()); let data = render::MenuBarData { open_menu_idx: engine.menu_open_idx, @@ -2544,19 +2641,23 @@ impl SimpleComponent for App { title, show_window_controls: true, is_vscode_mode: engine.is_vscode_mode(), + nav_back_enabled: engine.tab_nav_can_go_back(), + nav_forward_enabled: engine.tab_nav_can_go_forward(), }; let w = da.width() as f64; let h = da.height() as f64; - draw_menu_bar(cr, &data, &theme, 0.0, 0.0, w, h); + let rects = draw_menu_bar(cr, &data, &theme, 0.0, 0.0, w, h); + *nav_rects.borrow_mut() = rects; }); } // Click gesture: open/close individual menus (no hamburger zone here). { let sender_menu = sender.input_sender().clone(); let engine_menu = engine.clone(); + let nav_rects_click = nav_arrow_rects_cell.clone(); let gesture = gtk4::GestureClick::new(); gesture.set_button(1); - gesture.connect_pressed(move |_, _, x, _y| { + gesture.connect_pressed(move |gest, _, x, _y| { let engine = engine_menu.borrow(); // Scan menu labels from left edge (no hamburger on this widget). // Use ~7px/char + 10px padding as approximation for UI font metrics. @@ -2573,6 +2674,30 @@ impl SimpleComponent for App { } cursor_x += item_w; } + // Use cached arrow pixel positions from draw_menu_bar. + let (back_x, back_end, fwd_x, fwd_end, unit_end) = *nav_rects_click.borrow(); + if x >= back_x && x < back_end { + // Claim the gesture so WindowHandle doesn't maximize on double-click. + gest.set_state(gtk4::EventSequenceState::Claimed); + sender_menu.send(Msg::MruNavBack).ok(); + return; + } + if x >= fwd_x && x < fwd_end { + gest.set_state(gtk4::EventSequenceState::Claimed); + sender_menu.send(Msg::MruNavForward).ok(); + return; + } + // Click on the search box area → open Command Center. + if x >= fwd_end && x < unit_end { + gest.set_state(gtk4::EventSequenceState::Claimed); + sender_menu.send(Msg::OpenCommandCenter).ok(); + return; + } + // Claim clicks within the nav+search box area to prevent + // WindowHandle double-click-to-maximize on the search box. + if x >= back_x && x < unit_end { + gest.set_state(gtk4::EventSequenceState::Claimed); + } // Click in empty part of bar → close any open dropdown if engine.menu_open_idx.is_some() { sender_menu.send(Msg::CloseMenu).ok(); @@ -2715,8 +2840,8 @@ impl SimpleComponent for App { let sender_sc = sender.input_sender().clone(); let gesture = gtk4::GestureClick::new(); gesture.set_button(1); - gesture.connect_pressed(move |_, _, x, y| { - sender_sc.send(Msg::ScSidebarClick(x, y)).ok(); + gesture.connect_pressed(move |_, n_press, x, y| { + sender_sc.send(Msg::ScSidebarClick(x, y, n_press)).ok(); }); widgets.git_sidebar_da.add_controller(gesture); } @@ -3080,24 +3205,45 @@ impl SimpleComponent for App { col.add_attribute(&name_cell, "text", 1); col.add_attribute(&name_cell, "foreground", 3); - // Handle inline cell editing for rename + // Store name_cell for later use by StartInlineNewFile/Folder handlers + *name_cell_ref.borrow_mut() = Some(name_cell.clone()); + + // Handle inline cell editing for rename and new file/folder creation { let sender_for_edit = sender.clone(); let ts_for_edit = tree_store.clone(); let name_cell_for_cancel = name_cell.clone(); + let ts_for_cancel = tree_store.clone(); + let sender_for_cancel = sender.clone(); name_cell.connect_edited(move |cell, tree_path, new_text| { // Disable editable after edit completes cell.set_property("editable", false); let new_name = new_text.trim(); - if new_name.is_empty() { - return; - } - // Get the old path from the TreeStore (column 2) + // Get the path/marker from the TreeStore (column 2) if let Some(iter) = ts_for_edit.iter(&tree_path) { - let old_path_str: String = + let path_str: String = ts_for_edit.get_value(&iter, 2).get().unwrap_or_default(); - if !old_path_str.is_empty() { - let old_path = PathBuf::from(&old_path_str); + if let Some(parent_dir) = path_str.strip_prefix("__NEW_FILE__") { + // New file creation — remove temporary row + ts_for_edit.remove(&iter); + if !new_name.is_empty() { + sender_for_edit.input(Msg::CreateFile( + PathBuf::from(parent_dir), + new_name.to_string(), + )); + } + } else if let Some(parent_dir) = path_str.strip_prefix("__NEW_FOLDER__") { + // New folder creation — remove temporary row + ts_for_edit.remove(&iter); + if !new_name.is_empty() { + sender_for_edit.input(Msg::CreateFolder( + PathBuf::from(parent_dir), + new_name.to_string(), + )); + } + } else if !new_name.is_empty() && !path_str.is_empty() { + // Regular rename + let old_path = PathBuf::from(&path_str); sender_for_edit.input(Msg::RenameFile(old_path, new_name.to_string())); } } @@ -3105,6 +3251,11 @@ impl SimpleComponent for App { name_cell.connect_editing_canceled(move |_cell| { // Disable editable when editing is cancelled name_cell_for_cancel.set_property("editable", false); + // Remove any temporary new-entry rows + if let Some(iter) = ts_for_cancel.iter_first() { + remove_new_entry_rows(&ts_for_cancel, &iter); + } + sender_for_cancel.input(Msg::RefreshFileTree); }); } @@ -3170,8 +3321,13 @@ impl SimpleComponent for App { let path_buf = PathBuf::from(full_path); if path_buf.is_file() { sender_for_tree.input(Msg::OpenFileFromSidebar(path_buf)); + } else if path_buf.is_dir() { + if tree_view.row_expanded(tree_path) { + tree_view.collapse_row(tree_path); + } else { + tree_view.expand_row(tree_path, false); + } } - // If directory, do nothing for now (expand/collapse works automatically) } } }); @@ -3194,6 +3350,10 @@ impl SimpleComponent for App { let path_buf = PathBuf::from(full_path); if path_buf.is_file() { sender_for_click.input(Msg::PreviewFileFromSidebar(path_buf)); + } else { + // Clicking a directory keeps explorer focus so + // keyboard shortcuts (a/A/D/r) work immediately. + sender_for_click.input(Msg::FocusExplorer); } } } @@ -3275,11 +3435,7 @@ impl SimpleComponent for App { let pd = parent_dir.clone(); let a = gtk4::gio::SimpleAction::new("new_file", None); a.connect_activate(move |_, _| { - let pd2 = pd.clone(); - show_name_prompt_dialog("New File", "", None, { - let s2 = s.clone(); - move |name| s2.input(Msg::CreateFile(pd2.clone(), name)) - }); + s.input(Msg::StartInlineNewFile(pd.clone())); }); add_action(&actions, &a); } @@ -3288,11 +3444,7 @@ impl SimpleComponent for App { let pd = parent_dir.clone(); let a = gtk4::gio::SimpleAction::new("new_folder", None); a.connect_activate(move |_, _| { - let pd2 = pd.clone(); - show_name_prompt_dialog("New Folder", "", None, { - let s2 = s.clone(); - move |name| s2.input(Msg::CreateFolder(pd2.clone(), name)) - }); + s.input(Msg::StartInlineNewFolder(pd.clone())); }); add_action(&actions, &a); } @@ -3753,7 +3905,21 @@ impl SimpleComponent for App { let char_width = cw_cell_resize.get().max(1.0); let total_lines = (height as f64 / line_height).floor() as usize; - let viewport_lines = total_lines.saturating_sub(2); + // Subtract status bar (1) + command line (1) + tab bar (1) + + // breadcrumbs (1 if enabled). The per-window values from + // draw are more accurate; this is just the fallback estimate. + let chrome_rows = { + let e = engine_for_resize.borrow(); + let mut rows = 3usize; // status + cmd + tab bar + if e.settings.breadcrumbs { + rows += 1; + } + if e.settings.hide_single_tab && e.active_group().tabs.len() <= 1 { + rows -= 1; // tab bar hidden + } + rows + }; + let viewport_lines = total_lines.saturating_sub(chrome_rows); // viewport_cols here is a rough estimate used by ensure_cursor_visible. // The accurate wrap column is computed in build_rendered_window from @@ -3808,6 +3974,7 @@ impl SimpleComponent for App { let editor_hover_rect_for_draw = model.editor_hover_popup_rect.clone(); let editor_hover_links_for_draw = model.editor_hover_link_rects.clone(); let mouse_pos_for_draw = mouse_pos_cell.clone(); + let tab_vis_for_draw = tab_visible_counts_cell.clone(); widgets .drawing_area .set_draw_func(move |_, cr, width, height| { @@ -3831,6 +3998,7 @@ impl SimpleComponent for App { &editor_hover_rect_for_draw, &editor_hover_links_for_draw, mouse_pos_for_draw.get(), + &tab_vis_for_draw, ); })); if let Err(e) = result { @@ -4099,8 +4267,11 @@ impl SimpleComponent for App { | Msg::PreviewFileFromSidebar(_) | Msg::CreateFile(_, _) | Msg::CreateFolder(_, _) + | Msg::StartInlineNewFile(_) + | Msg::StartInlineNewFolder(_) + | Msg::ExplorerAction(_) + | Msg::ExplorerActivateSelected | Msg::ConfirmDeletePath(_) - | Msg::DeletePath(_) | Msg::RefreshFileTree | Msg::FocusExplorer | Msg::ToggleFocusExplorer @@ -4374,6 +4545,9 @@ impl SimpleComponent for App { Msg::ToggleMenuBar | Msg::OpenMenu(_) | Msg::CloseMenu + | Msg::MruNavBack + | Msg::MruNavForward + | Msg::OpenCommandCenter | Msg::MenuActivateItem(_, _, _) | Msg::MenuHighlight(_) => { self.handle_menu_msg(msg, &sender); @@ -4383,7 +4557,7 @@ impl SimpleComponent for App { | Msg::DebugSidebarScroll(_) => { self.handle_debug_sidebar_msg(msg); } - Msg::ScSidebarClick(_, _) | Msg::ScSidebarMotion(_, _) | Msg::ScKey(_, _) => { + Msg::ScSidebarClick(_, _, _) | Msg::ScSidebarMotion(_, _) | Msg::ScKey(_, _) => { self.handle_sc_sidebar_msg(msg); } Msg::ExtSidebarKey(_, _) | Msg::ExtSidebarClick(_, _, _) => { @@ -5042,6 +5216,49 @@ impl App { // Route keys to sidebar handlers when a sidebar has focus. // GTK focus on sidebar DrawingAreas is unreliable, so we check // the engine focus flags here (same approach as TUI backend). + + // Explorer sidebar: CRUD keys + navigation + if self.engine.borrow().explorer_has_focus { + let key_mapped = map_gtk_key_name(key_name.as_str()); + if key_mapped == "Escape" { + self.engine.borrow_mut().explorer_has_focus = false; + self.tree_has_focus = false; + if let Some(ref drawing) = *self.drawing_area.borrow() { + drawing.grab_focus(); + } + self.draw_needed.set(true); + return; + } + // Explorer CRUD keys + if !ctrl { + if let Some(ch) = unicode { + let is_crud = self + .engine + .borrow() + .settings + .explorer_keys + .resolve(ch) + .is_some(); + if is_crud { + // Defer to avoid borrow conflicts with start_inline_new_entry + let s = sender.clone(); + gtk4::glib::idle_add_local_once(move || { + s.input(Msg::ExplorerAction(ch.to_string())); + }); + self.draw_needed.set(true); + return; + } + } + } + // Let j/k/Up/Down through to TreeView for navigation + if matches!(key_mapped, "j" | "k" | "Up" | "Down") { + return; // don't consume — let GTK TreeView handle navigation + } + // Other keys while explorer focused — ignore (don't pass to editor) + self.draw_needed.set(true); + return; + } + { let mut engine = self.engine.borrow_mut(); if engine.ext_panel_has_focus { @@ -5191,6 +5408,15 @@ impl App { } } + // Ctrl-W h/l overflow: move focus to explorer sidebar + { + let overflow = self.engine.borrow_mut().window_nav_overflow.take(); + if let Some(false) = overflow { + // Left overflow → focus explorer + sender.input(Msg::FocusExplorer); + } + } + // Sync the unnamed register to the system clipboard if it changed. // The comparison is O(1); actual write is deferred to the background thread. self.sync_plus_register_to_clipboard(); @@ -5207,6 +5433,17 @@ impl App { } fn handle_poll_tick(&mut self, sender: &ComponentSender) { + // Apply tab visible counts reported by the last draw callback. + { + let counts = self.tab_visible_counts.borrow().clone(); + if !counts.is_empty() { + let mut engine = self.engine.borrow_mut(); + for (group_id, count) in &counts { + engine.set_tab_visible_count(*group_id, *count); + } + self.tab_visible_counts.borrow_mut().clear(); + } + } // Reload CSS if the colorscheme changed (e.g. via :colorscheme command). { let current = self.engine.borrow().settings.colorscheme.clone(); @@ -5680,6 +5917,64 @@ impl App { } self.draw_needed.set(true); } else { + // ── Status bar branch click — open branch picker ───────────── + if self.cached_line_height > 0.0 { + let lh = self.cached_line_height; + let engine = self.engine.borrow(); + let wildmenu_px = if engine.wildmenu_items.is_empty() { + 0.0 + } else { + lh + }; + let status_bar_height = lh * 2.0 + wildmenu_px; + let status_y = height - status_bar_height; + if y >= status_y && y < status_y + lh && engine.git_branch.is_some() { + // Reconstruct branch column range (matching build_status_line logic) + let mode_str = engine.mode_str(); + let filename = match engine.file_path() { + Some(p) => p + .file_name() + .map(|f| f.to_string_lossy().into_owned()) + .unwrap_or_else(|| p.display().to_string()), + None => "[No Name]".to_string(), + }; + let dirty = if engine.dirty() { " [+]" } else { "" }; + let recording = if let Some(reg) = engine.macro_recording { + format!(" [recording @{}]", reg) + } else { + String::new() + }; + let prefix = format!(" -- {}{} -- {}{}", mode_str, recording, filename, dirty); + let b = engine.git_branch.as_deref().unwrap(); + let mut branch_text = b.to_string(); + if engine.sc_ahead > 0 || engine.sc_behind > 0 { + let mut parts = Vec::new(); + if engine.sc_ahead > 0 { + parts.push(format!("↑{}", engine.sc_ahead)); + } + if engine.sc_behind > 0 { + parts.push(format!("↓{}", engine.sc_behind)); + } + branch_text = format!("{} {}", branch_text, parts.join(" ")); + } + let branch_str = format!(" [{}]", branch_text); + let start = prefix.len(); + let end = start + branch_str.len(); + let cw = self.cached_char_width.max(1.0); + let click_col = (x / cw) as usize; + drop(engine); + if click_col >= start && click_col < end { + self.engine + .borrow_mut() + .open_picker(crate::core::engine::PickerSource::GitBranches); + self.draw_needed.set(true); + return; + } + } else { + drop(engine); + } + } + // Snapshot the active file path before processing the click so we // can detect tab switches (and only then highlight in the tree). let file_before_click = self.engine.borrow().file_path().cloned(); @@ -6939,6 +7234,18 @@ impl App { } self.draw_needed.set(true); } + Msg::MruNavBack => { + self.engine.borrow_mut().tab_nav_back(); + self.draw_needed.set(true); + } + Msg::OpenCommandCenter => { + self.engine.borrow_mut().open_command_center(); + self.draw_needed.set(true); + } + Msg::MruNavForward => { + self.engine.borrow_mut().tab_nav_forward(); + self.draw_needed.set(true); + } Msg::MenuActivateItem(menu_idx, item_idx, action) => { // Close the menu engine-side for every action. self.engine.borrow_mut().close_menu(); @@ -7170,7 +7477,7 @@ impl App { fn handle_sc_sidebar_msg(&mut self, msg: Msg) { match msg { - Msg::ScSidebarClick(x_click, y) => { + Msg::ScSidebarClick(x_click, y, n_press) => { let lh = self.cached_ui_line_height; if lh <= 0.0 { return; @@ -7292,8 +7599,10 @@ impl App { engine.sc_selected = flat_idx; if is_header { Some("Tab") - } else { + } else if n_press >= 2 { Some("Return") + } else { + None // single-click: just select } } None => None, @@ -8108,10 +8417,13 @@ impl App { fn handle_explorer_msg(&mut self, msg: Msg, sender: &ComponentSender) { match msg { Msg::OpenFileFromSidebar(path) => { - let mut engine = self.engine.borrow_mut(); - // Open in a new tab, or switch to the existing tab that shows this file. - engine.open_file_in_tab(&path); - drop(engine); + { + let mut engine = self.engine.borrow_mut(); + // Open in a new tab, or switch to the existing tab that shows this file. + engine.open_file_in_tab(&path); + engine.explorer_has_focus = false; + } + self.tree_has_focus = false; if let Some(ref tree) = *self.file_tree_view.borrow() { highlight_file_in_tree(tree, &path); } @@ -8220,94 +8532,100 @@ impl App { } self.draw_needed.set(true); } - Msg::ConfirmDeletePath(path) => { - let filename = path - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or("unknown") - .to_string(); - let item_type = if path.is_dir() { "folder" } else { "file" }; - let parent_win = self - .drawing_area - .borrow() - .as_ref() - .and_then(|da| da.root()) - .and_then(|r| r.downcast::().ok()); - let dialog = gtk4::Dialog::with_buttons( - Some("Confirm Delete"), - parent_win.as_ref(), - gtk4::DialogFlags::MODAL | gtk4::DialogFlags::DESTROY_WITH_PARENT, - &[ - ("Delete", gtk4::ResponseType::Accept), - ("Cancel", gtk4::ResponseType::Cancel), - ], - ); - let label = - gtk4::Label::new(Some(&format!("Delete {} '{}'?", item_type, filename))); - label.set_margin_all(12); - dialog.content_area().append(&label); - let s = sender.clone(); - dialog.connect_response(move |dlg, resp| { - if resp == gtk4::ResponseType::Accept { - s.input(Msg::DeletePath(path.clone())); - } - dlg.close(); - }); - dialog.present(); - } - Msg::DeletePath(path) => { - // Get filename for message - let filename = path - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or("unknown"); - - let is_dir = path.is_dir(); - let item_type = if is_dir { "folder" } else { "file" }; - - // Check if path exists - if !path.exists() { - self.engine.borrow_mut().message = format!("'{}' does not exist", filename); - self.draw_needed.set(true); - return; - } - - // Attempt deletion - let result = if is_dir { - std::fs::remove_dir_all(&path) - } else { - std::fs::remove_file(&path) - }; - - match result { - Ok(_) => { - self.engine.borrow_mut().message = - format!("Deleted {}: '{}'", item_type, filename); - - // If deleted file was open, close its buffer - // Find buffer by path and delete it - if !is_dir { - let path_str = path.to_string_lossy(); - let mut engine = self.engine.borrow_mut(); - if let Some(buffer_id) = engine.buffer_manager.find_by_path(&path_str) { - // Delete the buffer (force=true since file is gone anyway) - let _ = engine.delete_buffer(buffer_id, true); + Msg::StartInlineNewFile(parent_dir) => { + let is_folder = false; + self.start_inline_new_entry(parent_dir, is_folder); + } + Msg::StartInlineNewFolder(parent_dir) => { + let is_folder = true; + self.start_inline_new_entry(parent_dir, is_folder); + } + Msg::ExplorerActivateSelected => { + if let Some(ref tv) = *self.file_tree_view.borrow() { + // Try cursor position first (tracks arrow-key navigation), + // fall back to selection. + use gtk4::prelude::TreeViewExt; + let tp = TreeViewExt::cursor(tv).0.or_else(|| { + tv.selection() + .selected() + .map(|(_, iter)| tv.model().unwrap().path(&iter)) + }); + let model = tv.model(); + if let (Some(tp), Some(model)) = (tp, model) { + // Sync selection to cursor so visual highlight matches. + tv.selection().select_path(&tp); + if let Some(iter) = model.iter(&tp) { + let full_path: String = + model.get_value(&iter, 2).get().unwrap_or_default(); + let path_buf = PathBuf::from(&full_path); + if path_buf.is_dir() { + if tv.row_expanded(&tp) { + tv.collapse_row(&tp); + } else { + tv.expand_row(&tp, false); + } + } else if path_buf.is_file() { + sender.input(Msg::OpenFileFromSidebar(path_buf)); } } - - sender.input(Msg::RefreshFileTree); } - Err(e) => { - let msg = match e.kind() { - std::io::ErrorKind::PermissionDenied => { - format!("Permission denied: '{}'", filename) + } + } + Msg::ExplorerAction(key_str) => { + use crate::core::settings::ExplorerAction; + // Resolve the action first, then drop the engine borrow before + // calling methods that may re-borrow (e.g. start_inline_new_entry). + let action = key_str + .chars() + .next() + .and_then(|ch| self.engine.borrow().settings.explorer_keys.resolve(ch)); + if let Some(action) = action { + match action { + ExplorerAction::NewFile => { + let parent_dir = selected_parent_dir_from_app(&self.file_tree_view); + self.start_inline_new_entry(parent_dir, false); + } + ExplorerAction::NewFolder => { + let parent_dir = selected_parent_dir_from_app(&self.file_tree_view); + self.start_inline_new_entry(parent_dir, true); + } + ExplorerAction::Delete => { + if let Some(path) = selected_file_path_from_app(&self.file_tree_view) { + sender.input(Msg::ConfirmDeletePath(path)); } - std::io::ErrorKind::NotFound => format!("'{}' not found", filename), - _ => format!("Error deleting '{}': {}", filename, e), - }; - self.engine.borrow_mut().message = msg; + } + ExplorerAction::Rename => { + // Trigger GTK native inline cell editing + let tv_ref = self.file_tree_view.clone(); + let nc_ref = self.name_cell.clone(); + gtk4::glib::idle_add_local_once(move || { + if let Some(ref tv) = *tv_ref.borrow() { + if let Some(ref nc) = *nc_ref.borrow() { + nc.set_property("editable", true); + if let Some(column) = tv.column(0) { + if let Some((model, iter)) = tv.selection().selected() { + let tree_path = model.path(&iter); + gtk4::prelude::TreeViewExt::set_cursor( + tv, + &tree_path, + Some(&column), + true, + ); + } + } + } + } + }); + } + ExplorerAction::MoveFile => { + // Move not yet supported via keyboard in GTK + // (uses status-line prompt in TUI) + } } } + } + Msg::ConfirmDeletePath(path) => { + self.engine.borrow_mut().confirm_delete_file(&path); self.draw_needed.set(true); } Msg::RefreshFileTree => { @@ -8429,6 +8747,73 @@ impl App { } } + /// Insert a temporary row in the TreeStore and start inline editing for new file/folder. + fn start_inline_new_entry(&self, parent_dir: PathBuf, is_folder: bool) { + // Extract colorscheme before borrowing tree_view to avoid RefCell conflicts. + let colorscheme = self.engine.borrow().settings.colorscheme.clone(); + let theme = Theme::from_name(&colorscheme); + let fg_hex = theme.foreground.to_hex(); + + if let Some(ref tree_view) = *self.file_tree_view.borrow() { + if let Some(model) = tree_view.model() { + if let Some(tree_store) = model.downcast_ref::() { + // Find the parent iter in the tree store + let parent_iter = find_tree_iter_for_path(tree_store, &parent_dir); + + // Expand the parent row if it exists + if let Some(ref pi) = parent_iter { + let path = tree_store.path(pi); + tree_view.expand_row(&path, false); + } + + // Insert a new row as the first child + let new_iter = tree_store.prepend(parent_iter.as_ref()); + let icon = if is_folder { "\u{f07b}" } else { "\u{f15b}" }; + let marker = if is_folder { + format!("__NEW_FOLDER__{}", parent_dir.display()) + } else { + format!("__NEW_FILE__{}", parent_dir.display()) + }; + // Use valid hex colors to avoid GTK "Don't know color ''" warnings + tree_store.set( + &new_iter, + &[ + (0, &icon.to_value()), + (1, &"".to_value()), + (2, &marker.to_value()), + (3, &fg_hex.to_value()), + (4, &"".to_value()), + (5, &fg_hex.to_value()), + ], + ); + + // Start inline editing on the new row. + // Wrapped in catch_unwind because GTK set_cursor with + // start_editing=true can abort the process if it panics + // inside an extern "C" callback. + let tv = tree_view.clone(); + let name_cell_ref = self.name_cell.clone(); + let new_row_path = tree_store.path(&new_iter); + gtk4::glib::idle_add_local_once(move || { + let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + if let Some(ref nc) = *name_cell_ref.borrow() { + nc.set_property("editable", true); + if let Some(column) = tv.column(0) { + gtk4::prelude::TreeViewExt::set_cursor( + &tv, + &new_row_path, + Some(&column), + true, + ); + } + } + })); + }); + } + } + } + } + fn handle_find_replace_msg(&mut self, msg: Msg) { match msg { Msg::ToggleFindDialog => { @@ -9247,6 +9632,25 @@ pub(crate) fn run(file_path: Option) { if std::env::var_os("WAYLAND_DISPLAY").is_none() && std::env::var_os("DISPLAY").is_none() { std::env::set_var("DISPLAY", ":0"); } + + // Install panic hook that flushes swap files + writes crash log. + { + let prev_hook = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |info| { + // Emergency: flush swap files for all dirty buffers. + crate::core::swap::run_emergency_flush(); + + let bt = std::backtrace::Backtrace::force_capture(); + let loc_str = info + .location() + .map(|l| format!(" at {}:{}:{}\n", l.file(), l.line(), l.column())) + .unwrap_or_default(); + let crash_msg = format!("PANIC: {}\n{}backtrace:\n{}\n", info, loc_str, bt); + let _ = std::fs::write("/tmp/vimcode-crash.log", &crash_msg); + prev_hook(info); + })); + } + install_icon_and_desktop(); unsafe { gtk4::glib::ffi::g_log_set_handler( diff --git a/src/gtk/tree.rs b/src/gtk/tree.rs index c5590f7f..973f54da 100644 --- a/src/gtk/tree.rs +++ b/src/gtk/tree.rs @@ -285,43 +285,30 @@ pub(super) fn selected_parent_dir(tv: >k4::TreeView) -> PathBuf { std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")) } -/// Show a modal dialog with a text entry prompting for a name. -/// `title` is the dialog title, `prefill` pre-populates the entry, -/// and `on_accept` is called with the entered text when the user confirms. -pub(super) fn show_name_prompt_dialog( - title: &str, - prefill: &str, - parent: Option<>k4::Window>, - on_accept: F, -) { - let dialog = gtk4::Dialog::with_buttons( - Some(title), - parent, - gtk4::DialogFlags::MODAL | gtk4::DialogFlags::DESTROY_WITH_PARENT, - &[ - ("Create", gtk4::ResponseType::Accept), - ("Cancel", gtk4::ResponseType::Cancel), - ], - ); - let entry = gtk4::Entry::new(); - entry.set_text(prefill); - entry.set_placeholder_text(Some("Enter name…")); - if !prefill.is_empty() { - entry.select_region(0, -1); +/// Like `selected_parent_dir` but takes the `Rc>>` used by `App`. +pub(super) fn selected_parent_dir_from_app( + tv_ref: &std::rc::Rc>>, +) -> PathBuf { + if let Some(ref tv) = *tv_ref.borrow() { + return selected_parent_dir(tv); } - dialog.content_area().append(&entry); - dialog.set_default_response(gtk4::ResponseType::Accept); - entry.set_activates_default(true); - dialog.connect_response(move |dlg, resp| { - if resp == gtk4::ResponseType::Accept { - let name = entry.text().to_string(); - if !name.is_empty() { - on_accept(name); + std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")) +} + +/// Get the full path of the currently selected tree row (from App's Rc). +pub(super) fn selected_file_path_from_app( + tv_ref: &std::rc::Rc>>, +) -> Option { + if let Some(ref tv) = *tv_ref.borrow() { + if let Some((model, iter)) = tv.selection().selected() { + if let Ok(s) = model.get_value(&iter, 2).get::() { + if !s.is_empty() { + return Some(PathBuf::from(s)); + } } } - dlg.close(); - }); - dialog.present(); + } + None } /// Validate filename for file/folder creation @@ -431,3 +418,58 @@ pub(super) fn highlight_file_in_tree(tree_view: >k4::TreeView, file_path: &Pat 0.0, ); } + +/// Find a TreeStore iter whose column 2 (path) matches the given filesystem path. +/// Searches the entire tree recursively. Returns `None` if not found. +pub(super) fn find_tree_iter_for_path( + store: >k4::TreeStore, + target: &Path, +) -> Option { + let target_str = target.to_string_lossy(); + let iter = store.iter_first()?; + find_iter_recursive(store, &iter, &target_str) +} + +fn find_iter_recursive( + store: >k4::TreeStore, + iter: >k4::TreeIter, + target: &str, +) -> Option { + loop { + let path_str: String = store.get_value(iter, 2).get().unwrap_or_default(); + if path_str == target { + return Some(*iter); + } + // Recurse into children + if let Some(child) = store.iter_children(Some(iter)) { + if let Some(found) = find_iter_recursive(store, &child, target) { + return Some(found); + } + } + if !store.iter_next(iter) { + break; + } + } + None +} + +/// Recursively remove any rows with `__NEW_FILE__` or `__NEW_FOLDER__` markers +/// in column 2. Called when inline editing is cancelled. +pub(super) fn remove_new_entry_rows(store: >k4::TreeStore, iter: >k4::TreeIter) { + loop { + let path_str: String = store.get_value(iter, 2).get().unwrap_or_default(); + if path_str.starts_with("__NEW_FILE__") || path_str.starts_with("__NEW_FOLDER__") { + if !store.remove(iter) { + return; // no more siblings + } + continue; // re-check at same position (remove shifts the next row in) + } + // Recurse into children + if let Some(child) = store.iter_children(Some(iter)) { + remove_new_entry_rows(store, &child); + } + if !store.iter_next(iter) { + break; + } + } +} diff --git a/src/render.rs b/src/render.rs index 88fc1d35..cfd143cb 100644 --- a/src/render.rs +++ b/src/render.rs @@ -383,6 +383,8 @@ pub struct GroupTabBar { pub bounds: WindowRect, /// Diff toolbar data, present when the group is showing a diff view. pub diff_toolbar: Option, + /// Index of the first visible tab (scroll offset for overflow tab bars). + pub tab_scroll_offset: usize, } /// One segment in the breadcrumb bar (either a path component or a symbol). @@ -982,6 +984,10 @@ pub struct MenuBarData { pub show_window_controls: bool, /// When true, use `vscode_shortcut` instead of `shortcut` for menu items. pub is_vscode_mode: bool, + /// Whether the back navigation arrow is enabled (history available). + pub nav_back_enabled: bool, + /// Whether the forward navigation arrow is enabled (history available). + pub nav_forward_enabled: bool, } /// One button in the debug toolbar strip. @@ -1578,6 +1584,8 @@ pub struct ScreenLayout { pub windows: Vec, pub status_left: String, pub status_right: String, + /// Byte range within `status_left` where the git branch name appears (for click detection). + pub status_branch_range: Option<(usize, usize)>, pub command: CommandLineData, /// Wildmenu bar (Tab completion in command mode), or `None` when inactive. pub wildmenu: Option, @@ -1629,6 +1637,8 @@ pub struct ScreenLayout { pub context_menu: Option, /// Tab hover tooltip: shortened file path to display near the hovered tab. pub tab_tooltip: Option, + /// Tab scroll offset for the single-group tab bar. + pub tab_scroll_offset: usize, } /// Context menu data for TUI rendering. @@ -1995,7 +2005,7 @@ impl Theme { md_code: Color::from_hex("#98c379"), // green (string-like) md_link: Color::from_hex("#61afef"), // blue - sidebar_sel_bg: Color::from_hex("#2c313a"), // focused: subtle highlight + sidebar_sel_bg: Color::from_hex("#373d4a"), // focused: visible highlight sidebar_sel_bg_inactive: Color::from_hex("#21252b"), // unfocused: very faint semantic_parameter: Color::from_hex("#c8ae9d"), // warm sandy (distinct from variable red) semantic_property: Color::from_hex("#d19a66"), // orange @@ -2015,7 +2025,7 @@ impl Theme { bracket_match_bg: Color::from_hex("#3a3d41"), explorer_dir_fg: Color::from_hex("#61afef"), // function blue - explorer_active_bg: Color::from_hex("#2c313a"), // subtle tint + explorer_active_bg: Color::from_hex("#333842"), // current-file tint } } @@ -2115,7 +2125,7 @@ impl Theme { md_code: Color::from_hex("#b8bb26"), md_link: Color::from_hex("#83a598"), - sidebar_sel_bg: Color::from_hex("#3c3836"), // focused + sidebar_sel_bg: Color::from_hex("#504945"), // focused: visible highlight sidebar_sel_bg_inactive: Color::from_hex("#32302f"), // unfocused semantic_parameter: Color::from_hex("#83a598"), // blue semantic_property: Color::from_hex("#d3869b"), // purple-pink @@ -2135,7 +2145,7 @@ impl Theme { bracket_match_bg: Color::from_hex("#504945"), explorer_dir_fg: Color::from_hex("#83a598"), // gruvbox blue - explorer_active_bg: Color::from_hex("#3c3836"), // subtle tint + explorer_active_bg: Color::from_hex("#45403d"), // current-file tint } } @@ -2235,7 +2245,7 @@ impl Theme { md_code: Color::from_hex("#9ece6a"), md_link: Color::from_hex("#7aa2f7"), - sidebar_sel_bg: Color::from_hex("#292e42"), // focused + sidebar_sel_bg: Color::from_hex("#33395a"), // focused: visible highlight sidebar_sel_bg_inactive: Color::from_hex("#1f2335"), // unfocused semantic_parameter: Color::from_hex("#e0af68"), // orange-gold semantic_property: Color::from_hex("#73daca"), // teal @@ -2255,7 +2265,7 @@ impl Theme { bracket_match_bg: Color::from_hex("#364a82"), explorer_dir_fg: Color::from_hex("#7aa2f7"), // tokyo blue - explorer_active_bg: Color::from_hex("#292e42"), // subtle tint + explorer_active_bg: Color::from_hex("#2f3550"), // current-file tint } } @@ -2355,7 +2365,7 @@ impl Theme { md_code: Color::from_hex("#859900"), md_link: Color::from_hex("#268bd2"), - sidebar_sel_bg: Color::from_hex("#073642"), // focused + sidebar_sel_bg: Color::from_hex("#0a4a5a"), // focused: visible highlight sidebar_sel_bg_inactive: Color::from_hex("#002b36"), // unfocused (base03) semantic_parameter: Color::from_hex("#268bd2"), // blue semantic_property: Color::from_hex("#2aa198"), // cyan @@ -2375,7 +2385,7 @@ impl Theme { bracket_match_bg: Color::from_hex("#0d4a5a"), explorer_dir_fg: Color::from_hex("#268bd2"), // solarized blue - explorer_active_bg: Color::from_hex("#073642"), // subtle tint + explorer_active_bg: Color::from_hex("#0a4050"), // current-file tint } } @@ -2475,7 +2485,7 @@ impl Theme { md_code: Color::from_hex("#ce9178"), md_link: Color::from_hex("#3794ff"), - sidebar_sel_bg: Color::from_hex("#37373d"), + sidebar_sel_bg: Color::from_hex("#04395e"), // focused: visible blue highlight sidebar_sel_bg_inactive: Color::from_hex("#2a2d2e"), semantic_parameter: Color::from_hex("#9cdcfe"), // light blue semantic_property: Color::from_hex("#9cdcfe"), // light blue @@ -2495,7 +2505,7 @@ impl Theme { bracket_match_bg: Color::from_hex("#3a3d41"), explorer_dir_fg: Color::from_hex("#dcdcaa"), // warm yellow (like function names) - explorer_active_bg: Color::from_hex("#37373d"), // subtle tint + explorer_active_bg: Color::from_hex("#2a2d3e"), // current-file tint } } @@ -2594,7 +2604,7 @@ impl Theme { md_code: Color::from_hex("#a31515"), md_link: Color::from_hex("#0066bf"), - sidebar_sel_bg: Color::from_hex("#d6ebff"), + sidebar_sel_bg: Color::from_hex("#b4d9ff"), // focused: visible blue highlight sidebar_sel_bg_inactive: Color::from_hex("#e4e6f1"), semantic_parameter: Color::from_hex("#001080"), // dark blue semantic_property: Color::from_hex("#001080"), // dark blue @@ -2614,7 +2624,7 @@ impl Theme { bracket_match_bg: Color::from_hex("#dddddd"), explorer_dir_fg: Color::from_hex("#795e26"), // warm brown dirs - explorer_active_bg: Color::from_hex("#e8e8e8"), // subtle tint + explorer_active_bg: Color::from_hex("#dce5f0"), // current-file tint } } @@ -3077,7 +3087,7 @@ pub fn build_screen_layout( }) .collect(); - let (status_left, status_right) = build_status_line(engine); + let (status_left, status_right, status_branch_range) = build_status_line(engine); let command = build_command_line(engine); let wildmenu = if engine.wildmenu_items.is_empty() { @@ -3171,9 +3181,13 @@ pub fn build_screen_layout( } else { 0 }; + // Use workspace directory name (not active file) so the centered + // search box stays fixed when switching tabs (like VSCode Command Center). let title = engine - .active_buffer_name() - .map(|n| format!("VimCode \u{2014} {}", n)) + .cwd + .file_name() + .and_then(|n| n.to_str()) + .map(|n| n.to_string()) .unwrap_or_else(|| "VimCode".to_string()); MenuBarData { open_menu_idx: engine.menu_open_idx, @@ -3183,6 +3197,8 @@ pub fn build_screen_layout( title, show_window_controls: false, // GTK backend overrides this is_vscode_mode: engine.is_vscode_mode(), + nav_back_enabled: engine.tab_nav_can_go_back(), + nav_forward_enabled: engine.tab_nav_can_go_forward(), } }); @@ -3513,11 +3529,17 @@ pub fn build_screen_layout( } else { None }; + let tab_scroll_offset = engine + .editor_groups + .get(&gid) + .map(|g| g.tab_scroll_offset) + .unwrap_or(0); GroupTabBar { group_id: gid, tabs, bounds, diff_toolbar, + tab_scroll_offset, } }) .collect(); @@ -3611,6 +3633,7 @@ pub fn build_screen_layout( windows, status_left, status_right, + status_branch_range, command, wildmenu, active_window_id, @@ -3723,6 +3746,11 @@ pub fn build_screen_layout( screen_row: cm.screen_y, }), tab_tooltip: engine.tab_hover_tooltip.clone(), + tab_scroll_offset: engine + .editor_groups + .get(&engine.active_group) + .map(|g| g.tab_scroll_offset) + .unwrap_or(0), } } @@ -6034,7 +6062,7 @@ pub fn calculate_gutter_cols( } } -fn build_status_line(engine: &Engine) -> (String, String) { +fn build_status_line(engine: &Engine) -> (String, String, Option<(usize, usize)>) { let mode_str = engine.mode_str(); let filename = match engine.file_path() { @@ -6053,16 +6081,34 @@ fn build_status_line(engine: &Engine) -> (String, String) { String::new() }; - let branch = engine - .git_branch - .as_deref() - .map(|b| format!(" [{}]", b)) - .unwrap_or_default(); + // Build branch segment with ahead/behind counts + let branch = if let Some(b) = engine.git_branch.as_deref() { + let mut branch_text = b.to_string(); + if engine.sc_ahead > 0 || engine.sc_behind > 0 { + let mut parts = Vec::new(); + if engine.sc_ahead > 0 { + parts.push(format!("↑{}", engine.sc_ahead)); + } + if engine.sc_behind > 0 { + parts.push(format!("↓{}", engine.sc_behind)); + } + branch_text = format!("{} {}", branch_text, parts.join(" ")); + } + format!(" [{}]", branch_text) + } else { + String::new() + }; - let left = format!( - " -- {}{} -- {}{}{}", - mode_str, recording, filename, dirty, branch - ); + let prefix = format!(" -- {}{} -- {}{}", mode_str, recording, filename, dirty); + let branch_range = if branch.is_empty() { + None + } else { + let start = prefix.len(); + let end = start + branch.len(); + Some((start, end)) + }; + + let left = format!("{}{}", prefix, branch); let cursor = engine.cursor(); let (errors, warnings) = engine.diagnostic_counts(); @@ -6079,7 +6125,7 @@ fn build_status_line(engine: &Engine) -> (String, String) { diag_str ); - (left, right) + (left, right, branch_range) } fn build_command_line(engine: &Engine) -> CommandLineData { diff --git a/src/tui_main/mod.rs b/src/tui_main/mod.rs index ffee47bf..861ffb21 100644 --- a/src/tui_main/mod.rs +++ b/src/tui_main/mod.rs @@ -333,25 +333,6 @@ fn collect_rows( } } -/// ─── Prompt kind for CRUD operations ───────────────────────────────────────── -#[derive(Clone, Debug)] -enum PromptKind { - /// New file inside the given directory. - NewFile(PathBuf), - /// New folder inside the given directory. - NewFolder(PathBuf), - DeleteConfirm(PathBuf), - /// Move: source path; input is destination dir (relative to project root). - MoveFile(PathBuf), -} - -/// State for an active sidebar prompt shown in the command line area. -struct SidebarPrompt { - kind: PromptKind, - input: String, - cursor: usize, // byte offset into input -} - // ─── Public entry point ─────────────────────────────────────────────────────── /// State for an active scrollbar drag (vertical or horizontal). @@ -891,6 +872,9 @@ pub fn run(file_path: Option, debug_log_path: Option) { { let prev_hook = std::panic::take_hook(); std::panic::set_hook(Box::new(move |info| { + // Emergency: flush swap files for all dirty buffers before anything else. + crate::core::swap::run_emergency_flush(); + let bt = std::backtrace::Backtrace::force_capture(); let loc_str = info .location() @@ -905,6 +889,13 @@ pub fn run(file_path: Option, debug_log_path: Option) { })); } + // Register engine pointer for emergency swap flush from the panic hook. + // SAFETY: `engine` lives on the stack until process exit; the pointer is + // only dereferenced during panic recovery on the same thread. + unsafe { + crate::core::swap::register_emergency_engine(&engine as *const _); + } + let backend = CrosstermBackend::new(stdout); let mut terminal = Terminal::new(backend).expect("create terminal"); terminal.clear().expect("clear terminal"); @@ -916,6 +907,10 @@ pub fn run(file_path: Option, debug_log_path: Option) { restore_terminal(&mut terminal, keyboard_enhanced); if let Err(e) = result { + // Emergency: flush swap files for all dirty buffers before exiting. + // This preserves unsaved work that would otherwise be lost. + engine.emergency_swap_flush(); + // Extract the panic message before aborting — resume_unwind would call // abort() on Linux (via the default panic handler), producing a core dump. let msg = if let Some(s) = e.downcast_ref::<&str>() { @@ -926,6 +921,7 @@ pub fn run(file_path: Option, debug_log_path: Option) { "VimCode internal error (unknown panic payload)".to_string() }; eprintln!("{msg}"); + eprintln!("Unsaved buffers written to swap files for recovery."); eprintln!("Crash details written to /tmp/vimcode-crash.log"); eprintln!("Please report this at https://github.com/anthropics/claude-code/issues"); std::process::exit(1); @@ -966,7 +962,6 @@ fn event_loop( sidebar.show_hidden_files = engine.settings.show_hidden_files; // Optional active prompt (for sidebar CRUD operations) - let mut sidebar_prompt: Option = None; // Mutable sidebar width (default SIDEBAR_WIDTH, clamped 15..60) let mut sidebar_width: u16 = SIDEBAR_WIDTH; @@ -1060,6 +1055,15 @@ fn event_loop( // Timestamp of the last Alt+t press (for tab switcher auto-confirm on timeout). let mut tab_switcher_last_cycle: Option = None; + // Reveal the active file in the explorer sidebar at startup (session restore). + if let Some(path) = engine.file_path().cloned() { + let h = terminal + .size() + .map(|s| s.height.saturating_sub(4) as usize) + .unwrap_or(40); + sidebar.reveal_path(&path, h); + } + loop { // Refresh theme in case :colorscheme was run. theme = Theme::from_name(&engine.settings.colorscheme); @@ -1098,7 +1102,22 @@ fn event_loop( let content_cols = size .width .saturating_sub(ab_w + sidebar_cols + gutter_approx); - engine.set_viewport_lines(content_rows.saturating_sub(1).max(1) as usize); // -1 for tab bar row inside content_rows + // Compute how many rows the tab bar + breadcrumbs consume. + let tab_bar_rows: u16 = { + let has_single_tab = engine.active_group().tabs.len() <= 1; + if engine.settings.hide_single_tab && has_single_tab { + if engine.settings.breadcrumbs { + 1 + } else { + 0 + } + } else if engine.settings.breadcrumbs { + 2 + } else { + 1 + } + }; + engine.set_viewport_lines(content_rows.saturating_sub(tab_bar_rows).max(1) as usize); engine.set_viewport_cols(content_cols.max(1) as usize); } @@ -1167,6 +1186,7 @@ fn event_loop( } } + let mut tab_visible_counts: Vec<(crate::core::window::GroupId, usize)> = Vec::new(); terminal .draw(|frame| { if let Some(s) = &screen { @@ -1177,7 +1197,6 @@ fn event_loop( &theme, &mut sidebar, engine, - &sidebar_prompt, sidebar_width, quickfix_scroll_top, debug_output_scroll, @@ -1190,10 +1209,16 @@ fn event_loop( &mut hover_popup_rect, &mut editor_hover_popup_rect, &mut editor_hover_link_rects, + &mut tab_visible_counts, ); } }) .expect("draw frame"); + // Report rendered tab counts back to the engine so that + // ensure_active_tab_visible() knows how many tabs fit. + for (gid, count) in &tab_visible_counts { + engine.set_tab_visible_count(*gid, *count); + } // Set terminal cursor shape to match mode / pending key. let cursor_style = if !sidebar.has_focus && engine.pending_key == Some('r') { @@ -1603,94 +1628,16 @@ fn event_loop( continue; } - // ── Prompt mode (sidebar CRUD) ────────────────────────────── - if let Some(ref mut prompt) = sidebar_prompt { - match key_event.code { - KeyCode::Esc => { - sidebar_prompt = None; - } - KeyCode::Enter => { - let input = prompt.input.clone(); - let kind = prompt.kind.clone(); - sidebar_prompt = None; - let vh = terminal - .size() - .map(|s| s.height.saturating_sub(4) as usize) - .unwrap_or(40); - handle_sidebar_prompt(engine, &mut sidebar, kind, input, vh); - } - KeyCode::Backspace => { - if prompt.cursor > 0 { - // Find the previous char boundary - let prev = prompt.input[..prompt.cursor] - .char_indices() - .next_back() - .map(|(i, _)| i) - .unwrap_or(0); - prompt.input.remove(prev); - prompt.cursor = prev; - } - } - KeyCode::Delete => { - if prompt.cursor < prompt.input.len() { - prompt.input.remove(prompt.cursor); - } - } - KeyCode::Left => { - if prompt.cursor > 0 { - prompt.cursor = prompt.input[..prompt.cursor] - .char_indices() - .next_back() - .map(|(i, _)| i) - .unwrap_or(0); - } - } - KeyCode::Right => { - if prompt.cursor < prompt.input.len() { - let rest = &prompt.input[prompt.cursor..]; - let next = rest - .char_indices() - .nth(1) - .map(|(i, _)| prompt.cursor + i) - .unwrap_or(prompt.input.len()); - prompt.cursor = next; - } - } - KeyCode::Home => { - prompt.cursor = 0; - } - KeyCode::End => { - prompt.cursor = prompt.input.len(); - } - KeyCode::Char(c) - if key_event.kind != KeyEventKind::Release - && !key_event.modifiers.contains(KeyModifiers::CONTROL) => - { - // For delete confirm only accept y/n - if matches!(prompt.kind, PromptKind::DeleteConfirm(_)) { - if c == 'y' || c == 'n' { - let kind = prompt.kind.clone(); - sidebar_prompt = None; - if c == 'y' { - let vh = terminal - .size() - .map(|s| s.height.saturating_sub(3) as usize) - .unwrap_or(40); - handle_sidebar_prompt( - engine, - &mut sidebar, - kind, - "y".to_string(), - vh, - ); - } - } - } else { - prompt.input.insert(prompt.cursor, c); - prompt.cursor += c.len_utf8(); - } + // ── Inline new file/folder in explorer ─────────────────────── + if engine.explorer_new_entry.is_some() { + if let Some((key_name, unicode, ctrl)) = + translate_key(key_event, keyboard_enhanced) + { + engine.handle_explorer_new_entry_key(&key_name, unicode, ctrl); + if engine.explorer_needs_refresh { + sidebar.build_rows(); + engine.explorer_needs_refresh = false; } - _ => {} } needs_redraw = true; continue; @@ -1712,6 +1659,13 @@ fn event_loop( engine.open_folder(&path); sidebar = TuiSidebar::new(engine.cwd.clone(), sidebar.visible); sidebar.show_hidden_files = engine.settings.show_hidden_files; + if let Some(fp) = engine.file_path().cloned() { + let h = terminal + .size() + .map(|s| s.height.saturating_sub(4) as usize) + .unwrap_or(40); + sidebar.reveal_path(&fp, h); + } } } else { // Check if ".." was selected — navigate up instead of opening @@ -1732,6 +1686,14 @@ fn event_loop( } sidebar = TuiSidebar::new(engine.cwd.clone(), sidebar.visible); sidebar.show_hidden_files = engine.settings.show_hidden_files; + // Reveal the active file from the restored session + if let Some(path) = engine.file_path().cloned() { + let h = terminal + .size() + .map(|s| s.height.saturating_sub(4) as usize) + .unwrap_or(40); + sidebar.reveal_path(&path, h); + } } } } @@ -2676,38 +2638,20 @@ fn event_loop( sidebar.root.clone() } }; - // Pre-fill with target dir relative to root + / - let prefill = target_dir - .strip_prefix(&sidebar.root) - .unwrap_or(&target_dir) - .to_string_lossy() - .to_string(); - let prefill = if prefill.is_empty() { - String::new() - } else { - format!("{}/", prefill) - }; - let kind = if action == ExplorerAction::NewFile { - PromptKind::NewFile(sidebar.root.clone()) + // Expand the target dir so the new entry row is visible + sidebar.expanded.insert(target_dir.clone()); + sidebar.build_rows(); + if action == ExplorerAction::NewFile { + engine.start_explorer_new_file(target_dir); } else { - PromptKind::NewFolder(sidebar.root.clone()) - }; - let cursor = prefill.len(); - sidebar_prompt = Some(SidebarPrompt { - kind, - input: prefill, - cursor, - }); + engine.start_explorer_new_folder(target_dir); + } } ExplorerAction::Delete => { let idx = sidebar.selected; if idx < sidebar.rows.len() { let path = sidebar.rows[idx].path.clone(); - sidebar_prompt = Some(SidebarPrompt { - kind: PromptKind::DeleteConfirm(path), - input: String::new(), - cursor: 0, - }); + engine.confirm_delete_file(&path); } } ExplorerAction::Rename => { @@ -2721,18 +2665,8 @@ fn event_loop( let idx = sidebar.selected; if idx < sidebar.rows.len() { let path = sidebar.rows[idx].path.clone(); - // Pre-fill with full relative path from root - let prefill = path - .strip_prefix(&sidebar.root) - .unwrap_or(&path) - .to_string_lossy() - .to_string(); - let cursor = prefill.len(); - sidebar_prompt = Some(SidebarPrompt { - kind: PromptKind::MoveFile(path), - input: prefill, - cursor, - }); + let root = sidebar.root.clone(); + engine.start_move_file_dialog(&path, &root); } } } @@ -2973,6 +2907,16 @@ fn event_loop( needs_redraw = true; continue; } + if matches_tui_key(&pk.nav_back, code, mods) { + engine.tab_nav_back(); + needs_redraw = true; + continue; + } + if matches_tui_key(&pk.nav_forward, code, mods) { + engine.tab_nav_forward(); + needs_redraw = true; + continue; + } } // Escape when menu dropdown is open: close it @@ -3351,8 +3295,8 @@ fn event_loop( && intercept_paste_key(engine, unicode == Some('P')); // ── Context menu keyboard intercept (TUI-side) ────────── - // Handle here so explorer actions (new_file etc.) can set - // sidebar_prompt, which the engine doesn't know about. + // Handle here so explorer actions (new_file etc.) can be + // dispatched to the engine's dialog system. if engine.context_menu.is_some() { let effective_key = if key_name.is_empty() { unicode.map(|c| c.to_string()).unwrap_or_default() @@ -3366,7 +3310,6 @@ fn event_loop( &act, engine, &sidebar, - &mut sidebar_prompt, terminal.size().ok(), ); } @@ -3455,6 +3398,9 @@ fn event_loop( if sidebar.visible { sidebar.has_focus = true; match sidebar.active_panel { + TuiPanel::Explorer => { + engine.explorer_has_focus = true; + } TuiPanel::Git => engine.sc_has_focus = true, TuiPanel::Debug => engine.dap_sidebar_has_focus = true, TuiPanel::Extensions => { @@ -3559,7 +3505,6 @@ fn event_loop( &mut dragging_settings_sb, &mut dragging_generic_sb, last_layout.as_ref(), - &mut sidebar_prompt, &mut last_click_time, &mut last_click_pos, &mut mouse_text_drag, @@ -3611,7 +3556,6 @@ fn event_loop( &mut dragging_settings_sb, &mut dragging_generic_sb, last_layout.as_ref(), - &mut sidebar_prompt, &mut last_click_time, &mut last_click_pos, &mut mouse_text_drag, @@ -3778,7 +3722,6 @@ fn handle_explorer_context_action( action: &str, engine: &mut Engine, sidebar: &TuiSidebar, - sidebar_prompt: &mut Option, terminal_size: Option, ) { // Get the path from the engine's last context menu target. @@ -3794,41 +3737,21 @@ fn handle_explorer_context_action( match action { "new_file" | "new_folder" => { let target = if is_dir { - &path - } else { - path.parent().unwrap_or(&sidebar.root) - }; - let prefill = target - .strip_prefix(&sidebar.root) - .unwrap_or(target) - .to_string_lossy() - .to_string(); - let prefill = if prefill.is_empty() { - String::new() + path.clone() } else { - format!("{}/", prefill) + path.parent().unwrap_or(&sidebar.root).to_path_buf() }; - let kind = if action == "new_file" { - PromptKind::NewFile(sidebar.root.clone()) + if action == "new_file" { + engine.start_explorer_new_file(target); } else { - PromptKind::NewFolder(sidebar.root.clone()) - }; - let cursor = prefill.len(); - *sidebar_prompt = Some(SidebarPrompt { - kind, - input: prefill, - cursor, - }); + engine.start_explorer_new_folder(target); + } } "rename" => { engine.start_explorer_rename(path); } "delete" => { - *sidebar_prompt = Some(SidebarPrompt { - kind: PromptKind::DeleteConfirm(path), - input: String::new(), - cursor: 0, - }); + engine.confirm_delete_file(&path); } // copy_path, copy_relative_path, reveal, open_side, open_side_vsplit handled by engine "copy_path" | "copy_relative_path" | "reveal" | "open_side" | "open_side_vsplit" => {} diff --git a/src/tui_main/mouse.rs b/src/tui_main/mouse.rs index 3277f21b..c6f4dd38 100644 --- a/src/tui_main/mouse.rs +++ b/src/tui_main/mouse.rs @@ -22,7 +22,6 @@ pub(super) fn handle_mouse( dragging_settings_sb: &mut Option, dragging_generic_sb: &mut Option, last_layout: Option<&render::ScreenLayout>, - sidebar_prompt: &mut Option, last_click_time: &mut Instant, last_click_pos: &mut (u16, u16), mouse_text_drag: &mut bool, @@ -762,8 +761,10 @@ pub(super) fn handle_mouse( let gw = gtb.bounds.width as u16; if row == tab_bar_row && rel_col >= gx && rel_col < gx + gw { let local_col = rel_col - gx; - let mut x: u16 = 0; - for (i, tab) in gtb.tabs.iter().enumerate() { + let ov_cols: u16 = if gtb.tab_scroll_offset > 0 { 2 } else { 0 }; + let mut x: u16 = ov_cols; + for (i, tab) in gtb.tabs.iter().enumerate().skip(gtb.tab_scroll_offset) + { let name_w = tab.name.chars().count() as u16; let tab_w = name_w + TAB_CLOSE_COLS; if local_col >= x && local_col < x + tab_w { @@ -778,8 +779,10 @@ pub(super) fn handle_mouse( } else { // Single-group tab bar (row == menu_rows) if row == menu_rows && !engine.is_tab_bar_hidden(engine.active_group) { - let mut x: u16 = 0; - for (i, tab) in layout.tab_bar.iter().enumerate() { + let sg_offset = layout.tab_scroll_offset; + let ov_cols: u16 = if sg_offset > 0 { 2 } else { 0 }; + let mut x: u16 = ov_cols; + for (i, tab) in layout.tab_bar.iter().enumerate().skip(sg_offset) { let name_w = tab.name.chars().count() as u16; let tab_w = name_w + TAB_CLOSE_COLS; if rel_col >= x && rel_col < x + tab_w { @@ -829,7 +832,6 @@ pub(super) fn handle_mouse( &act, engine, sidebar, - sidebar_prompt, *terminal_size, ); } @@ -992,14 +994,24 @@ pub(super) fn handle_mouse( let gw = gtb.bounds.width as u16; if row == tab_bar_row && rel_col >= gx && rel_col < gx + gw { let local_col = rel_col - gx; - tooltip = - tab_tooltip_at_col(engine, gtb.group_id, local_col, >b.tabs); + tooltip = tab_tooltip_at_col( + engine, + gtb.group_id, + local_col, + >b.tabs, + gtb.tab_scroll_offset, + ); break; } } } else if row == menu_rows && !engine.is_tab_bar_hidden(engine.active_group) { - tooltip = - tab_tooltip_at_col(engine, engine.active_group, rel_col, &layout.tab_bar); + tooltip = tab_tooltip_at_col( + engine, + engine.active_group, + rel_col, + &layout.tab_bar, + layout.tab_scroll_offset, + ); } } } @@ -1133,8 +1145,24 @@ pub(super) fn handle_mouse( } } - // Bottom 2 rows are status + cmd — ignore - if row + 2 >= term_height { + // ── Status bar branch click — open branch picker ─────────────────────── + if row + 2 == term_height { + if let MouseEventKind::Down(MouseButton::Left) = ev.kind { + if let Some(layout) = last_layout { + if let Some((start, end)) = layout.status_branch_range { + let click_col = col as usize; + if click_col >= start && click_col < end { + engine.open_picker(crate::core::engine::PickerSource::GitBranches); + return sidebar_width; + } + } + } + } + return sidebar_width; + } + + // Bottom row is cmd — ignore + if row + 1 >= term_height { return sidebar_width; } @@ -1153,6 +1181,41 @@ pub(super) fn handle_mouse( } col_pos += item_w; } + // Nav arrows + search box are centered between menu_end and right edge. + let menu_end = col_pos; + let arrows_w: u16 = 4; // "◀ ▶ " + let title = engine + .cwd + .file_name() + .and_then(|n| n.to_str()) + .map(|n| n.to_string()) + .unwrap_or_else(|| "VimCode".to_string()); + let display = format!("\u{1f50d} {}", title); + let text_len = display.chars().count() as u16; + let box_width = if !title.is_empty() { text_len + 4 } else { 0 }; + let gap: u16 = if box_width > 0 { 1 } else { 0 }; + let total_unit = arrows_w + gap + box_width; + let term_w = terminal_size.map(|r| r.width).unwrap_or(80); + let available = term_w.saturating_sub(menu_end); + if available >= total_unit + 2 { + let unit_start = menu_end + (available - total_unit) / 2; + // Back arrow at unit_start, forward at unit_start+2 + if col == unit_start { + engine.tab_nav_back(); + return sidebar_width; + } + if col == unit_start + 2 { + engine.tab_nav_forward(); + return sidebar_width; + } + // Search box area: from arrows_w past unit_start to end of box + let search_start = unit_start + arrows_w + gap; + let search_end = unit_start + total_unit; + if col >= search_start && col < search_end { + engine.open_command_center(); + return sidebar_width; + } + } engine.close_menu(); // click in empty area of menu bar return sidebar_width; } @@ -1549,6 +1612,8 @@ pub(super) fn handle_mouse( } } } else if sidebar.active_panel == TuiPanel::Explorer { + sidebar.has_focus = true; + engine.explorer_has_focus = true; // tree_height = (total height - 2 status rows) - 1 header row let tree_height = term_height.saturating_sub(3) as usize; let total_rows = sidebar.rows.len(); @@ -1579,40 +1644,27 @@ pub(super) fn handle_mouse( match btn { 0 | 1 if idx < sidebar.rows.len() => { let target = if selected_is_dir { - &sidebar.rows[idx].path + sidebar.rows[idx].path.clone() } else { - sidebar.rows[idx].path.parent().unwrap_or(&sidebar.root) + sidebar.rows[idx] + .path + .parent() + .unwrap_or(&sidebar.root) + .to_path_buf() }; - let prefill = target - .strip_prefix(&sidebar.root) - .unwrap_or(target) - .to_string_lossy() - .to_string(); - let prefill = if prefill.is_empty() { - String::new() - } else { - format!("{}/", prefill) - }; - let kind = if btn == 0 { - PromptKind::NewFile(sidebar.root.clone()) + // Expand the target dir so the new entry row is visible + sidebar.expanded.insert(target.clone()); + sidebar.build_rows(); + if btn == 0 { + engine.start_explorer_new_file(target); } else { - PromptKind::NewFolder(sidebar.root.clone()) - }; - let cursor = prefill.len(); - *sidebar_prompt = Some(SidebarPrompt { - kind, - input: prefill, - cursor, - }); + engine.start_explorer_new_folder(target); + } } 2 => { if idx < sidebar.rows.len() { let path = sidebar.rows[idx].path.clone(); - *sidebar_prompt = Some(SidebarPrompt { - kind: PromptKind::DeleteConfirm(path), - input: String::new(), - cursor: 0, - }); + engine.confirm_delete_file(&path); } } _ => {} @@ -1764,11 +1816,17 @@ pub(super) fn handle_mouse( if is_header { engine.handle_sc_key("Tab", false, None); } else { - // Open tab immediately, diff arrives - // asynchronously via poll_sc_diff. - engine.sc_open_selected_async(); - engine.sc_has_focus = true; - sidebar.has_focus = true; + let now = Instant::now(); + let is_double = now.duration_since(*last_click_time) + < Duration::from_millis(400) + && *last_click_pos == (col, row); + *last_click_time = now; + *last_click_pos = (col, row); + if is_double { + engine.sc_open_selected_async(); + engine.sc_has_focus = true; + sidebar.has_focus = true; + } } } } @@ -2003,6 +2061,7 @@ pub(super) fn handle_mouse( >b.tabs, gtb.diff_toolbar.as_ref(), was_active, + gtb.tab_scroll_offset, )); break; } @@ -2014,6 +2073,7 @@ pub(super) fn handle_mouse( group_tabs, diff_toolbar_ref, was_active, + scroll_offset, )) = matched_group { engine.active_group = group_id; @@ -2022,7 +2082,7 @@ pub(super) fn handle_mouse( let mut tab_matched = false; // Collect tab hit info from immutable borrow, then apply mutably. let mut hit_info: Option<(usize, bool)> = None; - for (i, tab) in group_tabs.iter().enumerate() { + for (i, tab) in group_tabs.iter().enumerate().skip(scroll_offset) { let name_width = tab.name.chars().count() as u16; let tab_width = name_width + TAB_CLOSE_COLS; if local_col >= x && local_col < x + tab_width { @@ -2040,18 +2100,19 @@ pub(super) fn handle_mouse( x += tab_width; } if let Some((tab_idx, is_close)) = hit_info { - if let Some(g) = engine.editor_groups.get_mut(&group_id) { - g.active_tab = tab_idx; - } engine.active_group = group_id; - engine.line_annotations.clear(); if is_close { + if let Some(g) = engine.editor_groups.get_mut(&group_id) { + g.active_tab = tab_idx; + } + engine.line_annotations.clear(); if engine.dirty() { *close_tab_confirm = true; } else { engine.close_tab(); } } else { + engine.goto_tab(tab_idx); // Record drag start position for tab drag-and-drop. *tab_drag_start = Some((col, row)); engine.lsp_ensure_active_buffer(); @@ -2129,9 +2190,11 @@ pub(super) fn handle_mouse( .saturating_sub(editor_left); let bar_width = editor_col_width; let local_col = rel_col; + let scroll_offset = layout.tab_scroll_offset; + let mut x: u16 = 0; let mut tab_matched = false; - for (i, tab) in layout.tab_bar.iter().enumerate() { + for (i, tab) in layout.tab_bar.iter().enumerate().skip(scroll_offset) { let name_width = tab.name.chars().count() as u16; let tab_width = name_width + TAB_CLOSE_COLS; if local_col >= x && local_col < x + tab_width { @@ -2147,8 +2210,7 @@ pub(super) fn handle_mouse( engine.close_tab(); } } else { - engine.active_group_mut().active_tab = i; - engine.line_annotations.clear(); + engine.goto_tab(i); // Record drag start position for tab drag-and-drop. *tab_drag_start = Some((col, row)); engine.lsp_ensure_active_buffer(); diff --git a/src/tui_main/panels.rs b/src/tui_main/panels.rs index 94881fd2..33ef9955 100644 --- a/src/tui_main/panels.rs +++ b/src/tui_main/panels.rs @@ -256,14 +256,44 @@ pub(super) fn render_sidebar( // ── Tree rows ──────────────────────────────────────────────────────── let tree_height = area.height.saturating_sub(1) as usize; - let visible_rows = sidebar - .rows - .iter() - .enumerate() - .skip(sidebar.scroll_top) - .take(tree_height); - for (i, (row_idx, row)) in visible_rows.enumerate() { + // Determine where a new-entry row should be inserted (right after parent dir). + // `new_entry_after_row` is the sidebar.rows index after which we inject the + // virtual new-entry row. `None` = no active new entry, or parent is root + // (insert at index 0 visually, before all rows). + let new_entry_insert = engine.explorer_new_entry.as_ref().map(|ne| { + // Find the parent dir row index, or usize::MAX for "before all rows" + sidebar + .rows + .iter() + .position(|r| r.is_dir && r.path == ne.parent_dir) + }); + // `true` if parent is root (no matching row — insert before first row) + let new_entry_at_top = new_entry_insert == Some(None); + let new_entry_after_idx = new_entry_insert.and_then(|opt| opt); + + // We manually iterate to interleave the virtual new-entry row. + let mut visual_row = 0usize; + let mut row_iter_idx = sidebar.scroll_top; + // If new entry goes at top and scroll_top == 0, render it first + let mut new_entry_rendered = engine.explorer_new_entry.is_none(); + + // Handle new-entry-at-top: if scroll_top == 0, render the new entry first + if new_entry_at_top && !new_entry_rendered && sidebar.scroll_top == 0 { + let ne = engine.explorer_new_entry.as_ref().unwrap(); + let screen_y = area.y + 1; + // depth 0: parent is root, so child is at depth 0 + render_new_entry_row(buf, area, screen_y, ne, 0, theme); + visual_row += 1; + new_entry_rendered = true; + } + + while visual_row < tree_height && row_iter_idx < sidebar.rows.len() { + let row_idx = row_iter_idx; + let row = &sidebar.rows[row_iter_idx]; + row_iter_idx += 1; + + let i = visual_row; let screen_y = area.y + 1 + i as u16; if screen_y >= area.y + area.height { break; @@ -278,6 +308,7 @@ pub(super) fn render_sidebar( let is_selected = row_idx == sidebar.selected; let is_drop_target = explorer_drop_target == Some(row_idx); let is_active = !row.is_dir + && !engine.explorer_has_focus && active_path.as_ref().is_some_and(|ap| { row.path.canonicalize().unwrap_or_else(|_| row.path.clone()) == *ap }); @@ -452,6 +483,24 @@ pub(super) fn render_sidebar( } } } + + visual_row += 1; + + // Inject virtual new-entry row after the parent dir row + if !new_entry_rendered { + if let Some(after_idx) = new_entry_after_idx { + if row_idx == after_idx && visual_row < tree_height { + let ne = engine.explorer_new_entry.as_ref().unwrap(); + let parent_depth = row.depth; + let screen_y = area.y + 1 + visual_row as u16; + if screen_y < area.y + area.height { + render_new_entry_row(buf, area, screen_y, ne, parent_depth, theme); + visual_row += 1; + } + new_entry_rendered = true; + } + } + } } // Vertical scrollbar (rightmost column, tree rows only — not header) @@ -480,6 +529,74 @@ pub(super) fn render_sidebar( } } +/// Render the inline new-file/folder entry row in the explorer tree. +fn render_new_entry_row( + buf: &mut ratatui::buffer::Buffer, + area: Rect, + screen_y: u16, + entry: &crate::core::engine::ExplorerNewEntryState, + depth: usize, + theme: &Theme, +) { + let input_bg = rc(theme.background); + let input_fg = rc(theme.foreground); + let dim_fg = rc(theme.line_number_fg); + let row_bg = rc(theme.tab_bar_bg); + + // Clear row + for x in area.x..area.x + area.width { + set_cell(buf, x, screen_y, ' ', input_fg, row_bg); + } + + let mut x = area.x; + + // Indent (child of parent, so depth + 1) + let indent = " ".repeat(depth + 1); + for ch in indent.chars() { + if x >= area.x + area.width { + break; + } + set_cell(buf, x, screen_y, ch, dim_fg, row_bg); + x += 1; + } + + // Icon prefix + let icon_str = if entry.is_folder { + "\u{f07b} " // folder icon + } else { + " \u{f15b} " // file icon with spacing + }; + for ch in icon_str.chars() { + if x >= area.x + area.width { + break; + } + set_cell(buf, x, screen_y, ch, dim_fg, row_bg); + x += 1; + } + + // Editable input with inverted cursor + for (byte_idx, ch) in entry.input.char_indices() { + if x >= area.x + area.width { + break; + } + let is_cursor = byte_idx == entry.cursor; + let cell_fg = if is_cursor { input_bg } else { input_fg }; + let cell_bg = if is_cursor { input_fg } else { input_bg }; + set_cell(buf, x, screen_y, ch, cell_fg, cell_bg); + x += 1; + } + // Cursor at end of input (append position) + if entry.cursor >= entry.input.len() && x < area.x + area.width { + set_cell(buf, x, screen_y, ' ', input_bg, input_fg); + x += 1; + } + // Fill remaining width with input background + while x < area.x + area.width { + set_cell(buf, x, screen_y, ' ', input_fg, input_bg); + x += 1; + } +} + /// Render the settings panel — shows current key settings and the file path. pub(super) fn render_settings_panel( buf: &mut ratatui::buffer::Buffer, @@ -1226,48 +1343,6 @@ pub(super) fn render_search_panel( } } -/// Render a one-line prompt in the command area (used for sidebar CRUD input). -pub(super) fn render_prompt_line( - buf: &mut ratatui::buffer::Buffer, - area: Rect, - text: &str, - cursor_char_pos: usize, - theme: &Theme, -) { - let fg = rc(theme.command_fg); - let bg = rc(theme.command_bg); - for x in area.x..area.x + area.width { - set_cell(buf, x, area.y, ' ', fg, bg); - } - let mut x = area.x; - let mut char_idx = 0; - let mut cursor_x = None; - for ch in text.chars() { - if x >= area.x + area.width { - break; - } - if char_idx == cursor_char_pos { - cursor_x = Some(x); - } - set_cell(buf, x, area.y, ch, fg, bg); - x += 1; - char_idx += 1; - } - // If cursor is at the end (past all chars) - if cursor_x.is_none() && char_idx == cursor_char_pos { - cursor_x = Some(x); - } - // Show cursor (inverted colors) - if let Some(cx) = cursor_x { - if cx < area.x + area.width { - let cell = buf.get_mut(cx, area.y); - let old_fg = cell.fg; - let old_bg = cell.bg; - cell.set_fg(old_bg).set_bg(old_fg); - } - } -} - // ─── Wildmenu (command Tab completion bar) ─────────────────────────────────── pub(super) fn render_wildmenu( diff --git a/src/tui_main/render_impl.rs b/src/tui_main/render_impl.rs index 1a82ba56..87f92f33 100644 --- a/src/tui_main/render_impl.rs +++ b/src/tui_main/render_impl.rs @@ -86,7 +86,6 @@ pub(super) fn draw_frame( theme: &Theme, sidebar: &mut TuiSidebar, engine: &Engine, - sidebar_prompt: &Option, sidebar_width: u16, quickfix_scroll_top: usize, debug_output_scroll: usize, @@ -99,6 +98,7 @@ pub(super) fn draw_frame( hover_popup_rect_out: &mut Option<(u16, u16, u16, u16)>, editor_hover_popup_rect_out: &mut Option<(u16, u16, u16, u16)>, editor_hover_link_rects_out: &mut Vec<(u16, u16, u16, u16, String)>, + tab_visible_counts_out: &mut Vec<(GroupId, usize)>, ) { let area = frame.size(); @@ -248,9 +248,7 @@ pub(super) fn draw_frame( let tab_x = gtb.bounds.x as u16 + editor_area.x; let tab_w = gtb.bounds.width as u16; let is_active = gtb.group_id == split.active_group; - // In diff mode, show split buttons on all groups so clicking - // an inactive group's toolbar doesn't cause a visual shift. - let show_split = is_active || engine.is_in_diff_view(); + let show_split = is_active; if tab_w > 0 { let bar_y = editor_area.y + (gtb.bounds.y as u16).saturating_sub(tui_tbh); let g_tab = Rect { @@ -259,14 +257,16 @@ pub(super) fn draw_frame( width: tab_w, height: 1, }; - render_tab_bar( + let vis = render_tab_bar( frame.buffer_mut(), g_tab, >b.tabs, theme, show_split, gtb.diff_toolbar.as_ref(), + gtb.tab_scroll_offset, ); + tab_visible_counts_out.push((gtb.group_id, vis)); } } // Draw breadcrumb bars (below each group's tab bar). @@ -317,14 +317,16 @@ pub(super) fn draw_frame( width: editor_area.width, height: 1, }; - render_tab_bar( + let vis = render_tab_bar( frame.buffer_mut(), tab_rect, &screen.tab_bar, theme, true, screen.diff_toolbar.as_ref(), + screen.tab_scroll_offset, ); + tab_visible_counts_out.push((engine.active_group, vis)); } // Draw breadcrumb bar for the single group. if let Some(bc) = screen.breadcrumbs.first() { @@ -552,44 +554,19 @@ pub(super) fn draw_frame( theme, ); - if let Some(prompt) = sidebar_prompt { - let (prefix, input_cursor) = match &prompt.kind { - PromptKind::NewFile(_) => ("New file: ".to_string(), prompt.cursor), - PromptKind::NewFolder(_) => ("New folder: ".to_string(), prompt.cursor), - PromptKind::DeleteConfirm(path) => { - let name = path - .file_name() - .map(|n| n.to_string_lossy().to_string()) - .unwrap_or_default(); - (format!("Delete '{}'? (y/n)", name), 0) - } - PromptKind::MoveFile(_) => ("Move to: ".to_string(), prompt.cursor), - }; - let prompt_text = format!("{}{}", prefix, prompt.input); - // Cursor position in rendered chars: prefix char count + cursor char count - let cursor_char_pos = prefix.chars().count() + prompt.input[..input_cursor].chars().count(); - render_prompt_line( - frame.buffer_mut(), - cmd_area, - &prompt_text, - cursor_char_pos, - theme, - ); - } else { - render_command_line(frame.buffer_mut(), cmd_area, &screen.command, theme); - // Highlight command-line mouse selection (invert fg/bg for selected cells) - if let Some((start, end)) = cmd_sel { - let lo = start.min(end); - let hi = start.max(end); - let buf = frame.buffer_mut(); - for i in lo..=hi { - let cx = cmd_area.x + i as u16; - if cx < cmd_area.x + cmd_area.width { - let cell = buf.get_mut(cx, cmd_area.y); - let old_fg = cell.fg; - let old_bg = cell.bg; - cell.set_fg(old_bg).set_bg(old_fg); - } + render_command_line(frame.buffer_mut(), cmd_area, &screen.command, theme); + // Highlight command-line mouse selection (invert fg/bg for selected cells) + if let Some((start, end)) = cmd_sel { + let lo = start.min(end); + let hi = start.max(end); + let buf = frame.buffer_mut(); + for i in lo..=hi { + let cx = cmd_area.x + i as u16; + if cx < cmd_area.x + cmd_area.width { + let cell = buf.get_mut(cx, cmd_area.y); + let old_fg = cell.fg; + let old_bg = cell.bg; + cell.set_fg(old_bg).set_bg(old_fg); } } } @@ -642,85 +619,6 @@ pub(super) fn draw_frame( } } -// ─── Sidebar CRUD handling ──────────────────────────────────────────────────── - -pub(super) fn handle_sidebar_prompt( - engine: &mut Engine, - sidebar: &mut TuiSidebar, - kind: PromptKind, - input: String, - viewport_height: usize, -) { - match kind { - PromptKind::NewFile(target_dir) => { - let name = input.trim(); - if !name.is_empty() { - let path = target_dir.join(name); - if let Err(e) = fs::write(&path, "") { - engine.message = format!("Error creating file: {}", e); - } else { - sidebar.reveal_path(&path, viewport_height); - if let Err(e) = engine.open_file_with_mode(&path, OpenMode::Permanent) { - engine.message = e; - } - } - } - } - PromptKind::NewFolder(target_dir) => { - let name = input.trim(); - if !name.is_empty() { - let path = target_dir.join(name); - if let Err(e) = fs::create_dir_all(&path) { - engine.message = format!("Error creating folder: {}", e); - } else { - sidebar.reveal_path(&path, viewport_height); - } - } - } - PromptKind::DeleteConfirm(path) => { - if input == "y" { - let result = if path.is_dir() { - fs::remove_dir_all(&path) - } else { - fs::remove_file(&path) - }; - if let Err(e) = result { - engine.message = format!("Error deleting: {}", e); - } else { - sidebar.build_rows(); - } - } - } - PromptKind::MoveFile(src) => { - let dest_str = input.trim(); - if !dest_str.is_empty() { - // Resolve destination relative to project root - let dest = if std::path::Path::new(dest_str).is_absolute() { - std::path::PathBuf::from(dest_str) - } else { - sidebar.root.join(dest_str) - }; - match engine.move_file(&src, &dest) { - Ok(()) => { - // engine.move_file resolves the final path; figure out - // the actual destination for reveal_path. - let final_dest = if dest.is_dir() { - dest.join(src.file_name().unwrap_or_default()) - } else { - dest.clone() - }; - sidebar.reveal_path(&final_dest, viewport_height); - engine.message = format!("Moved to '{}'", final_dest.display()); - } - Err(e) => { - engine.message = e; - } - } - } - } - } -} - // ─── Tab bar constants ─────────────────────────────────────────────────────── /// Close-tab × button character (shown on every tab). @@ -746,9 +644,11 @@ pub(super) fn tab_tooltip_at_col( group_id: GroupId, local_col: u16, tabs: &[render::TabInfo], + tab_scroll_offset: usize, ) -> Option { - let mut x: u16 = 0; - for (i, tab) in tabs.iter().enumerate() { + let overflow_cols: u16 = if tab_scroll_offset > 0 { 2 } else { 0 }; + let mut x: u16 = overflow_cols; + for (i, tab) in tabs.iter().enumerate().skip(tab_scroll_offset) { let name_width = tab.name.chars().count() as u16; let tab_width = name_width + TAB_CLOSE_COLS; if local_col >= x && local_col < x + tab_width { @@ -867,7 +767,7 @@ pub(super) fn render_tab_drag_overlay( // For TabReorder, draw a vertical insertion bar at the target position. if let DropZone::TabReorder(gid, idx) = zone { - let tab_bar_info: Option<(u16, u16, &[render::TabInfo])> = + let tab_bar_info: Option<(u16, u16, &[render::TabInfo], usize)> = if let Some(ref split) = screen.editor_group_split { split .group_tab_bars @@ -876,15 +776,21 @@ pub(super) fn render_tab_drag_overlay( .map(|g| { let x = editor_area.x + g.bounds.x as u16; let y = editor_area.y + (g.bounds.y as u16).saturating_sub(tui_tbh); - (x, y, g.tabs.as_slice()) + (x, y, g.tabs.as_slice(), g.tab_scroll_offset) }) } else { - Some((editor_area.x, editor_area.y, screen.tab_bar.as_slice())) + Some(( + editor_area.x, + editor_area.y, + screen.tab_bar.as_slice(), + screen.tab_scroll_offset, + )) }; - if let Some((bar_x, bar_y, tabs)) = tab_bar_info { - let mut insert_x: u16 = 0; - for (i, tab) in tabs.iter().enumerate() { + if let Some((bar_x, bar_y, tabs, scroll_off)) = tab_bar_info { + let ov_cols: u16 = if scroll_off > 0 { 2 } else { 0 }; + let mut insert_x: u16 = ov_cols; + for (i, tab) in tabs.iter().enumerate().skip(scroll_off) { if i == idx { break; } @@ -961,8 +867,9 @@ pub(super) fn compute_tui_tab_drop_zone( // Tab bar region — determine reorder insertion index. if row == tab_bar_row && rel_col >= gx && rel_col < gx + gw { let local_col = rel_col - gx; - let mut x: u16 = 0; - for (i, tab) in gtb.tabs.iter().enumerate() { + let ov_cols: u16 = if gtb.tab_scroll_offset > 0 { 2 } else { 0 }; + let mut x: u16 = ov_cols; + for (i, tab) in gtb.tabs.iter().enumerate().skip(gtb.tab_scroll_offset) { let name_w = tab.name.chars().count() as u16; let tab_w = name_w + TAB_CLOSE_COLS; let mid = x + tab_w / 2; @@ -1013,8 +920,10 @@ pub(super) fn compute_tui_tab_drop_zone( let group_id = engine.active_group; if row == menu_rows { let local_col = rel_col; - let mut x: u16 = 0; - for (i, tab) in layout.tab_bar.iter().enumerate() { + let sg_offset = layout.tab_scroll_offset; + let ov_cols: u16 = if sg_offset > 0 { 2 } else { 0 }; + let mut x: u16 = ov_cols; + for (i, tab) in layout.tab_bar.iter().enumerate().skip(sg_offset) { let name_w = tab.name.chars().count() as u16; let tab_w = name_w + TAB_CLOSE_COLS; let mid = x + tab_w / 2; @@ -1060,6 +969,9 @@ pub(super) fn compute_tui_tab_drop_zone( DropZone::None } +/// Render the tab bar. Returns the number of tabs that were actually drawn +/// (used to update `EditorGroup::tab_visible_count`). +#[allow(clippy::too_many_arguments)] pub(super) fn render_tab_bar( buf: &mut ratatui::buffer::Buffer, area: Rect, @@ -1067,7 +979,8 @@ pub(super) fn render_tab_bar( theme: &Theme, show_split_btns: bool, diff_toolbar: Option<&render::DiffToolbarData>, -) { + tab_scroll_offset: usize, +) -> usize { let bar_bg = rc(theme.tab_bar_bg); for x in area.x..area.x + area.width { @@ -1100,7 +1013,10 @@ pub(super) fn render_tab_bar( }; let mut x = area.x; - for tab in tabs { + let tab_end_for_content = tab_end; + + let mut last_rendered_tab = tabs.len(); // track whether we truncated + for (i, tab) in tabs.iter().enumerate().skip(tab_scroll_offset) { let (fg, bg) = match (tab.active, tab.preview) { (true, true) => (rc(theme.tab_preview_active_fg), rc(theme.tab_active_bg)), (true, false) => (rc(theme.tab_active_fg), rc(theme.tab_active_bg)), @@ -1113,15 +1029,23 @@ pub(super) fn render_tab_bar( Modifier::empty() }; + // Check if this tab would overflow the available space. + let name_w = tab.name.chars().count() as u16; + let tab_w = name_w + TAB_CLOSE_COLS; + if x + tab_w > tab_end_for_content { + last_rendered_tab = i; + break; + } + for ch in tab.name.chars() { - if x >= tab_end { + if x >= tab_end_for_content { break; } set_cell_styled(buf, x, area.y, ch, fg, bg, modifier); x += 1; } // Show ● (modified dot) when dirty, × otherwise (VSCode style). - if x < tab_end { + if x < tab_end_for_content { let (close_ch, close_fg) = if tab.dirty { ('●', rc(theme.foreground)) } else if tab.active { @@ -1133,7 +1057,7 @@ pub(super) fn render_tab_bar( x += 1; } // Trailing separator space. - if x < tab_end { + if x < tab_end_for_content { set_cell(buf, x, area.y, ' ', bar_bg, bar_bg); x += 1; } @@ -1187,6 +1111,9 @@ pub(super) fn render_tab_bar( set_cell(buf, bx, area.y, ' ', btn_fg, bar_bg); set_cell_wide(buf, bx + 1, area.y, '\u{F0931}', btn_fg, bar_bg); } + + // Return how many tabs were actually rendered. + last_rendered_tab.saturating_sub(tab_scroll_offset) } pub(super) fn render_breadcrumb_bar( @@ -3419,21 +3346,74 @@ pub(super) fn render_menu_bar( } } - // Title text drawn right-aligned (dimmed) - if !data.title.is_empty() { - let title_chars: Vec = data.title.chars().collect(); - let title_len = title_chars.len() as u16; - let right_margin = 1u16; - if area.width > title_len + right_margin { - let title_start = area.x + area.width - title_len - right_margin; - if title_start > col { - let dim_fg = rc(theme.line_number_fg); - for (i, ch) in title_chars.iter().enumerate() { - let tx = title_start + i as u16; - if tx < area.x + area.width { - set_cell(buf, tx, y, *ch, dim_fg, bar_bg); - } - } + // Center nav arrows + search box as one unit between menu labels and right edge. + let dim_fg = rc(theme.line_number_fg); + let active_fg = bar_fg; + let menu_end = col; + + // Compute total unit width: "◀ ▶ [ 🔍 title ]" + let arrows_w: u16 = 4; // "◀ ▶" = 4 cols (arrow + space + arrow + space) + let display = if data.title.is_empty() { + String::new() + } else { + format!("\u{1f50d} {}", data.title) + }; + let display_chars: Vec = display.chars().collect(); + let text_len = display_chars.len() as u16; + // Box = [ space text space ] = text + 4 + let box_width = if !display.is_empty() { text_len + 4 } else { 0 }; + let gap = if box_width > 0 { 1u16 } else { 0 }; + let total_unit = arrows_w + gap + box_width; + let right_edge = area.x + area.width; + let available = right_edge.saturating_sub(menu_end); + + if available >= total_unit + 2 { + let unit_start = menu_end + (available - total_unit) / 2; + + // Draw arrows. + let mut ax = unit_start; + let back_fg = if data.nav_back_enabled { + active_fg + } else { + dim_fg + }; + set_cell(buf, ax, y, '◀', back_fg, bar_bg); + ax += 1; + set_cell(buf, ax, y, ' ', bar_bg, bar_bg); + ax += 1; + let fwd_fg = if data.nav_forward_enabled { + active_fg + } else { + dim_fg + }; + set_cell(buf, ax, y, '▶', fwd_fg, bar_bg); + ax += 1; + set_cell(buf, ax, y, ' ', bar_bg, bar_bg); + ax += 1; + + // Draw search box. + if !display.is_empty() { + ax += gap; + let box_start = ax; + let box_end = box_start + box_width; + // Use bar_fg (same as menu text) for box border and text + if box_start < right_edge { + set_cell(buf, box_start, y, '[', dim_fg, bar_bg); + } + if box_start + 1 < right_edge { + set_cell(buf, box_start + 1, y, ' ', bar_fg, bar_bg); + } + for (i, ch) in display_chars.iter().enumerate() { + let cx = box_start + 2 + i as u16; + if cx < right_edge { + set_cell(buf, cx, y, *ch, bar_fg, bar_bg); + } + } + if box_end >= 2 && box_end - 2 < right_edge { + set_cell(buf, box_end - 2, y, ' ', bar_fg, bar_bg); + } + if box_end >= 1 && box_end - 1 < right_edge { + set_cell(buf, box_end - 1, y, ']', dim_fg, bar_bg); } } } diff --git a/tests/swap_recovery.rs b/tests/swap_recovery.rs index 198f1d02..995a40c5 100644 --- a/tests/swap_recovery.rs +++ b/tests/swap_recovery.rs @@ -389,3 +389,45 @@ fn test_swap_recovery_intercepts_normal_keys() { let _ = fs::remove_file(&swap_path); let _ = fs::remove_file(&path); } + +// ── 14. No recovery offered when swap matches disk ────────────────────────── + +#[test] +fn test_swap_no_recovery_when_content_matches_disk() { + let content = "unchanged content\n"; + let path = temp_file("unchanged.rs", content); + let canonical_path = canonical(&path); + let swap_path = swap::swap_path_for(&canonical_path); + + // Create a stale swap with the SAME content as the file on disk. + fs::create_dir_all(swap_path.parent().unwrap()).unwrap(); + { + let mut f = fs::File::create(&swap_path).unwrap(); + writeln!(f, "VIMCODE_SWAP_V1").unwrap(); + writeln!(f, "path: {}", canonical_path.display()).unwrap(); + writeln!(f, "pid: 999999999").unwrap(); // dead PID + writeln!(f, "modified: 2026-01-01T00:00:00Z").unwrap(); + writeln!(f, "---").unwrap(); + write!(f, "{}", content).unwrap(); + } + + let mut e = engine_with(""); + e.open_file_in_tab(&path); + + // No recovery should be offered — swap content matches disk. + assert!( + e.pending_swap_recovery.is_none(), + "no recovery should be offered when swap matches file on disk" + ); + assert!( + e.dialog.is_none(), + "no dialog should be shown for unchanged swap" + ); + + // In a real run, the stale swap would be deleted and replaced with a + // fresh one. In tests, `delete_swap`/`write_swap` are suppressed, so + // we can only verify that the engine state is correct (no dialog). + + let _ = fs::remove_file(&swap_path); + let _ = fs::remove_file(&path); +} diff --git a/tests/visual_mode.rs b/tests/visual_mode.rs index de81b4fa..3f07ee7f 100644 --- a/tests/visual_mode.rs +++ b/tests/visual_mode.rs @@ -208,11 +208,11 @@ fn test_visual_paste_with_uppercase_p() { fn test_visual_paste_multichar_selection() { // Yank "xx" from register, select "cd" and replace let mut e = engine_with("xxcdef\n"); - // yiw yanks "xxcdef" — use yy + specific register instead // Just yank "xx" using visual (cols 0-1) type_chars(&mut e, "vly"); - // After visual yank, cursor stays at col 1. Move to col 2 ('c'). - press(&mut e, 'l'); + // After visual yank, cursor moves to start of selection (col 0, Vim behavior). + // Move to col 2 ('c'). + type_chars(&mut e, "ll"); // select "cd" (cols 2-3) press(&mut e, 'v'); press(&mut e, 'l');