Skip to content

impl Feature request: Code folding #900#1042

Merged
sinelaw merged 6 commits intosinelaw:masterfrom
asukaminato0721:900
Feb 21, 2026
Merged

impl Feature request: Code folding #900#1042
sinelaw merged 6 commits intosinelaw:masterfrom
asukaminato0721:900

Conversation

@asukaminato0721
Copy link
Copy Markdown
Contributor

@asukaminato0721 asukaminato0721 commented Feb 16, 2026

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

@asukaminato0721 asukaminato0721 marked this pull request as ready for review February 16, 2026 18:40
Copilot AI review requested due to automatic review settings February 16, 2026 18:40
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_fold action 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.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +214 to +218
};

if state.folding_ranges.is_empty() {
return;
}
Copy link

Copilot AI Feb 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +2416 to +2422
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);
}
Copy link

Copilot AI Feb 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +2495 to +2499
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)
}
Copy link

Copilot AI Feb 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +2516 to +2521
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));
Copy link

Copilot AI Feb 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +2548 to +2558
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);
}
}
Copy link

Copilot AI Feb 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
@asukaminato0721 asukaminato0721 marked this pull request as ready for review February 16, 2026 19:44
@asukaminato0721 asukaminato0721 marked this pull request as draft February 17, 2026 04:34
@asukaminato0721 asukaminato0721 force-pushed the 900 branch 2 times, most recently from c598c51 to b79dd01 Compare February 17, 2026 07:49
@asukaminato0721 asukaminato0721 marked this pull request as ready for review February 17, 2026 15:30
@sinelaw
Copy link
Copy Markdown
Owner

sinelaw commented Feb 18, 2026

  1. when clicking rapidly i think it's interpreted as double or triple click and not handled - folding/unfolding doesn't work in that case
  2. if cursor is currently placed inside the folded block (second line or more) the folding doesn't work - clicking does nothing, need to move the cursor manually out of the block
  3. mouse wheel and moving down the file with cursor by pressing down key starting before the folded block - also produces weird results, see attached video
  4. syntax highlighting seems sometimes broken by the folding - this could be a general syntax highlighting bug with incremental view, made worse by folding

All of the issues should have e2e tests that reproduce them before fixing so we ensure the fix is robust

fresh-folding-bug.mp4

@asukaminato0721 asukaminato0721 marked this pull request as draft February 19, 2026 09:11
@asukaminato0721 asukaminato0721 marked this pull request as ready for review February 19, 2026 09:54
@sinelaw
Copy link
Copy Markdown
Owner

sinelaw commented Feb 19, 2026

Can you please squash to a handful of commits (separate if they are doing unrelated changes) with meaningful commit messages?

@asukaminato0721 asukaminato0721 force-pushed the 900 branch 2 times, most recently from 26ce0d3 to 836c38d Compare February 19, 2026 10:02
@sinelaw
Copy link
Copy Markdown
Owner

sinelaw commented Feb 19, 2026

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
Also please rebase on latest master because there are conflicts

@sinelaw
Copy link
Copy Markdown
Owner

sinelaw commented Feb 19, 2026

There's still a bug with scrolling when code is folded:

fresh-fold-scroll-bug.mp4

@sinelaw
Copy link
Copy Markdown
Owner

sinelaw commented Feb 19, 2026

It's looking better! Almost there.

  1. 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
    Can you add an e2e test that fails on this (reproduces the bug) and then fix it?
fresh-unfold.mp4
  1. Remove this file from the branch (amend the commit that added it) - 551772438-9be48c1a-15e0-4ac0-80c3-9adeff177969.mp4

  2. 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);

  1. 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.

  1. 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);

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.

@asukaminato0721 asukaminato0721 force-pushed the 900 branch 2 times, most recently from 221b8c3 to 8957395 Compare February 20, 2026 07:18
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
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);
@asukaminato0721
Copy link
Copy Markdown
Contributor Author

some changes are from clippy --fix

@sinelaw
Copy link
Copy Markdown
Owner

sinelaw commented Feb 20, 2026

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
@sinelaw sinelaw merged commit a4b3a9a into sinelaw:master Feb 21, 2026
8 checks passed
@sinelaw
Copy link
Copy Markdown
Owner

sinelaw commented Feb 21, 2026

Thanks! There's a few more bugs but I'll fix them in a new PR

@asukaminato0721 asukaminato0721 deleted the 900 branch February 25, 2026 09:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Feature request: Code folding

3 participants