fresh-editor 0.2.11

A lightweight, fast terminal-based text editor with LSP support and TypeScript plugins
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
// End-to-end tests for side-by-side diff view scrolling
//
// Tests mouse wheel scrolling, scrollbar click-to-jump, and scrollbar drag
// interactions within composite buffer diff views.

use crate::common::harness::EditorTestHarness;
use fresh::model::composite_buffer::{
    CompositeLayout, DiffHunk, LineAlignment, PaneStyle, SourcePane,
};
use fresh::model::event::BufferId;
use fresh::primitives::text_property::TextPropertyEntry;

/// Helper to create a side-by-side diff view with two buffers.
///
/// Creates two virtual buffers with `old_content` and `new_content`, then creates
/// a composite buffer showing them side-by-side with the given diff hunks.
/// Returns the composite buffer id.
fn setup_side_by_side_diff(
    harness: &mut EditorTestHarness,
    old_content: &str,
    new_content: &str,
    hunks: &[DiffHunk],
) -> BufferId {
    // Create two hidden virtual buffers for old and new content
    let old_buffer_id =
        harness
            .editor_mut()
            .create_virtual_buffer("OLD".to_string(), "text".to_string(), true);

    // Set content on the old buffer
    harness
        .editor_mut()
        .set_virtual_buffer_content(old_buffer_id, vec![TextPropertyEntry::text(old_content)])
        .unwrap();

    let new_buffer_id =
        harness
            .editor_mut()
            .create_virtual_buffer("NEW".to_string(), "text".to_string(), true);

    // Set content on the new buffer
    harness
        .editor_mut()
        .set_virtual_buffer_content(new_buffer_id, vec![TextPropertyEntry::text(new_content)])
        .unwrap();

    // Create composite buffer with side-by-side layout
    let sources = vec![
        SourcePane::new(old_buffer_id, "OLD", false).with_style(PaneStyle::old_diff()),
        SourcePane::new(new_buffer_id, "NEW", false).with_style(PaneStyle::new_diff()),
    ];

    let layout = CompositeLayout::SideBySide {
        ratios: vec![0.5, 0.5],
        show_separator: true,
    };

    let composite_id = harness.editor_mut().create_composite_buffer(
        "Diff View".to_string(),
        "diff-view".to_string(),
        layout,
        sources,
    );

    // Set alignment from hunks
    let old_line_count = old_content.lines().count();
    let new_line_count = new_content.lines().count();
    let alignment = LineAlignment::from_hunks(hunks, old_line_count, new_line_count);
    harness
        .editor_mut()
        .set_composite_alignment(composite_id, alignment);

    // Switch to the composite buffer
    harness.editor_mut().switch_buffer(composite_id);
    harness.render().unwrap();

    composite_id
}

/// Generate old and new content for a diff with many lines.
/// Old content has `line_count` lines, new content has `line_count + extra_new_lines` lines.
/// A hunk is created around the middle of the file.
fn generate_diff_content(
    line_count: usize,
    extra_new_lines: usize,
) -> (String, String, Vec<DiffHunk>) {
    let old_content: String = (1..=line_count)
        .map(|i| format!("Line {i} original content here\n"))
        .collect();

    let hunk_start = line_count / 2;
    let hunk_old_count = 3;
    let hunk_new_count = hunk_old_count + extra_new_lines;

    let mut new_lines: Vec<String> = (1..=line_count)
        .map(|i| format!("Line {i} original content here\n"))
        .collect();
    // Replace old lines in the hunk with modified + added lines
    let replacement: Vec<String> = (0..hunk_new_count)
        .map(|i| {
            if i < hunk_old_count {
                format!("Line {} modified content here\n", hunk_start + 1 + i)
            } else {
                format!("Line NEW-{} added content\n", i - hunk_old_count + 1)
            }
        })
        .collect();

    // Replace lines [hunk_start..hunk_start+hunk_old_count] with replacement
    new_lines.splice(hunk_start..hunk_start + hunk_old_count, replacement);
    let new_content: String = new_lines.join("");

    let hunks = vec![DiffHunk::new(
        hunk_start,
        hunk_old_count,
        hunk_start,
        hunk_new_count,
    )];

    (old_content, new_content, hunks)
}

/// Test mouse wheel scrolling down in a side-by-side diff view.
/// After scrolling, the first line should no longer be visible and later lines should appear.
#[test]
fn test_side_by_side_diff_mouse_wheel_scroll_down() {
    let mut harness = EditorTestHarness::new(120, 40).unwrap();

    let (old_content, new_content, hunks) = generate_diff_content(100, 5);
    let _composite_id = setup_side_by_side_diff(&mut harness, &old_content, &new_content, &hunks);

    // Initial screen should show early lines
    let initial_screen = harness.screen_to_string();
    assert!(
        initial_screen.contains("Line 1 original"),
        "Initial view should show Line 1. Screen:\n{}",
        initial_screen
    );

    // Scroll down with mouse wheel (multiple times to accumulate scroll)
    // Each scroll event moves 3 lines
    for _ in 0..5 {
        harness.mouse_scroll_down(60, 20).unwrap();
    }

    let screen_after = harness.screen_to_string();
    // After scrolling down 15 lines, Line 1 should no longer be visible
    assert!(
        !screen_after.contains("Line 1 original"),
        "After scrolling down, Line 1 should not be visible. Screen:\n{}",
        screen_after
    );
}

/// Test mouse wheel scroll up reverses scroll down.
#[test]
fn test_side_by_side_diff_mouse_wheel_scroll_up() {
    let mut harness = EditorTestHarness::new(120, 40).unwrap();

    let (old_content, new_content, hunks) = generate_diff_content(100, 5);
    let _composite_id = setup_side_by_side_diff(&mut harness, &old_content, &new_content, &hunks);

    // Scroll down first so Line 1 is off-screen
    for _ in 0..10 {
        harness.mouse_scroll_down(60, 20).unwrap();
    }

    let screen_scrolled_down = harness.screen_to_string();
    assert!(
        !screen_scrolled_down.contains("Line 1 original"),
        "Line 1 should not be visible after scrolling down"
    );

    // Now scroll all the way back up
    for _ in 0..15 {
        harness.mouse_scroll_up(60, 20).unwrap();
    }

    let screen_after_up = harness.screen_to_string();
    assert!(
        screen_after_up.contains("Line 1 original"),
        "After scrolling back up, Line 1 should be visible again. Screen:\n{}",
        screen_after_up
    );
}

/// Test that mouse wheel scroll doesn't go below zero (stays at top).
#[test]
fn test_side_by_side_diff_mouse_wheel_scroll_clamps_at_top() {
    let mut harness = EditorTestHarness::new(120, 40).unwrap();

    let (old_content, new_content, hunks) = generate_diff_content(100, 5);
    let _composite_id = setup_side_by_side_diff(&mut harness, &old_content, &new_content, &hunks);

    // Try to scroll up when already at top -- should be a no-op
    for _ in 0..10 {
        harness.mouse_scroll_up(60, 20).unwrap();
    }

    // Line 1 should still be visible (we didn't scroll anywhere)
    let screen = harness.screen_to_string();
    assert!(
        screen.contains("Line 1 original"),
        "After scrolling up at top, Line 1 should still be visible. Screen:\n{}",
        screen
    );
}

/// Test scrollbar click-to-jump in a side-by-side diff view.
/// Clicking on the scrollbar track near the bottom should jump the viewport
/// so early lines are no longer visible.
#[test]
fn test_side_by_side_diff_scrollbar_click_jump() {
    let mut harness = EditorTestHarness::new(120, 40).unwrap();

    let (old_content, new_content, hunks) = generate_diff_content(200, 10);
    let _composite_id = setup_side_by_side_diff(&mut harness, &old_content, &new_content, &hunks);

    // Initial screen should show Line 1
    let initial_screen = harness.screen_to_string();
    assert!(
        initial_screen.contains("Line 1 original"),
        "Should show Line 1 initially"
    );

    // The scrollbar is at the rightmost column (119 for 120-width terminal).
    // Click near the bottom of the scrollbar to jump to a later position.
    harness.mouse_click(119, 30).unwrap();
    harness.render().unwrap();

    let screen_after = harness.screen_to_string();
    assert!(
        !screen_after.contains("Line 1 original"),
        "After clicking near bottom of scrollbar, Line 1 should not be visible. Screen:\n{}",
        screen_after
    );
}

/// Test scrollbar click near the top of the track jumps back to earlier content.
#[test]
fn test_side_by_side_diff_scrollbar_click_near_top() {
    let mut harness = EditorTestHarness::new(120, 40).unwrap();

    let (old_content, new_content, hunks) = generate_diff_content(200, 10);
    let _composite_id = setup_side_by_side_diff(&mut harness, &old_content, &new_content, &hunks);

    // First scroll down via mouse wheel so Line 1 is off-screen
    for _ in 0..20 {
        harness.mouse_scroll_down(60, 20).unwrap();
    }

    let screen_scrolled = harness.screen_to_string();
    assert!(
        !screen_scrolled.contains("Line 1 original"),
        "Line 1 should not be visible after scrolling down"
    );

    // Click at the very top of the scrollbar to jump back to the start.
    // The scrollbar starts at row 2 (below menu bar and tab bar).
    harness.mouse_click(119, 2).unwrap();
    harness.render().unwrap();

    let screen_after_click = harness.screen_to_string();
    assert!(
        screen_after_click.contains("Line 1 original"),
        "After clicking at top of scrollbar, Line 1 should be visible. Screen:\n{}",
        screen_after_click
    );
}

/// Test scrollbar drag in a side-by-side diff view.
/// Dragging the scrollbar from top to middle should scroll the view down.
#[test]
fn test_side_by_side_diff_scrollbar_drag_down() {
    let mut harness = EditorTestHarness::new(120, 40).unwrap();

    let (old_content, new_content, hunks) = generate_diff_content(200, 10);
    let _composite_id = setup_side_by_side_diff(&mut harness, &old_content, &new_content, &hunks);

    // Verify starting position
    let initial_screen = harness.screen_to_string();
    assert!(
        initial_screen.contains("Line 1 original"),
        "Should show Line 1 initially"
    );

    // Drag scrollbar from top to middle of content area.
    // Scrollbar is at column 119, content area is roughly rows 2..38.
    harness.mouse_drag(119, 3, 119, 20).unwrap();
    harness.render().unwrap();

    let screen_after = harness.screen_to_string();
    assert!(
        !screen_after.contains("Line 1 original"),
        "After dragging scrollbar down, Line 1 should not be visible. Screen:\n{}",
        screen_after
    );
}

/// Test scrollbar drag back up.
/// After scrolling down, dragging the scrollbar up should bring back earlier content.
#[test]
fn test_side_by_side_diff_scrollbar_drag_up() {
    let mut harness = EditorTestHarness::new(120, 40).unwrap();

    let (old_content, new_content, hunks) = generate_diff_content(200, 10);
    let _composite_id = setup_side_by_side_diff(&mut harness, &old_content, &new_content, &hunks);

    // First scroll down via mouse wheel so Line 1 is off-screen
    for _ in 0..20 {
        harness.mouse_scroll_down(60, 20).unwrap();
    }

    let screen_scrolled = harness.screen_to_string();
    assert!(
        !screen_scrolled.contains("Line 1 original"),
        "Line 1 should not be visible after scrolling down"
    );

    // Wait to avoid double-click detection
    let double_click_delay =
        std::time::Duration::from_millis(harness.config().editor.double_click_time_ms * 2);
    std::thread::sleep(double_click_delay);

    // Drag scrollbar from middle back toward the very top.
    // Dragging to row 0 (above scrollbar) ensures we clamp to the start.
    harness.mouse_drag(119, 20, 119, 0).unwrap();
    harness.render().unwrap();

    let screen_after = harness.screen_to_string();
    assert!(
        screen_after.contains("Line 1 original"),
        "After dragging scrollbar back up, Line 1 should be visible. Screen:\n{}",
        screen_after
    );
}

/// Test that the diff view renders with a scrollbar visible.
#[test]
fn test_side_by_side_diff_scrollbar_visible() {
    let mut harness = EditorTestHarness::new(120, 40).unwrap();

    let (old_content, new_content, hunks) = generate_diff_content(100, 5);
    let _composite_id = setup_side_by_side_diff(&mut harness, &old_content, &new_content, &hunks);

    // The scrollbar should be rendered at the rightmost column (119)
    assert!(
        harness.has_scrollbar_at_column(119),
        "Scrollbar should be visible in the rightmost column of the diff view"
    );
}

/// Test mouse wheel scrolling works on both left and right panes.
/// Scrolling over either pane should produce the same result.
#[test]
fn test_side_by_side_diff_scroll_works_on_both_panes() {
    let mut harness = EditorTestHarness::new(120, 40).unwrap();

    let (old_content, new_content, hunks) = generate_diff_content(100, 5);
    let _composite_id = setup_side_by_side_diff(&mut harness, &old_content, &new_content, &hunks);

    // Scroll over the left pane (column 20)
    for _ in 0..5 {
        harness.mouse_scroll_down(20, 20).unwrap();
    }
    let screen_after_left = harness.screen_to_string();

    // Reset scroll by scrolling up
    for _ in 0..10 {
        harness.mouse_scroll_up(20, 20).unwrap();
    }

    // Verify we're back at the top
    let screen_reset = harness.screen_to_string();
    assert!(
        screen_reset.contains("Line 1 original"),
        "Should have scrolled back to top"
    );

    // Scroll over the right pane (column 80)
    for _ in 0..5 {
        harness.mouse_scroll_down(80, 20).unwrap();
    }
    let screen_after_right = harness.screen_to_string();

    // Both should produce the same screen output (same scroll position)
    assert_eq!(
        screen_after_left, screen_after_right,
        "Scrolling on left pane and right pane should produce the same view"
    );
}

/// Test that scrolling a large diff view shows later content correctly.
#[test]
fn test_side_by_side_diff_scroll_to_later_content() {
    let mut harness = EditorTestHarness::new(120, 40).unwrap();

    let (old_content, new_content, hunks) = generate_diff_content(100, 5);
    let _composite_id = setup_side_by_side_diff(&mut harness, &old_content, &new_content, &hunks);

    // Scroll down past the first half of the document
    for _ in 0..30 {
        harness.mouse_scroll_down(60, 20).unwrap();
    }
    harness.render().unwrap();

    let screen = harness.screen_to_string();

    // Line 1 should not be visible
    assert!(
        !screen.contains("Line 1 original"),
        "Line 1 should not be visible after scrolling past it"
    );

    // Lines from later in the file should be visible
    let has_later_content = screen.contains("Line 80 original")
        || screen.contains("Line 90 original")
        || screen.contains("Line 95 original")
        || screen.contains("Line 100 original")
        || screen.contains("modified content");
    assert!(
        has_later_content,
        "After scrolling down, should see later content. Screen:\n{}",
        screen
    );
}