impl Feature request: Code folding #900#1042
Conversation
There was a problem hiding this comment.
Pull request overview
This PR implements LSP-based code folding functionality for the Fresh editor, addressing feature request #900. The implementation adds support for collapsing and expanding code regions based on folding ranges provided by language servers.
Changes:
- Added marker-based fold range tracking system that auto-adjusts on edits
- Implemented UI layer with gutter fold indicators (▸/▾) and click-to-toggle functionality
- Integrated LSP folding range requests with debouncing and caching
- Added
toggle_foldaction accessible via command palette and gutter clicks
Reviewed changes
Copilot reviewed 32 out of 32 changed files in this pull request and generated 18 comments.
Show a summary per file
| File | Description |
|---|---|
| crates/fresh-editor/src/view/folding.rs | New module implementing FoldManager with marker-based tracking |
| crates/fresh-editor/src/view/ui/split_rendering.rs | Rendering logic for hiding folded lines, appending placeholders, and displaying gutter indicators |
| crates/fresh-editor/src/state.rs | Added folds and folding_ranges fields to EditorState |
| crates/fresh-editor/src/services/lsp/manager.rs | Added folding range capability tracking |
| crates/fresh-editor/src/services/lsp/async_handler.rs | LSP folding range request/response handling |
| crates/fresh-editor/src/services/async_bridge.rs | Added LspFoldingRanges message type |
| crates/fresh-editor/src/input/keybindings.rs | Added ToggleFold action |
| crates/fresh-editor/src/input/commands.rs | Added toggle_fold command definition |
| crates/fresh-editor/src/input/actions.rs | Integrated ToggleFold action routing |
| crates/fresh-editor/src/app/mod.rs | Added folding range state tracking and cleanup |
| crates/fresh-editor/src/app/render.rs | Added folding range refresh during render |
| crates/fresh-editor/src/app/lsp_requests.rs | Debounced folding range request implementation |
| crates/fresh-editor/src/app/lsp_actions.rs | Toggle fold action implementation and LSP data cleanup |
| crates/fresh-editor/src/app/input.rs | Gutter click handler for fold toggling |
| crates/fresh-editor/src/app/file_operations.rs | Schedule folding range refresh on file open |
| crates/fresh-editor/src/app/async_messages.rs | Folding range response handler |
| crates/fresh-editor/locales/*.json (15 files) | Added localized strings for toggle_fold action and commands |
| crates/fresh-core/src/api.rs | Added folding_ranges field to EditorStateSnapshot |
| crates/fresh-editor/src/view/mod.rs | Exported folding module |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 32 out of 32 changed files in this pull request and generated 6 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| }; | ||
|
|
||
| if state.folding_ranges.is_empty() { | ||
| return; | ||
| } |
There was a problem hiding this comment.
toggle_fold_at_line returns early when state.folding_ranges is empty, which prevents expanding/removing an already-collapsed fold if folding ranges haven’t been fetched (or an empty foldingRange response was received). Consider attempting state.folds.remove_by_header_line(...) first; only if no fold was removed should the function require folding_ranges to be present to create a new fold.
| let hidden = range | ||
| .end_line | ||
| .saturating_sub(range.start_line) | ||
| .saturating_add(1); | ||
| total = total.saturating_add(hidden); | ||
| end_line = start_line.saturating_add(total); | ||
| } |
There was a problem hiding this comment.
fold_adjusted_visible_count sums hidden for every resolved fold range whose header is within the (growing) viewport window. If folds can overlap/nest, this double-counts hidden lines and can cause large over-fetching from the buffer. Consider normalizing/merging hidden line intervals (or skipping ranges whose header/hidden span is already covered by a previous range) before adding to total.
| fn is_hidden_line(line_num: usize, ranges: &[crate::view::folding::ResolvedFoldRange]) -> bool { | ||
| ranges | ||
| .iter() | ||
| .any(|range| line_num >= range.start_line && line_num <= range.end_line) | ||
| } |
There was a problem hiding this comment.
is_hidden_line performs a linear scan over all collapsed ranges for every rendered line. With many folds and large viewports this becomes O(lines × folds). Consider precomputing merged hidden intervals and checking membership via pointer walk/binary search instead of iter().any(...) per line.
| line.char_visual_cols.pop(); | ||
| let width = char_width(last_char); | ||
| for _ in 0..width { | ||
| line.visual_to_char.pop(); | ||
| } | ||
| removed_newline = Some((last_char, removed_source, removed_style)); |
There was a problem hiding this comment.
In append_fold_placeholder, newline removal uses char_width('\n'), but char_width returns 0 for control chars. In ViewLineIterator, newline is treated as width 1 when building visual_to_char, so this leaves visual_to_char inconsistent with text/char_* vectors and can break click mapping/cursor math. Treat newline width as 1 here (or otherwise mirror ViewLineIterator’s newline handling) when popping from visual_to_char.
| if let Some((ch, source, style)) = removed_newline { | ||
| let char_idx = line.char_source_bytes.len(); | ||
| let width = char_width(ch); | ||
| line.text.push(ch); | ||
| line.char_source_bytes.push(source); | ||
| line.char_styles.push(style); | ||
| line.char_visual_cols.push(col); | ||
| for _ in 0..width { | ||
| line.visual_to_char.push(char_idx); | ||
| } | ||
| } |
There was a problem hiding this comment.
When re-appending the removed newline in append_fold_placeholder, the code again uses char_width('\n') (0), so it does not extend visual_to_char for the newline even though ends_with_newline remains true. This produces a line where text contains a newline but visual_to_char doesn’t account for it. Re-add the newline with width 1 (consistent with ViewLineIterator’s add_char!(..., 1) for newlines).
c598c51 to
b79dd01
Compare
All of the issues should have e2e tests that reproduce them before fixing so we ensure the fix is robust fresh-folding-bug.mp4 |
|
Can you please squash to a handful of commits (separate if they are doing unrelated changes) with meaningful commit messages? |
26ce0d3 to
836c38d
Compare
|
I just created a more detailed contribution guide - please read quickly (it's short), you can feed it into an LLM to apply on your branch: https://github.com/sinelaw/fresh/blob/master/CONTRIBUTING.md |
104a93d to
3044e96
Compare
|
There's still a bug with scrolling when code is folded: fresh-fold-scroll-bug.mp4 |
|
It's looking better! Almost there.
fresh-unfold.mp4
The old handle_editor_click code had:
compute_buffer_layout (split_rendering.rs:4886) mutates folds during rendering: This means rendering is not idempotent -- re-rendering after cursor movement silently expands folds. This should be handled in the cursor movement / input handling layer, not during rendering. The render path should be read-only with respect to fold state. As-is, the fold auto-expand only triggers when a render happens, which creates a timing dependency.
We should avoid using get_line_number - can we change it to be more incremental (lazy walking over lines until we fill up the view, skip rendering the lines that belong under the fold) and keep the +1 ? I'm not sure. |
221b8c3 to
8957395
Compare
the new state should be added in the per-view per-buffer state (recently refactored) so that if there are TWO views to the same buffer, e.g. via split screen, folding the code in one does not affect folding the code in the other one folding state of each buffer/view should be part of the persisted workspace and restored after quitting and restarting colors should use the theme system
… viewport with real lines. fix a bug
Remaining bug I noticed: after folding a block, moving down causes a
sudden jump and then moving back up - shows that the folding has been
undone (not folded anymore). This doesn't happen when scrolling with
mouse wheel
Ctrl+click selection extension silently removed
The old handle_editor_click code had:
// Both modifiers supported since some terminals intercept shift+click
let extend_selection = modifiers.contains(KeyModifiers::SHIFT)
|| modifiers.contains(KeyModifiers::CONTROL);
Side-effectful fold removal in the render/layout path
compute_buffer_layout (split_rendering.rs:4886) mutates folds during
rendering:
if !folds.is_empty() {
let cursor_positions: Vec = cursors.iter().map(|(_, c)|
c.position).collect();
for pos in cursor_positions {
let _ = folds.remove_if_contains_byte(&mut state.marker_list, pos);
}
}
This means rendering is not idempotent -- re-rendering after cursor
movement silently expands folds. This should be handled in the cursor
movement / input handling layer, not during rendering. The render path
should be read-only with respect to fold state. As-is, the fold
auto-expand only triggers when a render happens, which creates a timing
dependency.
Line numbers in large files are broken now. I'm guessing it's
related to this change - from incremental line counting
(prev_was_source_line + increment) to source-byte lookup
(get_line_number(first_byte))
// Before: if show_line_number && prev_was_source_line {
current_source_line_num += 1; }
// After: current_source_line_num =
state.buffer.get_line_number(first_byte);
|
some changes are from clippy --fix |
|
Please add this commit (cherry pick) to your branch, and see that the new test there passes: d90a9a6 |
…ing_scroll by correcting the gutter line-number baseline when the viewport starts inside a folded range and by reintroducing incremental line-number jumps for collapsed headers
|
Thanks! There's a few more bugs but I'll fix them in a new PR |
impl foldingRange support
Added a folding UI layer: collapsed ranges are marker-tracked, folded lines are hidden, and a placeholder is appended on the fold header line.
Added gutter fold indicators (▾ expanded, ▸ collapsed) and gutter click toggling.
Added the toggle_fold action + command palette entry, with localized strings across all locales.
test_folding_hides_lines_and_adds_placeholder: verifies folded lines are hidden and placeholder is appended on the header line.
test_fold_indicators_collapsed_and_expanded: verifies gutter indicator state for collapsed vs expanded fold ranges.
fix #900