diff --git a/Cargo.toml b/Cargo.toml index 52febda9..707c298e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "vimcode" -version = "0.3.4" +version = "0.3.5" edition = "2021" description = "Vim-like code editor with GTK4 and tree-sitter" license = "MIT" diff --git a/PLAN.md b/PLAN.md index 960b4b4a..a999e30d 100644 --- a/PLAN.md +++ b/PLAN.md @@ -5,8 +5,8 @@ --- ## Recently Completed +- **Session 176**: GTK Performance — Lazy tree loading (one level at a time, expand on demand), Open Folder fix (set_current_dir + engine.cwd for tree refresh). - **Session 175**: Diff View Improvements — Per-group diff toolbar click handling (GTK + TUI), fold-aware scrolling (Ctrl-D/U/F/B/E/Y + scroll wheel skip fold bodies), aligned-sequence fold computation (uses diff_aligned instead of raw diff_results), sc_has_focus fix, TUI glyph revert. 3 new tests. -- **Session 174**: Bug Fixes — Modal dialog system for swap recovery, stderr suppression in TUI, sticky completion popup fix, diff padding suppression with fold filtering, large-file diff fix (removed MAX_LINES guard), GTK Find Panel focus fix, Visual Ctrl-D/U fix, undo/redo LSP notification, diff toolbar cross-group verification. 40 new tests. > Sessions 173 and earlier in **SESSION_HISTORY.md**. diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index c52f5d58..ab5bc7d3 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -1,9 +1,9 @@ # VimCode Project State -**Last updated:** Mar 13, 2026 (Session 175 — Diff View Improvements: Click Handling, Fold-Aware Scrolling, Aligned Folds) | **Tests:** 4263 +**Last updated:** Mar 13, 2026 (Session 176 — GTK Performance: Lazy Tree + Open Folder Fix) | **Tests:** 4266 > Feature documentation lives in **README.md**. -> Per-session implementation notes through Session 175 are in **SESSION_HISTORY.md**. +> Per-session implementation notes through Session 176 are in **SESSION_HISTORY.md**. --- @@ -26,7 +26,7 @@ When implementing a new key/command, add tests covering: ## Recent Work -**Session 175 — Diff View Improvements: Click Handling, Fold-Aware Scrolling, Aligned Folds (4263 tests):** -Per-group diff toolbar click handling (GTK `DiffBtnMap`/`SplitBtnMap` replacing single shared cache; TUI `was_active` tracking). Click precedence fix: diff toolbar checked before split buttons in both backends. Split buttons visible on all groups in diff mode. Fold-aware scrolling: `next_visible_line()`/`prev_visible_line()` on View skip fold bodies; Ctrl-D/U/F/B/E/Y + scroll wheel all fold-aware in normal, visual, and both backends. Aligned-sequence fold computation: `diff_apply_folds()` rewritten to use `diff_aligned` (visual row → buffer line mapping) instead of raw `diff_results`, fixing incorrect folds when files have different line counts. `sc_has_focus` cleared on diff commands. TUI diff toolbar glyphs reverted to Nerd Font with 3-col button width. 3 new tests. +**Session 176 — GTK Performance: Lazy Tree + Open Folder Fix (4266 tests):** +GTK explorer tree lazy loading: replaced eager recursive `build_file_tree()` with `build_file_tree_shallow()` that populates one directory level at a time with dummy placeholder children; `tree_row_expanded()` replaces dummies with real children on demand via `row-expanded` signal. Fixes multi-second startup when opening in large directories (e.g., home). Open Folder fix: `open_folder()` now calls `std::env::set_current_dir()` to update process working directory; `RefreshFileTree` handler uses `engine.cwd` instead of `std::env::current_dir()`. `highlight_file_in_tree` rewritten to walk path components, expanding ancestors lazily. -> Sessions 174 and earlier archived in **SESSION_HISTORY.md**. +> Sessions 175 and earlier archived in **SESSION_HISTORY.md**. diff --git a/SESSION_HISTORY.md b/SESSION_HISTORY.md index 7255834b..32d42af0 100644 --- a/SESSION_HISTORY.md +++ b/SESSION_HISTORY.md @@ -1,10 +1,13 @@ # VimCode Session History Detailed per-session implementation notes archived from PROJECT_STATE.md. -All sessions through 175 archived here. Recent work summary in PROJECT_STATE.md. +All sessions through 176 archived here. Recent work summary in PROJECT_STATE.md. --- +**Session 176 — GTK Performance: Lazy Tree + Open Folder Fix (4266 tests):** +GTK explorer tree lazy loading: replaced eager recursive `build_file_tree()` with `build_file_tree_shallow()` that populates one directory level at a time with dummy placeholder children (`TREE_DUMMY_PATH`); `tree_row_expanded()` replaces dummies with real children on demand via `row-expanded` signal. Fixes multi-second startup when opening in large directories (e.g., home directory). Open Folder fix: `open_folder()` now calls `std::env::set_current_dir(&canonical)` to update process working directory alongside `engine.cwd`; `RefreshFileTree` handler uses `engine.cwd` instead of `std::env::current_dir()`, so tree repopulates with the new folder as root. `highlight_file_in_tree` rewritten to walk path components, expanding ancestors lazily. Removed `find_tree_path_for_file` (no longer needed). + **Session 175 — Diff View Improvements: Click Handling, Fold-Aware Scrolling, Aligned Folds (4263 tests):** Per-group diff toolbar click handling: GTK `DiffBtnMap`/`SplitBtnMap` HashMap types replacing single shared `DiffBtnPositions` cache; `draw_tab_bar` returns `TabBarDrawResult` tuple; `draw_editor` clears maps per frame; `pixel_to_click_target` checks diff toolbar FIRST then split buttons. TUI `was_active` tracking in multi-group click handler; `had_split = was_active || engine.is_in_diff_view()`. Split buttons shown on all groups in diff mode (`show_split = is_active || engine.is_in_diff_view()`). Fold-aware scrolling: `View::next_visible_line(from, count, max_line)` / `View::prev_visible_line(from, count)` skip fold bodies; Ctrl-D/U/F/B (normal + visual), Ctrl-E/Y, and scroll wheel (TUI `scroll_down_visible_for_window` + GTK `scroll_down_visible`) all fold-aware. `Engine::scroll_down_visible(count)` / `scroll_up_visible(count)` + per-window variants. Aligned-sequence fold computation: `diff_apply_folds()` rewritten to use `diff_aligned` (visual row → buffer line mapping via `AlignedDiffEntry`) instead of raw `diff_results`; builds per-visual-row `changed` flag from both sides, marks context, translates back to per-buffer `buf_visible` array, creates independent fold regions per window. Fixes trailing unchanged lines showing on shorter buffer side. `sc_has_focus` cleared in `cmd_git_diff()` / `cmd_git_diff_split()`. TUI diff toolbar glyphs reverted to Nerd Font (`\u{F0143}`/`\u{F0140}`/`\u{F0233}` via `set_cell_wide`) with `DIFF_BTN_COLS = 3`. 3 new tests (fold-aware Ctrl-D, Ctrl-U, scroll_down_visible). diff --git a/src/core/engine.rs b/src/core/engine.rs index ccbfe8ad..e571d070 100644 --- a/src/core/engine.rs +++ b/src/core/engine.rs @@ -13488,9 +13488,10 @@ impl Engine { self.next_group_id = 1; self.mode = Mode::Normal; - // Update cwd + workspace root + // Update cwd + workspace root + process working directory self.cwd = canonical.clone(); self.workspace_root = Some(canonical.clone()); + let _ = std::env::set_current_dir(&canonical); // Update git branch self.git_branch = git::current_branch(&canonical); diff --git a/src/main.rs b/src/main.rs index 6a3950af..581a5b4f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2837,6 +2837,18 @@ impl SimpleComponent for App { // Set the model on the TreeView widgets.file_tree_view.set_model(Some(&tree_store)); + // Lazy-load: populate directory children when the user expands a row. + { + let engine_ref = engine.clone(); + let tree_store_ref = tree_store.clone(); + widgets + .file_tree_view + .connect_row_expanded(move |_tree_view, iter, _tree_path| { + let show_hidden = engine_ref.borrow().settings.show_hidden_files; + tree_row_expanded(&tree_store_ref, iter, show_hidden); + }); + } + // Expand the root node so the tree contents are visible widgets .file_tree_view @@ -4576,25 +4588,18 @@ impl SimpleComponent for App { } Msg::RefreshFileTree => { if let Some(ref store) = self.tree_store { - match std::env::current_dir() { - Ok(cwd) => { - store.clear(); - build_file_tree_with_root( - store, - &cwd, - self.engine.borrow().settings.show_hidden_files, - ); - if let Some(ref tv) = *self.file_tree_view.borrow() { - tv.expand_row(>k4::TreePath::from_indices(&[0]), false); - // Highlight the active file in the tree after rebuild. - if let Some(path) = self.engine.borrow().file_path().cloned() { - highlight_file_in_tree(tv, &path); - } - } - } - Err(e) => { - self.engine.borrow_mut().message = - format!("Error refreshing tree: {}", e); + let cwd = self.engine.borrow().cwd.clone(); + store.clear(); + build_file_tree_with_root( + store, + &cwd, + self.engine.borrow().settings.show_hidden_files, + ); + if let Some(ref tv) = *self.file_tree_view.borrow() { + tv.expand_row(>k4::TreePath::from_indices(&[0]), false); + // Highlight the active file in the tree after rebuild. + if let Some(path) = self.engine.borrow().file_path().cloned() { + highlight_file_in_tree(tv, &path); } } } @@ -12418,7 +12423,12 @@ fn load_css(theme: &Theme) -> gtk4::CssProvider { provider } +/// Sentinel path stored in the dummy placeholder child of unexpanded directories. +const TREE_DUMMY_PATH: &str = "__vimcode_loading__"; + /// Build file tree with a root folder node at the top (like VSCode). +/// Only the root's immediate children are populated; subdirectories are +/// lazily expanded via the `row-expanded` signal (see `tree_row_expanded`). fn build_file_tree_with_root(store: >k4::TreeStore, root: &Path, show_hidden: bool) { let root_name = root .file_name() @@ -12434,12 +12444,13 @@ fn build_file_tree_with_root(store: >k4::TreeStore, root: &Path, show_hidden: (2, &root.to_string_lossy().to_string()), ], ); - build_file_tree(store, Some(&root_iter), root, show_hidden); + build_file_tree_shallow(store, Some(&root_iter), root, show_hidden); } -/// Build file tree recursively -/// TreeStore columns: [Icon(String), Name(String), FullPath(String)] -fn build_file_tree( +/// Populate one level of children under `parent`. For each child directory +/// a dummy placeholder row is added so the expand arrow appears, but its +/// contents are not read until the user actually expands the row. +fn build_file_tree_shallow( store: >k4::TreeStore, parent: Option<>k4::TreeIter>, path: &Path, @@ -12447,7 +12458,7 @@ fn build_file_tree( ) { let entries = match fs::read_dir(path) { Ok(e) => e, - Err(_) => return, // Handle permission errors silently + Err(_) => return, }; let mut entries: Vec<_> = entries.filter_map(|e| e.ok()).collect(); @@ -12456,7 +12467,6 @@ fn build_file_tree( entries.sort_by(|a, b| { let a_is_dir = a.path().is_dir(); let b_is_dir = b.path().is_dir(); - match (a_is_dir, b_is_dir) { (true, false) => std::cmp::Ordering::Less, (false, true) => std::cmp::Ordering::Greater, @@ -12465,16 +12475,18 @@ fn build_file_tree( }); for entry in entries { - let path = entry.path(); + let child_path = entry.path(); let name = entry.file_name().to_string_lossy().to_string(); - // Skip hidden files unless the setting is enabled if name.starts_with('.') && name != "." && name != ".." && !show_hidden { continue; } - let is_dir = path.is_dir(); - let ext = path.extension().and_then(|e| e.to_str()).unwrap_or(""); + let is_dir = child_path.is_dir(); + let ext = child_path + .extension() + .and_then(|e| e.to_str()) + .unwrap_or(""); let icon = if is_dir { "\u{f07b}" // nf-fa-folder } else { @@ -12487,21 +12499,43 @@ fn build_file_tree( &[ (0, &icon), (1, &name), - (2, &path.to_string_lossy().to_string()), + (2, &child_path.to_string_lossy().to_string()), ], ); - // Recursively add subdirectories + // For directories, insert a dummy child so the expand arrow appears. if is_dir { - // Limit recursion depth to prevent hanging on deep trees - let depth = parent.map_or(0, |_| 1); // Simple depth tracking - if depth < 10 { - build_file_tree(store, Some(&iter), &path, show_hidden); - } + store.insert_with_values( + Some(&iter), + None, + &[(0, &""), (1, &""), (2, &TREE_DUMMY_PATH)], + ); } } } +/// Called when a tree row is expanded. Replaces the dummy placeholder with +/// the directory's real contents (one level deep). +fn tree_row_expanded(store: >k4::TreeStore, iter: >k4::TreeIter, show_hidden: bool) { + use gtk4::prelude::TreeModelExt; + let dir_path: String = store.get_value(iter, 2).get().unwrap_or_default(); + if dir_path.is_empty() { + return; + } + + // Check whether the first child is the dummy placeholder. + if let Some(child) = store.iter_children(Some(iter)) { + let child_path: String = store.get_value(&child, 2).get().unwrap_or_default(); + if child_path == TREE_DUMMY_PATH { + // Remove the dummy and populate real children. + store.remove(&child); + build_file_tree_shallow(store, Some(iter), Path::new(&dir_path), show_hidden); + } + // If the first child is NOT the dummy, the directory was already + // populated (e.g. collapsed and re-expanded) — nothing to do. + } +} + /// Get the parent directory for creating a new file/folder, based on the /// currently selected tree row. If a directory is selected, use it. If a /// file is selected, use its parent. Fallback: cwd. @@ -12586,7 +12620,10 @@ fn validate_name(name: &str) -> Result<(), String> { Ok(()) } -/// Find and select file in tree, expanding parents if needed +/// Find and select file in tree, expanding parents if needed. +/// With lazy loading, parent directories may not be populated yet, so we +/// walk the path components from the root, expanding (and thus populating) +/// each ancestor before searching for the next child. fn highlight_file_in_tree(tree_view: >k4::TreeView, file_path: &Path) { let Some(model) = tree_view.model() else { return; @@ -12595,59 +12632,70 @@ fn highlight_file_in_tree(tree_view: >k4::TreeView, file_path: &Path) { return; }; - // Find the file in tree by full path (column 2) - let path_str = file_path.to_string_lossy().to_string(); + // Find the cwd root node (first child of the store). + let Some(root_iter) = tree_store.iter_first() else { + return; + }; + let root_path_str: String = tree_store + .get_value(&root_iter, 2) + .get() + .unwrap_or_default(); + let root_path = PathBuf::from(&root_path_str); + let rel = match file_path.strip_prefix(&root_path) { + Ok(r) => r, + Err(_) => return, // file not under the project root + }; - if let Some(tree_path) = find_tree_path_for_file(tree_store, &path_str, None) { - // Expand parents - if tree_path.depth() > 1 { - let mut parent_path = tree_path.clone(); - parent_path.up(); - tree_view.expand_to_path(&parent_path); + // Walk the relative path components, expanding each directory. + let mut current_iter = root_iter; + for component in rel.components() { + let name = component.as_os_str().to_string_lossy(); + + // Ensure this directory's children are populated (trigger lazy load). + let tp = tree_store.path(¤t_iter); + tree_view.expand_row(&tp, false); + + // Search children for the matching name. + let mut found = false; + if let Some(child_iter) = tree_store.iter_children(Some(¤t_iter)) { + loop { + let child_name: String = tree_store + .get_value(&child_iter, 1) + .get() + .unwrap_or_default(); + if child_name == name.as_ref() { + current_iter = child_iter; + found = true; + break; + } + if !tree_store.iter_next(&child_iter) { + break; + } + } + } + if !found { + return; } - - // Select the row - tree_view.selection().select_path(&tree_path); - - // Scroll to make visible - tree_view.scroll_to_cell( - Some(&tree_path), - None::<>k4::TreeViewColumn>, - false, - 0.0, - 0.0, - ); } -} - -/// Recursively find tree path for given file path string -fn find_tree_path_for_file( - model: >k4::TreeStore, - target_path: &str, - parent: Option<>k4::TreeIter>, -) -> Option { - let n = model.iter_n_children(parent); - for i in 0..n { - let iter = if let Some(parent) = parent { - model.iter_nth_child(Some(parent), i)? - } else { - model.iter_nth_child(None, i)? - }; - - // Check if this row matches - let path_str: String = model.get_value(&iter, 2).get().ok()?; - if path_str == target_path { - return Some(model.path(&iter)); - } + // current_iter now points to the target file/directory. + let tree_path = tree_store.path(¤t_iter); - // Recursively check children - if let Some(found) = find_tree_path_for_file(model, target_path, Some(&iter)) { - return Some(found); - } + // Expand parents so the row is visible. + if tree_path.depth() > 1 { + let mut parent_path = tree_path.clone(); + parent_path.up(); + tree_view.expand_to_path(&parent_path); } - None + tree_view.selection().select_path(&tree_path); + tree_view.scroll_to_cell( + Some(&tree_path), + None::<>k4::TreeViewColumn>, + false, + 0.0, + 0.0, + ); } /// Install the application icon and `.desktop` file to `~/.local/share/` so that