Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
2 changes: 1 addition & 1 deletion PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**.

Expand Down
10 changes: 5 additions & 5 deletions PROJECT_STATE.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
# VimCode Project State

**Last updated:** Mar 13, 2026 (Session 175Diff View Improvements: Click Handling, Fold-Aware Scrolling, Aligned Folds) | **Tests:** 4263
**Last updated:** Mar 13, 2026 (Session 176GTK 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**.

---

Expand All @@ -26,7 +26,7 @@ When implementing a new key/command, add tests covering:

## Recent Work

**Session 175Diff 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 176GTK 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**.
5 changes: 4 additions & 1 deletion SESSION_HISTORY.md
Original file line number Diff line number Diff line change
@@ -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).

Expand Down
3 changes: 2 additions & 1 deletion src/core/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
214 changes: 131 additions & 83 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(&gtk4::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(&gtk4::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);
}
}
}
Expand Down Expand Up @@ -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: &gtk4::TreeStore, root: &Path, show_hidden: bool) {
let root_name = root
.file_name()
Expand All @@ -12434,20 +12444,21 @@ fn build_file_tree_with_root(store: &gtk4::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: &gtk4::TreeStore,
parent: Option<&gtk4::TreeIter>,
path: &Path,
show_hidden: bool,
) {
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();
Expand All @@ -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,
Expand All @@ -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 {
Expand All @@ -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: &gtk4::TreeStore, iter: &gtk4::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.
Expand Down Expand Up @@ -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: &gtk4::TreeView, file_path: &Path) {
let Some(model) = tree_view.model() else {
return;
Expand All @@ -12595,59 +12632,70 @@ fn highlight_file_in_tree(tree_view: &gtk4::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(&current_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(&current_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::<&gtk4::TreeViewColumn>,
false,
0.0,
0.0,
);
}
}

/// Recursively find tree path for given file path string
fn find_tree_path_for_file(
model: &gtk4::TreeStore,
target_path: &str,
parent: Option<&gtk4::TreeIter>,
) -> Option<gtk4::TreePath> {
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(&current_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::<&gtk4::TreeViewColumn>,
false,
0.0,
0.0,
);
}

/// Install the application icon and `.desktop` file to `~/.local/share/` so that
Expand Down
Loading